From 0b646238e9aa1b429ca6dfa223b9406450c17604 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 18 Jun 2026 18:54:47 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=A8=20Wavelet=20=E8=BE=B9?= =?UTF-8?q?=E7=BC=98=E8=8A=82=E7=82=B9=E8=AF=A6=E6=83=85=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=9C=8B=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将旧版 openflare-server 节点观测界面迁移至 Wavelet 前端,包含运行诊断摘要、 系统信息、实时资源、网络流量、24 小时趋势图、请求结构分布与健康事件时间线。 --- .../dashboard/capacity-trend-chart.tsx | 22 +- .../dashboard/traffic-trend-chart.tsx | 22 +- .../nodes/components/disk-io-trend-chart.tsx | 127 +++ .../nodes/components/distribution-list.tsx | 45 ++ .../nodes/components/edge-node-detail.tsx | 209 ++--- .../nodes/components/network-trend-chart.tsx | 127 +++ .../nodes/components/node-observability.tsx | 731 ++++++++++++++---- .../app/(main)/nodes/components/node-utils.ts | 72 +- .../nodes/components/relay-node-detail.tsx | 6 +- .../nodes/components/tunnel-node-detail.tsx | 6 +- .../frontend/lib/services/openflare/index.ts | 7 + .../frontend/lib/services/openflare/types.ts | 54 ++ docs/changelog/index.md | 1 + 13 files changed, 1117 insertions(+), 312 deletions(-) create mode 100644 Wavelet/frontend/app/(main)/nodes/components/disk-io-trend-chart.tsx create mode 100644 Wavelet/frontend/app/(main)/nodes/components/distribution-list.tsx create mode 100644 Wavelet/frontend/app/(main)/nodes/components/network-trend-chart.tsx diff --git a/Wavelet/frontend/app/(main)/components/dashboard/capacity-trend-chart.tsx b/Wavelet/frontend/app/(main)/components/dashboard/capacity-trend-chart.tsx index 7d050780..c6897f78 100644 --- a/Wavelet/frontend/app/(main)/components/dashboard/capacity-trend-chart.tsx +++ b/Wavelet/frontend/app/(main)/components/dashboard/capacity-trend-chart.tsx @@ -32,7 +32,15 @@ const chartConfig = { }, } satisfies ChartConfig; -export function CapacityTrendChart({ points }: { points: CapacityTrendPoint[] }) { +export function CapacityTrendChart({ + points, + title = '24 小时容量趋势', + description = '按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。', +}: { + points: CapacityTrendPoint[]; + title?: string; + description?: string; +}) { const data = points.map((point) => ({ hour: formatTrendHour(point.bucket_started_at), cpu: point.average_cpu_usage_percent, @@ -43,10 +51,8 @@ export function CapacityTrendChart({ points }: { points: CapacityTrendPoint[] }) return ( - 24 小时容量趋势 - - 按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。 - + {title} + {description}
@@ -60,10 +66,8 @@ export function CapacityTrendChart({ points }: { points: CapacityTrendPoint[] }) return ( - 24 小时容量趋势 - - 按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。 - + {title} + {description}
diff --git a/Wavelet/frontend/app/(main)/components/dashboard/traffic-trend-chart.tsx b/Wavelet/frontend/app/(main)/components/dashboard/traffic-trend-chart.tsx index b5d33105..4b3795d2 100644 --- a/Wavelet/frontend/app/(main)/components/dashboard/traffic-trend-chart.tsx +++ b/Wavelet/frontend/app/(main)/components/dashboard/traffic-trend-chart.tsx @@ -26,7 +26,15 @@ const chartConfig = { }, } satisfies ChartConfig; -export function TrafficTrendChart({ points }: { points: TrafficTrendPoint[] }) { +export function TrafficTrendChart({ + points, + title = '24 小时请求趋势', + description = '观察整体请求量和错误量是否出现异常抬升。', +}: { + points: TrafficTrendPoint[]; + title?: string; + description?: string; +}) { const data = points.map((point) => ({ hour: formatTrendHour(point.bucket_started_at), requests: point.request_count, @@ -37,10 +45,8 @@ export function TrafficTrendChart({ points }: { points: TrafficTrendPoint[] }) { return ( - 24 小时请求趋势 - - 观察整体请求量和错误量是否出现异常抬升。 - + {title} + {description}
@@ -54,10 +60,8 @@ export function TrafficTrendChart({ points }: { points: TrafficTrendPoint[] }) { return ( - 24 小时请求趋势 - - 观察整体请求量和错误量是否出现异常抬升。 - + {title} + {description}
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)}` - : ' —'} -

-
-
- ))} +
+
+

+ 状态码分布 +

+ +
+
+

+ Top Domain +

+ +
+
+
+
- {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 }) { - + - +