From 6c9a77d419d7358308836efbe7614efad5932971 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 1 Jun 2026 14:24:07 +0300 Subject: [PATCH] feat(admin-remnawave): merge Traffic into Nodes as a per-node accordion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the separate Traffic tab and fold its per-node inbound/outbound breakdown into the Nodes tab: click a node card to expand its traffic breakdown, click again to collapse (chevron indicator; action buttons stop propagation). Realtime totals (down/up/Σ) move to a compact line above the node list. Realtime stats now load on the Nodes tab. --- src/locales/ru.json | 1 + src/pages/AdminRemnawave.tsx | 280 ++++++++++++++++------------------- 2 files changed, 125 insertions(+), 156 deletions(-) diff --git a/src/locales/ru.json b/src/locales/ru.json index a175dae..bf8081d 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1645,6 +1645,7 @@ "totalUpload": "Отдача", "totalTraffic": "Всего", "online": "онлайн", + "realtimeTitle": "Текущий трафик", "inbounds": "Inbounds", "outbounds": "Outbounds" }, diff --git a/src/pages/AdminRemnawave.tsx b/src/pages/AdminRemnawave.tsx index d7fee72..25dec88 100644 --- a/src/pages/AdminRemnawave.tsx +++ b/src/pages/AdminRemnawave.tsx @@ -139,15 +139,46 @@ function StatCard({ label, value, icon, color = 'accent', subValue }: StatCardPr ); } +function NodeTrafficBreakdown({ + title, + items, +}: { + title: string; + items: { tag: string; downloadBytes: number; uploadBytes: number; totalBytes: number }[]; +}) { + return ( +
+

{title}

+ {[...items] + .sort((a, b) => b.totalBytes - a.totalBytes) + .map((it) => ( +
+ {it.tag} +
+ ↓ {formatBytes(it.downloadBytes)} + ↑ {formatBytes(it.uploadBytes)} + {formatBytes(it.totalBytes)} +
+
+ ))} +
+ ); +} + interface NodeCardProps { node: NodeInfo; providerName?: string; + realtime?: NodeRealtimeStats; onAction: (uuid: string, action: 'enable' | 'disable' | 'restart') => void; isLoading?: boolean; } -function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) { +function NodeCard({ node, providerName, realtime, onAction, isLoading }: NodeCardProps) { const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); 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'; @@ -177,8 +208,19 @@ function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) { const providerLabel = providerName || node.provider_name; const providerFavicon = providerFaviconUrl(node.provider_favicon); + // Per-node traffic breakdown (merged from the former Traffic tab) — shown in + // an accordion that toggles when the card is clicked. + const inbounds = realtime?.inbounds ?? []; + const outbounds = realtime?.outbounds ?? []; + const hasBreakdown = inbounds.length + outbounds.length > 0; + return ( -
+
setExpanded((v) => !v) : undefined} + > {/* Identity + actions */}
@@ -213,7 +255,10 @@ function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) {
+ {hasBreakdown && ( + + )}
@@ -312,6 +367,27 @@ function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) { )}
)} + + {/* Per-node traffic accordion (merged from the former Traffic tab) */} + {expanded && hasBreakdown && ( +
e.stopPropagation()} + > + {inbounds.length > 0 && ( + + )} + {outbounds.length > 0 && ( + + )} +
+ )}
); } @@ -824,6 +900,7 @@ function OverviewTab({ interface NodesTabProps { nodes: NodeInfo[]; providerByUuid: Record; + realtimeByUuid: Record; isLoading: boolean; onRefresh: () => void; onAction: (uuid: string, action: 'enable' | 'disable' | 'restart') => void; @@ -834,6 +911,7 @@ interface NodesTabProps { function NodesTab({ nodes, providerByUuid, + realtimeByUuid, isLoading, onRefresh, onAction, @@ -853,6 +931,13 @@ function NodesTab({ return { total, online, offline, disabled, totalUsers }; }, [nodes]); + const traffic = useMemo(() => { + const vals = Object.values(realtimeByUuid); + const download = vals.reduce((a, n) => a + (n.downloadBytes ?? 0), 0); + const upload = vals.reduce((a, n) => a + (n.uploadBytes ?? 0), 0); + return { download, upload, total: download + upload }; + }, [realtimeByUuid]); + if (isLoading) { return (
@@ -916,6 +1001,26 @@ function NodesTab({
+ {/* Realtime traffic totals (merged from the former Traffic tab) */} + {traffic.total > 0 && ( +
+ + {t('admin.remnawave.traffic.realtimeTitle', 'Realtime traffic')} + + + + {formatBytes(traffic.download)} + + + + {formatBytes(traffic.upload)} + + + {'Σ'} {formatBytes(traffic.total)} + +
+ )} + {/* Nodes List */}
{nodes.length === 0 ? ( @@ -928,6 +1033,7 @@ function NodesTab({ key={node.uuid} node={node} providerName={providerByUuid[node.uuid]} + realtime={realtimeByUuid[node.uuid]} onAction={onAction} isLoading={isActionLoading} /> @@ -1177,138 +1283,7 @@ function SyncTab({ ); } -interface TrafficTabProps { - data: NodeRealtimeStats[] | undefined; - isLoading: boolean; - onRefresh: () => void; -} - -function TrafficTab({ data, isLoading, onRefresh }: TrafficTabProps) { - const { t } = useTranslation(); - - if (isLoading) { - return ( -
-
-
- ); - } - - if (!data || data.length === 0) { - return ( -
-

- {t('admin.remnawave.traffic.noData', 'No traffic data available')} -

- -
- ); - } - - const totalDownload = data.reduce((acc, n) => acc + n.downloadBytes, 0); - const totalUpload = data.reduce((acc, n) => acc + n.uploadBytes, 0); - - return ( -
- {/* Totals */} -
- } - color="green" - /> - } - color="blue" - /> - } - color="purple" - /> -
- - {/* Per-node inbound breakdown */} - {data.map((node) => ( -
-
-
- {node.countryEmoji && {node.countryEmoji}} -

{node.nodeName}

- {node.providerName && ( - - {node.providerName} - - )} - - {node.usersOnline} {t('admin.remnawave.traffic.online', 'online')} - -
- {formatBytes(node.totalBytes)} -
- - {(node.inbounds?.length ?? 0) > 0 && ( -
-

- {t('admin.remnawave.traffic.inbounds', 'Inbounds')} -

- {[...(node.inbounds ?? [])] - .sort((a, b) => b.totalBytes - a.totalBytes) - .map((ib) => ( -
- {ib.tag} -
- ↓ {formatBytes(ib.downloadBytes)} - ↑ {formatBytes(ib.uploadBytes)} - - {formatBytes(ib.totalBytes)} - -
-
- ))} -
- )} - - {(node.outbounds?.length ?? 0) > 0 && ( -
-

- {t('admin.remnawave.traffic.outbounds', 'Outbounds')} -

- {[...(node.outbounds ?? [])] - .sort((a, b) => b.totalBytes - a.totalBytes) - .map((ob) => ( -
- {ob.tag} -
- ↓ {formatBytes(ob.downloadBytes)} - ↑ {formatBytes(ob.uploadBytes)} - - {formatBytes(ob.totalBytes)} - -
-
- ))} -
- )} -
- ))} -
- ); -} - -type TabType = 'overview' | 'nodes' | 'traffic' | 'squads' | 'sync'; +type TabType = 'overview' | 'nodes' | 'squads' | 'sync'; export default function AdminRemnawave() { const { t } = useTranslation(); @@ -1402,15 +1377,12 @@ export default function AdminRemnawave() { enabled: activeTab === 'squads', }); - const { - data: realtimeData, - isLoading: isLoadingRealtime, - refetch: refetchRealtime, - } = useQuery({ + const { data: realtimeData } = useQuery({ queryKey: ['admin-remnawave-realtime'], queryFn: adminRemnawaveApi.getNodesRealtime, - // Loaded on the Nodes tab too — realtime carries the provider name per node. - enabled: activeTab === 'nodes' || activeTab === 'traffic', + // Realtime carries the provider name + per-node inbound/outbound breakdown + // that the Nodes tab shows (provider badge + the per-node traffic accordion). + enabled: activeTab === 'nodes', refetchInterval: 10000, }); @@ -1424,6 +1396,14 @@ export default function AdminRemnawave() { return map; }, [realtimeData]); + // Full realtime stats by node uuid — feeds the per-node traffic accordion + // (inbounds/outbounds) merged into the Nodes tab. + const realtimeByUuid = useMemo(() => { + const map: Record = {}; + for (const r of realtimeData ?? []) map[r.nodeUuid] = r; + return map; + }, [realtimeData]); + const { data: autoSyncStatus, isLoading: isLoadingAutoSync } = useQuery({ queryKey: ['admin-remnawave-autosync'], queryFn: adminRemnawaveApi.getAutoSyncStatus, @@ -1498,11 +1478,6 @@ export default function AdminRemnawave() { icon: , }, { id: 'nodes' as const, label: t('admin.remnawave.tabs.nodes', 'Nodes'), icon: }, - { - id: 'traffic' as const, - label: t('admin.remnawave.tabs.traffic', 'Traffic'), - icon: , - }, { id: 'squads' as const, label: t('admin.remnawave.tabs.squads', 'Squads'), @@ -1598,6 +1573,7 @@ export default function AdminRemnawave() { refetchNodes()} onAction={handleNodeAction} @@ -1606,14 +1582,6 @@ export default function AdminRemnawave() { /> )} - {activeTab === 'traffic' && ( - refetchRealtime()} - /> - )} - {activeTab === 'squads' && (