From 08f12daa400b0117d1502d260a405f4e9bd40f13 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Sun, 31 May 2026 22:53:33 +0300 Subject: [PATCH] feat(admin-remnawave): panel-style node rows with live metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/pages/AdminRemnawave.tsx | 203 +++++++++++++++++++++++++++-------- 1 file changed, 156 insertions(+), 47 deletions(-) diff --git a/src/pages/AdminRemnawave.tsx b/src/pages/AdminRemnawave.tsx index d28b578..b34308e 100644 --- a/src/pages/AdminRemnawave.tsx +++ b/src/pages/AdminRemnawave.tsx @@ -50,6 +50,18 @@ const formatBytes = (bytes: number): string => { // сохранён для случая пустого кода (важно для UI-плейсхолдеров). 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 { label: string; value: string | number; @@ -84,73 +96,75 @@ function StatCard({ label, value, icon, color = 'accent', subValue }: StatCardPr interface NodeCardProps { node: NodeInfo; + providerName?: string; onAction: (uuid: string, action: 'enable' | 'disable' | 'restart') => void; isLoading?: boolean; } -function NodeCard({ node, onAction, isLoading }: NodeCardProps) { +function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) { const { t } = useTranslation(); - const statusColor = node.is_disabled - ? 'text-dark-500' - : node.is_connected && node.is_node_online - ? 'text-success-400' - : 'text-error-400'; - + const isUp = node.is_connected && node.is_node_online && !node.is_disabled; + const dotColor = node.is_disabled ? 'bg-dark-500' : isUp ? 'bg-success-400' : 'bg-error-400'; const statusText = node.is_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.offline', 'Offline'); - return ( -
-
-
-
- {getCountryFlag(node.country_code)} -

{node.name}

- - {statusText} - -
-

{node.address}

+ 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; -
- - - {t('admin.remnawave.nodes.usersOnlineCount', '{{count}} online', { - count: node.users_online ?? 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 ( +
+ {/* Identity + actions */} +
+
+ + + + {node.users_online ?? 0} + + + {getCountryFlag(node.country_code)} + +

{node.name}

+ {providerName && ( + + {providerName} - {node.traffic_used_bytes !== undefined && ( - - {formatBytes(node.traffic_used_bytes)}{' '} - {t('admin.remnawave.nodes.trafficUsed', 'used')} - - )} - {node.xray_uptime > 0 && ( - - {t('admin.remnawave.nodes.uptimeLabel', 'Uptime')}: {formatUptime(node.xray_uptime)} - - )} - {node.versions?.xray && Xray {node.versions.xray}} -
+ )}
-
+
+ + {/* Address + traffic + uptime */} +
+ + + {node.address} + + + {formatBytes(used)} + {trafficPct !== null && ( + + + + )} + {limit > 0 ? formatBytes(limit) : '∞'} + + {node.xray_uptime > 0 && ( + + + {formatUptime(node.xray_uptime)} + + )} +
+ + {/* Live metrics: RAM · load · speeds · versions */} + {(ramPct !== null || loadAvg || rx > 0 || tx > 0 || node.versions) && ( +
+ {ramPct !== null && ( + + 85 + ? 'text-error-400' + : ramPct > 65 + ? 'text-warning-400' + : 'text-dark-400' + } + > + {ramPct}% + + + + + + )} + {loadAvg && {loadAvg}} + + + + {formatSpeed(rx)} + + + + {formatSpeed(tx)} + + + {(node.versions?.node || node.versions?.xray) && ( + + {node.versions?.node && {node.versions.node}} + {node.versions?.xray && xray {node.versions.xray}} + + )} +
+ )}
); } @@ -676,6 +764,7 @@ function OverviewTab({ interface NodesTabProps { nodes: NodeInfo[]; + providerByUuid: Record; isLoading: boolean; onRefresh: () => void; onAction: (uuid: string, action: 'enable' | 'disable' | 'restart') => void; @@ -685,6 +774,7 @@ interface NodesTabProps { function NodesTab({ nodes, + providerByUuid, isLoading, onRefresh, onAction, @@ -775,7 +865,13 @@ function NodesTab({

) : ( nodes.map((node) => ( - + )) )}
@@ -1233,7 +1329,8 @@ export default function AdminRemnawave() { queryKey: ['admin-remnawave-nodes'], queryFn: adminRemnawaveApi.getNodes, enabled: activeTab === 'nodes', - refetchInterval: 15000, + // Fast poll so realtime metrics (RAM, load, speeds) stay live like the panel. + refetchInterval: 5000, }); const { @@ -1253,10 +1350,21 @@ export default function AdminRemnawave() { } = useQuery({ queryKey: ['admin-remnawave-realtime'], 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, }); + // 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 = {}; + for (const r of realtimeData ?? []) { + if (r.providerName) map[r.nodeUuid] = r.providerName; + } + return map; + }, [realtimeData]); + const { data: autoSyncStatus, isLoading: isLoadingAutoSync } = useQuery({ queryKey: ['admin-remnawave-autosync'], queryFn: adminRemnawaveApi.getAutoSyncStatus, @@ -1430,6 +1538,7 @@ export default function AdminRemnawave() { {activeTab === 'nodes' && ( refetchNodes()} onAction={handleNodeAction}