mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
feat(admin-remnawave): panel-style node rows with live metrics
Redesign the Nodes tab to match the Remnawave panel's node list: - per-node row: status dot, online users, flag + name, provider badge, IP, traffic used with a progress bar (∞ when unlimited), uptime - live metrics strip: RAM %, load average (1/5/15m), realtime ↓/↑ throughput and the node + xray versions, sourced from system.stats (memory, loadAvg, interface rx/txBytesPerSec) already returned by /api/nodes/ - provider name comes from the realtime stats (joined by node uuid), which is now loaded on the Nodes tab too - poll the nodes endpoint every 5s so the live metrics stay fresh, like the panel - formatSpeed mirrors the panel's bits-with-1024-step formatting
This commit is contained in:
@@ -50,6 +50,18 @@ const formatBytes = (bytes: number): string => {
|
|||||||
// сохранён для случая пустого кода (важно для UI-плейсхолдеров).
|
// сохранён для случая пустого кода (важно для UI-плейсхолдеров).
|
||||||
const getCountryFlag = (code: string | null | undefined): string => getFlagEmoji(code) || '🌍';
|
const getCountryFlag = (code: string | null | undefined): string => getFlagEmoji(code) || '🌍';
|
||||||
|
|
||||||
|
// Realtime interface throughput (bytes/s) → network-style speed, matching the
|
||||||
|
// panel exactly: the unit step is by 1024 bytes, but the value is shown in bits
|
||||||
|
// (×8 / 1000), e.g. 341 KB/s → "2728 Kb/s", 1.34 MB/s → "10.72 Mb/s".
|
||||||
|
const formatSpeed = (bytesPerSec: number): string => {
|
||||||
|
const bps = bytesPerSec || 0;
|
||||||
|
const bits = bps * 8;
|
||||||
|
if (bps < 1024) return `${bits.toFixed(0)} b/s`;
|
||||||
|
if (bps < 1024 ** 2) return `${(bits / 1000).toFixed(2)} Kb/s`;
|
||||||
|
if (bps < 1024 ** 3) return `${(bits / 1e6).toFixed(2)} Mb/s`;
|
||||||
|
return `${(bits / 1e9).toFixed(2)} Gb/s`;
|
||||||
|
};
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
label: string;
|
label: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
@@ -84,73 +96,75 @@ function StatCard({ label, value, icon, color = 'accent', subValue }: StatCardPr
|
|||||||
|
|
||||||
interface NodeCardProps {
|
interface NodeCardProps {
|
||||||
node: NodeInfo;
|
node: NodeInfo;
|
||||||
|
providerName?: string;
|
||||||
onAction: (uuid: string, action: 'enable' | 'disable' | 'restart') => void;
|
onAction: (uuid: string, action: 'enable' | 'disable' | 'restart') => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NodeCard({ node, onAction, isLoading }: NodeCardProps) {
|
function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const statusColor = node.is_disabled
|
const isUp = node.is_connected && node.is_node_online && !node.is_disabled;
|
||||||
? 'text-dark-500'
|
const dotColor = node.is_disabled ? 'bg-dark-500' : isUp ? 'bg-success-400' : 'bg-error-400';
|
||||||
: node.is_connected && node.is_node_online
|
|
||||||
? 'text-success-400'
|
|
||||||
: 'text-error-400';
|
|
||||||
|
|
||||||
const statusText = node.is_disabled
|
const statusText = node.is_disabled
|
||||||
? t('admin.remnawave.nodes.disabled', 'Disabled')
|
? t('admin.remnawave.nodes.disabled', 'Disabled')
|
||||||
: node.is_connected && node.is_node_online
|
: isUp
|
||||||
? t('admin.remnawave.nodes.online', 'Online')
|
? t('admin.remnawave.nodes.online', 'Online')
|
||||||
: t('admin.remnawave.nodes.offline', 'Offline');
|
: t('admin.remnawave.nodes.offline', 'Offline');
|
||||||
|
|
||||||
|
const s = node.system?.stats;
|
||||||
|
const memTotal = s ? s.memoryUsed + s.memoryFree : 0;
|
||||||
|
const ramPct = memTotal > 0 && s ? Math.round((s.memoryUsed / memTotal) * 100) : null;
|
||||||
|
const loadAvg = s?.loadAvg?.length
|
||||||
|
? s.loadAvg
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((n) => n.toFixed(2))
|
||||||
|
.join(' ')
|
||||||
|
: null;
|
||||||
|
const rx = s?.interface?.rxBytesPerSec ?? 0;
|
||||||
|
const tx = s?.interface?.txBytesPerSec ?? 0;
|
||||||
|
|
||||||
|
const used = node.traffic_used_bytes ?? 0;
|
||||||
|
const limit = node.traffic_limit_bytes ?? 0;
|
||||||
|
const trafficPct = limit > 0 ? Math.min(100, (used / limit) * 100) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4 transition-colors hover:border-dark-600">
|
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-3.5 transition-colors hover:border-dark-600">
|
||||||
<div className="flex items-start justify-between gap-3">
|
{/* Identity + actions */}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span className="text-lg">{getCountryFlag(node.country_code)}</span>
|
<span
|
||||||
<h3 className="truncate font-medium text-dark-100">{node.name}</h3>
|
className={`h-2 w-2 shrink-0 rounded-full ${dotColor} ${isUp ? 'animate-pulse' : ''}`}
|
||||||
<span className={`rounded-full px-2 py-0.5 text-xs ${statusColor} bg-current/10`}>
|
title={statusText}
|
||||||
{statusText}
|
/>
|
||||||
|
<span className="flex shrink-0 items-center gap-1 rounded-md bg-dark-700/60 px-1.5 py-0.5 text-[11px] text-dark-300">
|
||||||
|
<UsersIcon className="h-3 w-3" />
|
||||||
|
{node.users_online ?? 0}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span className="shrink-0 text-base leading-none">
|
||||||
<p className="mt-1 truncate text-xs text-dark-500">{node.address}</p>
|
{getCountryFlag(node.country_code)}
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-dark-400">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<UsersIcon className="h-3.5 w-3.5" />
|
|
||||||
{t('admin.remnawave.nodes.usersOnlineCount', '{{count}} online', {
|
|
||||||
count: node.users_online ?? 0,
|
|
||||||
})}
|
|
||||||
</span>
|
</span>
|
||||||
{node.traffic_used_bytes !== undefined && (
|
<h3 className="truncate font-semibold text-dark-100">{node.name}</h3>
|
||||||
<span>
|
{providerName && (
|
||||||
{formatBytes(node.traffic_used_bytes)}{' '}
|
<span className="shrink-0 truncate rounded-md bg-accent-500/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-accent-300">
|
||||||
{t('admin.remnawave.nodes.trafficUsed', 'used')}
|
{providerName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{node.xray_uptime > 0 && (
|
|
||||||
<span>
|
|
||||||
{t('admin.remnawave.nodes.uptimeLabel', 'Uptime')}: {formatUptime(node.xray_uptime)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{node.versions?.xray && <span>Xray {node.versions.xray}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 gap-1.5">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => onAction(node.uuid, 'restart')}
|
onClick={() => onAction(node.uuid, 'restart')}
|
||||||
disabled={isLoading || node.is_disabled}
|
disabled={isLoading || node.is_disabled}
|
||||||
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-50"
|
className="rounded-lg bg-dark-700 p-1.5 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
title={t('admin.remnawave.nodes.restart', 'Restart')}
|
title={t('admin.remnawave.nodes.restart', 'Restart')}
|
||||||
>
|
>
|
||||||
<ArrowPathIcon className="h-4 w-4" />
|
<ArrowPathIcon className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onAction(node.uuid, node.is_disabled ? 'enable' : 'disable')}
|
onClick={() => onAction(node.uuid, node.is_disabled ? 'enable' : 'disable')}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={`rounded-lg p-2 transition-colors disabled:opacity-50 ${
|
className={`rounded-lg p-1.5 transition-colors disabled:opacity-50 ${
|
||||||
node.is_disabled
|
node.is_disabled
|
||||||
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
|
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
|
||||||
: 'bg-error-500/20 text-error-400 hover:bg-error-500/30'
|
: 'bg-error-500/20 text-error-400 hover:bg-error-500/30'
|
||||||
@@ -161,10 +175,84 @@ function NodeCard({ node, onAction, isLoading }: NodeCardProps) {
|
|||||||
: t('admin.remnawave.nodes.disable', 'Disable')
|
: t('admin.remnawave.nodes.disable', 'Disable')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{node.is_disabled ? <PlayIcon className="h-4 w-4" /> : <StopIcon className="h-4 w-4" />}
|
{node.is_disabled ? (
|
||||||
|
<PlayIcon className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<StopIcon className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Address + traffic + uptime */}
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-dark-400">
|
||||||
|
<span className="flex items-center gap-1 font-mono text-dark-500">
|
||||||
|
<GlobeIcon className="h-3 w-3" />
|
||||||
|
{node.address}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="text-dark-300">{formatBytes(used)}</span>
|
||||||
|
{trafficPct !== null && (
|
||||||
|
<span className="h-1 w-16 overflow-hidden rounded-full bg-dark-700">
|
||||||
|
<span
|
||||||
|
className="block h-full rounded-full bg-accent-500"
|
||||||
|
style={{ width: `${trafficPct}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-dark-500">{limit > 0 ? formatBytes(limit) : '∞'}</span>
|
||||||
|
</span>
|
||||||
|
{node.xray_uptime > 0 && (
|
||||||
|
<span className="flex items-center gap-1 text-dark-500">
|
||||||
|
<PiTimer className="h-3 w-3" />
|
||||||
|
{formatUptime(node.xray_uptime)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live metrics: RAM · load · speeds · versions */}
|
||||||
|
{(ramPct !== null || loadAvg || rx > 0 || tx > 0 || node.versions) && (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-dark-700/60 pt-2 font-mono text-[10.5px] text-dark-500">
|
||||||
|
{ramPct !== null && (
|
||||||
|
<span className="flex items-center gap-1.5" title="RAM">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
ramPct > 85
|
||||||
|
? 'text-error-400'
|
||||||
|
: ramPct > 65
|
||||||
|
? 'text-warning-400'
|
||||||
|
: 'text-dark-400'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ramPct}%
|
||||||
|
</span>
|
||||||
|
<span className="h-1 w-10 overflow-hidden rounded-full bg-dark-700">
|
||||||
|
<span
|
||||||
|
className="block h-full rounded-full bg-dark-400"
|
||||||
|
style={{ width: `${ramPct}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{loadAvg && <span title="load average 1 / 5 / 15 min">{loadAvg}</span>}
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<DownloadIcon className="h-3 w-3 text-success-400/70" />
|
||||||
|
{formatSpeed(rx)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<UploadIcon className="h-3 w-3 text-accent-400/70" />
|
||||||
|
{formatSpeed(tx)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{(node.versions?.node || node.versions?.xray) && (
|
||||||
|
<span className="ml-auto flex items-center gap-2 text-dark-600">
|
||||||
|
{node.versions?.node && <span>{node.versions.node}</span>}
|
||||||
|
{node.versions?.xray && <span>xray {node.versions.xray}</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -676,6 +764,7 @@ function OverviewTab({
|
|||||||
|
|
||||||
interface NodesTabProps {
|
interface NodesTabProps {
|
||||||
nodes: NodeInfo[];
|
nodes: NodeInfo[];
|
||||||
|
providerByUuid: Record<string, string>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onAction: (uuid: string, action: 'enable' | 'disable' | 'restart') => void;
|
onAction: (uuid: string, action: 'enable' | 'disable' | 'restart') => void;
|
||||||
@@ -685,6 +774,7 @@ interface NodesTabProps {
|
|||||||
|
|
||||||
function NodesTab({
|
function NodesTab({
|
||||||
nodes,
|
nodes,
|
||||||
|
providerByUuid,
|
||||||
isLoading,
|
isLoading,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onAction,
|
onAction,
|
||||||
@@ -775,7 +865,13 @@ function NodesTab({
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
nodes.map((node) => (
|
nodes.map((node) => (
|
||||||
<NodeCard key={node.uuid} node={node} onAction={onAction} isLoading={isActionLoading} />
|
<NodeCard
|
||||||
|
key={node.uuid}
|
||||||
|
node={node}
|
||||||
|
providerName={providerByUuid[node.uuid]}
|
||||||
|
onAction={onAction}
|
||||||
|
isLoading={isActionLoading}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1233,7 +1329,8 @@ export default function AdminRemnawave() {
|
|||||||
queryKey: ['admin-remnawave-nodes'],
|
queryKey: ['admin-remnawave-nodes'],
|
||||||
queryFn: adminRemnawaveApi.getNodes,
|
queryFn: adminRemnawaveApi.getNodes,
|
||||||
enabled: activeTab === 'nodes',
|
enabled: activeTab === 'nodes',
|
||||||
refetchInterval: 15000,
|
// Fast poll so realtime metrics (RAM, load, speeds) stay live like the panel.
|
||||||
|
refetchInterval: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -1253,10 +1350,21 @@ export default function AdminRemnawave() {
|
|||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ['admin-remnawave-realtime'],
|
queryKey: ['admin-remnawave-realtime'],
|
||||||
queryFn: adminRemnawaveApi.getNodesRealtime,
|
queryFn: adminRemnawaveApi.getNodesRealtime,
|
||||||
enabled: activeTab === 'traffic',
|
// Loaded on the Nodes tab too — realtime carries the provider name per node.
|
||||||
|
enabled: activeTab === 'nodes' || activeTab === 'traffic',
|
||||||
refetchInterval: 10000,
|
refetchInterval: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Provider name (e.g. "WAICORE") only comes through the realtime stats; map it
|
||||||
|
// by node uuid so the Nodes tab can show the provider badge like the panel.
|
||||||
|
const providerByUuid = useMemo(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const r of realtimeData ?? []) {
|
||||||
|
if (r.providerName) map[r.nodeUuid] = r.providerName;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [realtimeData]);
|
||||||
|
|
||||||
const { data: autoSyncStatus, isLoading: isLoadingAutoSync } = useQuery({
|
const { data: autoSyncStatus, isLoading: isLoadingAutoSync } = useQuery({
|
||||||
queryKey: ['admin-remnawave-autosync'],
|
queryKey: ['admin-remnawave-autosync'],
|
||||||
queryFn: adminRemnawaveApi.getAutoSyncStatus,
|
queryFn: adminRemnawaveApi.getAutoSyncStatus,
|
||||||
@@ -1430,6 +1538,7 @@ export default function AdminRemnawave() {
|
|||||||
{activeTab === 'nodes' && (
|
{activeTab === 'nodes' && (
|
||||||
<NodesTab
|
<NodesTab
|
||||||
nodes={nodesData?.items || []}
|
nodes={nodesData?.items || []}
|
||||||
|
providerByUuid={providerByUuid}
|
||||||
isLoading={isLoadingNodes}
|
isLoading={isLoadingNodes}
|
||||||
onRefresh={() => refetchNodes()}
|
onRefresh={() => refetchNodes()}
|
||||||
onAction={handleNodeAction}
|
onAction={handleNodeAction}
|
||||||
|
|||||||
Reference in New Issue
Block a user