diff --git a/Wavelet/frontend/app/(main)/nodes/components/disk-io-trend-chart.tsx b/Wavelet/frontend/app/(main)/nodes/components/disk-io-trend-chart.tsx
new file mode 100644
index 00000000..285a4aa1
--- /dev/null
+++ b/Wavelet/frontend/app/(main)/nodes/components/disk-io-trend-chart.tsx
@@ -0,0 +1,127 @@
+'use client';
+
+import {Area, AreaChart, CartesianGrid, Line, XAxis, YAxis} from 'recharts';
+
+import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
+import {
+ ChartConfig,
+ ChartContainer,
+ ChartLegend,
+ ChartLegendContent,
+ ChartTooltip,
+ ChartTooltipContent,
+} from '@/components/ui/chart';
+import type {DiskIOTrendPoint} from '@/lib/services/openflare';
+
+import {formatTrendHour} from '../../components/dashboard/dashboard-utils';
+import {formatBytes} from './node-utils';
+
+const chartConfig = {
+ diskRead: {
+ label: '磁盘读',
+ color: 'hsl(var(--chart-3))',
+ },
+ diskWrite: {
+ label: '磁盘写',
+ color: 'hsl(var(--chart-5))',
+ },
+} satisfies ChartConfig;
+
+export function DiskIOTrendChart({
+ points,
+ title = '24 小时磁盘 IO 趋势',
+ description = '观察磁盘读写变化,辅助判断日志放大、缓存抖动或磁盘压力。',
+}: {
+ points: DiskIOTrendPoint[];
+ title?: string;
+ description?: string;
+}) {
+ const data = points.map((point) => ({
+ hour: formatTrendHour(point.bucket_started_at),
+ diskRead: point.disk_read_bytes,
+ diskWrite: point.disk_write_bytes,
+ }));
+
+ if (data.length === 0) {
+ return (
+
+
+ {title}
+ {description}
+
+
+
+ 暂无趋势数据
+
+
+
+ );
+ }
+
+ return (
+
+
+ {title}
+ {description}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ formatBytes(Number(value))}
+ />
+ (
+
+ {formatBytes(Number(value))}
+
+ {name === 'diskRead' ? '读' : '写'}
+
+
+ )}
+ />
+ }
+ />
+
+
+ } />
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/Wavelet/frontend/app/(main)/nodes/components/distribution-list.tsx b/Wavelet/frontend/app/(main)/nodes/components/distribution-list.tsx
new file mode 100644
index 00000000..94b4cd56
--- /dev/null
+++ b/Wavelet/frontend/app/(main)/nodes/components/distribution-list.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import {Progress} from '@/components/ui/progress';
+
+import {formatCompactNumber} from '../../components/dashboard/dashboard-utils';
+
+export function DistributionList({
+ items,
+ emptyMessage = '暂无分布数据',
+}: {
+ items: Array<{ label: string; value: number }>;
+ emptyMessage?: string;
+}) {
+ const sortedItems = [...items]
+ .sort((left, right) => right.value - left.value)
+ .slice(0, 8);
+ const maxValue = sortedItems[0]?.value ?? 0;
+
+ if (sortedItems.length === 0) {
+ return (
+
+ {emptyMessage}
+
+ );
+ }
+
+ return (
+
+ {sortedItems.map((item) => {
+ const ratio = maxValue > 0 ? (item.value / maxValue) * 100 : 0;
+ return (
+
+
+ {item.label || '未知'}
+
+ {formatCompactNumber(item.value)}
+
+
+
+
+ );
+ })}
+
+ );
+}
\ No newline at end of file
diff --git a/Wavelet/frontend/app/(main)/nodes/components/edge-node-detail.tsx b/Wavelet/frontend/app/(main)/nodes/components/edge-node-detail.tsx
index 3a7fe261..028b7dda 100644
--- a/Wavelet/frontend/app/(main)/nodes/components/edge-node-detail.tsx
+++ b/Wavelet/frontend/app/(main)/nodes/components/edge-node-detail.tsx
@@ -2,9 +2,9 @@
import Link from 'next/link';
import {useRouter} from 'next/navigation';
-import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
+import {useMutation, useQueryClient} from '@tanstack/react-query';
import {useState} from 'react';
-import {ArrowLeft, FileText, Loader2, RefreshCw, RotateCcw, Trash2, Upload,} from 'lucide-react';
+import {ArrowLeft, FileText, RefreshCw, RotateCcw, Trash2, Upload,} from 'lucide-react';
import {toast} from 'sonner';
import {
@@ -25,6 +25,7 @@ import {NodeService} from '@/lib/services/openflare';
import {AgentUpdateDialog} from './agent-update-dialog';
import {NodeEditorDialog} from './node-editor-dialog';
+import {NodeObservability} from './node-observability';
import {NodeStatusBadge} from './node-status-badge';
import {
formatRelativeTime,
@@ -47,12 +48,6 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
const [upgradeOpen, setUpgradeOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
- const observabilityQuery = useQuery({
- queryKey: ['openflare', 'node-observability', node.id],
- queryFn: () => NodeService.getObservability(node.id, { hours: 24, limit: 48 }),
- refetchInterval: 10000,
- });
-
const saveMutation = useMutation({
mutationFn: (payload: Parameters[1]) =>
NodeService.updateNode(node.id, payload),
@@ -114,11 +109,6 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
onError: (error) => toast.error(getErrorMessage(error)),
});
- const profile = observabilityQuery.data?.profile ?? null;
- const latestMetric = observabilityQuery.data?.metric_snapshots?.[0] ?? null;
- const activeHealthEvents =
- observabilityQuery.data?.health_events.filter((event) => event.status === 'active') ?? [];
-
const handleRefresh = () => {
void Promise.all([
queryClient.invalidateQueries({ queryKey: nodesQueryKey }),
@@ -126,8 +116,6 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
]);
};
- const isRefreshing = observabilityQuery.isFetching;
-
return (
@@ -153,13 +141,9 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
size="sm"
className="h-7 text-xs"
onClick={handleRefresh}
- disabled={isRefreshing}
+ disabled={false}
>
- {isRefreshing ? (
-
- ) : (
-
- )}
+
刷新
-
-
-
- 基本信息
+
+
+
+
节点信息
边缘代理节点 (edge_node)
-
-
-
- 运行状态
-
+
+
+
+
+
+ 运行状态
+
+
+
+ OpenResty 健康
+
+
+
+ 最近应用
+
+
+
+ IP 地址
+
+ {node.ip || '—'}
+ {node.ip_manual_override ? '(已锁定)' : ''}
+
+
+
+ 地图点位
+ {node.geo_name || '未配置'}
+
+
+ OpenResty 状态消息
+
+ {node.openresty_message || '无'}
+
+
+
+ 创建时间
+ {formatDateTime(node.created_at)}
+
+
+ 更新时间
+ {formatDateTime(node.updated_at)}
+
+ {node.last_error ? (
+
+ {node.last_error}
-
- OpenResty 健康
-
-
-
- 最近应用
-
-
-
- IP 地址
-
- {node.ip || '—'}
- {node.ip_manual_override ? '(已锁定)' : ''}
-
-
-
- 地图点位
- {node.geo_name || '未配置'}
-
-
- 创建时间
- {formatDateTime(node.created_at)}
-
-
- 更新时间
- {formatDateTime(node.updated_at)}
-
- {node.last_error ? (
-
- {node.last_error}
-
- ) : null}
-
-
+ ) : null}
+
+
-
-
-
- 运行快照
- 来自 observability 接口的最近上报
-
-
-
-
- {observabilityQuery.isLoading ? (
-
-
- 加载运行快照中...
-
- ) : observabilityQuery.isError ? (
- {getErrorMessage(observabilityQuery.error)}
- ) : (
- <>
-
- 活动健康事件
- {activeHealthEvents.length}
-
-
- 主机名
- {profile?.hostname || '—'}
-
-
- 操作系统
-
- {profile
- ? `${profile.os_name || 'unknown'} ${profile.os_version || ''}`.trim()
- : '—'}
-
-
-
- CPU 使用率
-
- {latestMetric ? `${latestMetric.cpu_usage_percent.toFixed(1)}%` : '—'}
-
-
-
- OpenResty 连接数
- {latestMetric?.openresty_connections ?? '—'}
-
-
- OpenResty 状态消息
-
- {node.openresty_message || '无'}
-
-
- >
- )}
-
-
-
+
({
+ hour: formatTrendHour(point.bucket_started_at),
+ openrestyRx: point.openresty_rx_bytes,
+ openrestyTx: point.openresty_tx_bytes,
+ }));
+
+ if (data.length === 0) {
+ return (
+
+
+ {title}
+ {description}
+
+
+
+ 暂无趋势数据
+
+
+
+ );
+ }
+
+ return (
+
+
+ {title}
+ {description}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ formatBytesPerSecond(Number(value), 3600)}
+ />
+ (
+
+ {formatBytesPerSecond(Number(value), 3600)}
+
+ {name === 'openrestyRx' ? '入站' : '出站'}
+
+
+ )}
+ />
+ }
+ />
+
+
+ } />
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/Wavelet/frontend/app/(main)/nodes/components/node-observability.tsx b/Wavelet/frontend/app/(main)/nodes/components/node-observability.tsx
index 1ab6f50e..6c679fd3 100644
--- a/Wavelet/frontend/app/(main)/nodes/components/node-observability.tsx
+++ b/Wavelet/frontend/app/(main)/nodes/components/node-observability.tsx
@@ -18,21 +18,35 @@ import {
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {formatDateTime} from '@/lib/utils';
+import type {NodeHealthEvent, NodeItem, NodeSystemProfile} from '@/lib/services/openflare';
import {NodeService} from '@/lib/services/openflare';
+import {CapacityTrendChart} from '../../components/dashboard/capacity-trend-chart';
+import {TrafficTrendChart} from '../../components/dashboard/traffic-trend-chart';
+import {DiskIOTrendChart} from './disk-io-trend-chart';
+import {DistributionList} from './distribution-list';
+import {NetworkTrendChart} from './network-trend-chart';
import {NodeStatusBadge} from './node-status-badge';
import {
+ aggregateTrafficBreakdown,
formatBytes,
+ formatBytesPerSecond,
+ formatPercent,
formatRelativeTime,
formatUptime,
formatUsageRatio,
getErrorMessage,
getHealthEventLabel,
getHealthEventTone,
+ getNodeStatusLabel,
+ getNodeStatusTone,
+ getOpenrestyStatusLabel,
+ getOpenrestyStatusTone,
isMeaningfulTime,
} from './node-utils';
type HealthEventFilter = 'all' | 'active' | 'resolved';
+type NodeObservabilityVariant = 'edge' | 'compact';
function MetricBar({
label,
@@ -66,11 +80,205 @@ function MetricBar({
);
}
+function SummaryStat({
+ label,
+ value,
+ hint,
+}: {
+ label: string;
+ value: string;
+ hint: string;
+}) {
+ return (
+
+
+ {label}
+ {value}
+ {hint}
+
+
+ );
+}
+
+function SystemProfileCard({
+ profile,
+ nodeName,
+}: {
+ profile: NodeSystemProfile;
+ nodeName: string;
+}) {
+ return (
+
+
+
+
主机名
+
{profile.hostname || nodeName}
+
+
+
操作系统
+
+ {profile.os_name || 'unknown'}
+ {profile.os_version ? ` ${profile.os_version}` : ''}
+
+
+
+
内核 / 架构
+
+ {profile.kernel_version || 'unknown'} · {profile.architecture || 'unknown'}
+
+
+
+
在线时长
+
{formatUptime(profile.uptime_seconds)}
+
+
+
+
+
+
CPU
+
{profile.cpu_model || 'unknown'}
+
{profile.cpu_cores || 0} 核
+
+
+
总内存
+
{formatBytes(profile.total_memory_bytes)}
+
+
+
总存储
+
{formatBytes(profile.total_disk_bytes)}
+
+
+
上报时间
+
+ {isMeaningfulTime(profile.reported_at)
+ ? formatDateTime(profile.reported_at)
+ : '—'}
+
+
+
+
+ );
+}
+
+function HealthEventTimeline({
+ events,
+ allEventsCount,
+ healthEventFilter,
+ onFilterChange,
+ onCleanup,
+ cleanupPending,
+}: {
+ events: NodeHealthEvent[];
+ allEventsCount: number;
+ healthEventFilter: HealthEventFilter;
+ onFilterChange: (filter: HealthEventFilter) => void;
+ onCleanup: () => void;
+ cleanupPending: boolean;
+}) {
+ return (
+
+
+
+ 健康事件时间线
+ 保留活动与已恢复事件,帮助判断运行状态。
+
+
+
+
+ {allEventsCount > 0 ? (
+ <>
+
+ {(
+ [
+ ['all', '全部事件'],
+ ['active', '活动中'],
+ ['resolved', '已恢复'],
+ ] as const
+ ).map(([filter, label]) => (
+
+ ))}
+
+
+ {events.length === 0 ? (
+ 当前筛选下没有健康事件。
+ ) : null}
+
+ {events.slice(0, 8).map((event) => (
+
+
+
+
+
+
+ {event.message || '暂无详细消息'}
+
+
+
+ 首次触发:
+ {isMeaningfulTime(event.first_triggered_at)
+ ? ` ${formatDateTime(event.first_triggered_at)}`
+ : ' —'}
+
+
+ 最近触发:
+ {isMeaningfulTime(event.last_triggered_at)
+ ? ` ${formatDateTime(event.last_triggered_at)}`
+ : ' —'}
+
+
+ 恢复时间:
+ {isMeaningfulTime(event.resolved_at) && event.resolved_at
+ ? ` ${formatDateTime(event.resolved_at)}`
+ : ' —'}
+
+
+
+ ))}
+ >
+ ) : (
+ 节点当前还没有上报健康事件记录。
+ )}
+
+
+ );
+}
+
export function NodeObservability({
nodeId,
- connectionHint = '活动连接数',
+ node,
+ variant = 'edge',
+ connectionHint = 'OpenResty 当前连接',
}: {
nodeId: number;
+ node?: NodeItem;
+ variant?: NodeObservabilityVariant;
connectionHint?: string;
}) {
const queryClient = useQueryClient();
@@ -92,9 +300,12 @@ export function NodeObservability({
: '当前没有可清理的健康事件日志',
);
setCleanupOpen(false);
- await queryClient.invalidateQueries({
- queryKey: ['openflare', 'node-observability', nodeId],
- });
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: ['openflare', 'node-observability', nodeId],
+ }),
+ queryClient.invalidateQueries({ queryKey: ['openflare', 'dashboard', 'overview'] }),
+ ]);
},
onError: (error) => toast.error(getErrorMessage(error)),
});
@@ -121,6 +332,21 @@ export function NodeObservability({
}
}, [activeHealthEvents, healthEventFilter, observability?.health_events, resolvedHealthEvents]);
+ const statusCodeDistribution = useMemo(
+ () => aggregateTrafficBreakdown(observability?.traffic_reports, 'status_codes_json'),
+ [observability?.traffic_reports],
+ );
+ const topDomains = useMemo(
+ () => aggregateTrafficBreakdown(observability?.traffic_reports, 'top_domains_json'),
+ [observability?.traffic_reports],
+ );
+ const trafficSummary = observability?.analytics?.traffic ?? null;
+ const healthSummary = observability?.analytics?.health ?? null;
+ const topSourceCountry =
+ observability?.analytics?.distributions?.source_countries?.[0] ?? null;
+ const latestHealthEvent = activeHealthEvents[0] ?? null;
+ const dominantStatusCode = statusCodeDistribution[0] ?? null;
+ const dominantDomain = topDomains[0] ?? null;
const memoryUsageRatio = formatUsageRatio(
latestMetric?.memory_used_bytes,
latestMetric?.memory_total_bytes,
@@ -129,6 +355,8 @@ export function NodeObservability({
latestMetric?.storage_used_bytes,
latestMetric?.storage_total_bytes,
);
+ const trends = observability?.trends;
+ const nodeName = node?.name ?? observability?.node_id ?? String(nodeId);
if (observabilityQuery.isLoading) {
return (
@@ -151,97 +379,202 @@ export function NodeObservability({
);
}
+ if (variant === 'compact') {
+ return (
+
+
+
+
+
+
+
+
+
+
+ 系统画像
+ 节点上报的主机与硬件信息
+
+
+ {profile ? (
+
+ ) : (
+
+ 节点已接入,但尚未上报完整系统画像。
+
+ )}
+
+
+
+
+
+ 实时资源快照
+ 最近一次 metrics 上报
+
+
+ {latestMetric ? (
+
+
+
+
+
+
+ ) : (
+ 节点已接入,但尚未上报资源快照。
+ )}
+
+
+
+
+
setCleanupOpen(true)}
+ cleanupPending={cleanupMutation.isPending}
+ />
+
+
+
+
+ 清理健康事件日志
+
+ 该操作会删除此节点在控制端记录的所有健康诊断事件历史,不会影响后续新事件上报。
+
+
+
+ 取消
+ cleanupMutation.mutate()}
+ >
+ {cleanupMutation.isPending ? '清理中...' : '确认清理'}
+
+
+
+
+
+ );
+ }
+
return (
- <>
-
-
-
- 运行诊断
-
- {activeHealthEvents.length ? `${activeHealthEvents.length} 个活动异常` : '运行稳定'}
-
-
-
-
-
- 系统核心
-
- {profile?.hostname || '—'}
-
-
-
-
-
- 在线时长
-
- {formatUptime(profile?.uptime_seconds)}
-
-
-
+
+
+
+
+
+
-
+
- 系统画像
- 节点上报的主机与硬件信息
+ 系统信息
-
+
{profile ? (
- <>
-
- 操作系统
-
- {profile.os_name || 'unknown'}
- {profile.os_version ? ` ${profile.os_version}` : ''}
-
-
-
- 内核 / 架构
-
- {profile.kernel_version || 'unknown'} · {profile.architecture || 'unknown'}
-
-
-
- CPU
-
- {profile.cpu_model || 'unknown'}({profile.cpu_cores || 0} 核)
-
-
-
- 总内存
- {formatBytes(profile.total_memory_bytes)}
-
-
- 总存储
- {formatBytes(profile.total_disk_bytes)}
-
-
- 上报时间
-
- {isMeaningfulTime(profile.reported_at)
- ? formatDateTime(profile.reported_at)
- : '—'}
-
-
- >
+
) : (
- 节点已接入,但尚未上报完整系统画像。
+
+ 节点已经接入,但还没有上报完整系统画像。
+
)}
- 实时资源快照
- 最近一次 metrics 上报
+ 实时资源
{latestMetric ? (
) : (
- 节点已接入,但尚未上报资源快照。
+ 节点已经接入,但还没有上报资源快照。
+ )}
+
+
+
+
+
+ 网络流量
+
+
+ {latestMetric ? (
+ <>
+ {node ? (
+
+
+
+
+
+ ) : null}
+
+
+
+
+ OpenResty 吞吐
+
+
+
入站:{formatBytesPerSecond(latestMetric.openresty_rx_bytes, 60)}
+
出站:{formatBytesPerSecond(latestMetric.openresty_tx_bytes, 60)}
+
+
+
+
+ 节点网络
+
+
+
入站:{formatBytes(latestMetric.network_rx_bytes)}
+
出站:{formatBytes(latestMetric.network_tx_bytes)}
+
+
+
+
+
+
+
+ 最近窗口请求
+
+
+ {trafficSummary
+ ? trafficSummary.request_count.toLocaleString('zh-CN')
+ : '—'}
+
+
+ {trafficSummary
+ ? `QPS ${trafficSummary.estimated_qps.toFixed(1)} · UV ${trafficSummary.unique_visitor_count}`
+ : '暂无窗口流量摘要'}
+
+
+
+
+ 最近窗口错误
+
+
+ {trafficSummary ? trafficSummary.error_count.toLocaleString('zh-CN') : '—'}
+
+
+ {trafficSummary
+ ? `错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%`
+ : '暂无错误率摘要'}
+
+
+
+ >
+ ) : (
+
+ 节点已经接入,但还没有上报网络流量相关快照。
+
)}
-
-
-
- 健康事件时间线
- 保留活动与已恢复事件,用于运行异常诊断
-
-
-
-
- {observability?.health_events.length ? (
- <>
-
- {(['all', 'active', 'resolved'] as const).map((filter) => (
-
- ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 请求结构分布
+
+ 聚合最近 24 小时窗口上报,帮助判断错误集中在哪些状态码、流量集中在哪些域名。
+
+
+
+
+
+
主状态码
+
{dominantStatusCode?.label ?? '—'}
+
+ {dominantStatusCode ? `${dominantStatusCode.value} 次` : '暂无状态码分布'}
+
+
+
Top Domain
+
+ {dominantDomain?.label ?? '—'}
+
+
+ {dominantDomain ? `${dominantDomain.value} 次` : '暂无域名分布'}
+
+
+
+
已恢复事件
+
{resolvedHealthEvents.length}
+
最近 24 小时已恢复健康事件
+
+
- {filteredHealthEvents.slice(0, 8).map((event) => (
-
-
-
-
-
-
- {event.message || '暂无详细消息'}
-
-
-
- 首次触发:
- {isMeaningfulTime(event.first_triggered_at)
- ? ` ${formatDateTime(event.first_triggered_at)}`
- : ' —'}
-
-
- 最近触发:
- {isMeaningfulTime(event.last_triggered_at)
- ? ` ${formatDateTime(event.last_triggered_at)}`
- : ' —'}
-
-
- 恢复时间:
- {isMeaningfulTime(event.resolved_at) && event.resolved_at
- ? ` ${formatDateTime(event.resolved_at)}`
- : ' —'}
-
-
-
- ))}
+
+
+
- {filteredHealthEvents.length === 0 ? (
-
当前筛选下没有健康事件。
- ) : null}
- >
- ) : (
-
节点当前还没有上报健康事件记录。
- )}
-
-
+
setCleanupOpen(true)}
+ cleanupPending={cleanupMutation.isPending}
+ />
+
@@ -380,6 +797,6 @@ export function NodeObservability({
- >
+
);
}
\ No newline at end of file
diff --git a/Wavelet/frontend/app/(main)/nodes/components/node-utils.ts b/Wavelet/frontend/app/(main)/nodes/components/node-utils.ts
index 6fd3886d..f87d506c 100644
--- a/Wavelet/frontend/app/(main)/nodes/components/node-utils.ts
+++ b/Wavelet/frontend/app/(main)/nodes/components/node-utils.ts
@@ -1,4 +1,10 @@
-import type {ApplyResult, NodeItem, NodeStatus, OpenrestyStatus} from '@/lib/services/openflare';
+import type {
+ ApplyResult,
+ NodeItem,
+ NodeStatus,
+ NodeTrafficReport,
+ OpenrestyStatus,
+} from '@/lib/services/openflare';
export const WS_CONNECTED_LAST_SEEN = '__OPENFLARE_WS_CONNECTED__';
export const FLARED_WS_CONNECTED_LAST_SEEN = '__OPENFLARE_FLARED_WS_CONNECTED__';
@@ -150,9 +156,12 @@ export function buildTunnelDockerInstallCommand(serverUrl: string, tunnelToken:
}
export function formatBytes(bytes?: number | null, decimals = 1) {
- if (!bytes || !Number.isFinite(bytes) || bytes <= 0) {
+ if (bytes === undefined || bytes === null || !Number.isFinite(bytes)) {
return '—';
}
+ if (bytes <= 0) {
+ return '0 B';
+ }
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
@@ -160,6 +169,65 @@ export function formatBytes(bytes?: number | null, decimals = 1) {
return `${value.toFixed(decimals)} ${units[index]}`;
}
+export function formatPercent(value?: number | null) {
+ if (value === undefined || value === null || !Number.isFinite(value)) {
+ return '—';
+ }
+ return `${value.toFixed(1)}%`;
+}
+
+export function formatBytesPerSecond(
+ value?: number | null,
+ windowSeconds = 1,
+) {
+ if (value === undefined || value === null || !Number.isFinite(value)) {
+ return '—';
+ }
+ if (windowSeconds <= 0) {
+ return '—';
+ }
+ return `${formatBytes(value / windowSeconds)}/s`;
+}
+
+export function parseTrafficMap(value?: string | null) {
+ if (!value) {
+ return {} as Record;
+ }
+ try {
+ const parsed = JSON.parse(value) as Record;
+ return Object.entries(parsed).reduce>((result, [key, count]) => {
+ if (typeof count === 'number' && Number.isFinite(count)) {
+ result[key] = count;
+ }
+ return result;
+ }, {});
+ } catch {
+ return {} as Record;
+ }
+}
+
+export function aggregateTrafficBreakdown(
+ reports: NodeTrafficReport[] | undefined,
+ field: 'status_codes_json' | 'top_domains_json',
+) {
+ const summary = new Map();
+ for (const report of reports ?? []) {
+ const parsed = parseTrafficMap(report[field]);
+ for (const [key, value] of Object.entries(parsed)) {
+ summary.set(key, (summary.get(key) ?? 0) + value);
+ }
+ }
+ return Array.from(summary.entries())
+ .sort((left, right) => {
+ if (right[1] === left[1]) {
+ return left[0].localeCompare(right[0]);
+ }
+ return right[1] - left[1];
+ })
+ .slice(0, 6)
+ .map(([label, value]) => ({ label, value }));
+}
+
export function formatUsageRatio(used?: number | null, total?: number | null) {
if (!used || !total || total <= 0) {
return null;
diff --git a/Wavelet/frontend/app/(main)/nodes/components/relay-node-detail.tsx b/Wavelet/frontend/app/(main)/nodes/components/relay-node-detail.tsx
index 499b8455..d3b81d5b 100644
--- a/Wavelet/frontend/app/(main)/nodes/components/relay-node-detail.tsx
+++ b/Wavelet/frontend/app/(main)/nodes/components/relay-node-detail.tsx
@@ -347,7 +347,11 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
-
+
-
+