From ebd452cb3645e98cf77ae08dc70af3a09f23500f Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 14 Mar 2026 11:12:45 +0800 Subject: [PATCH] feat: refresh node detail observability view --- .../nodes/components/node-detail-page.tsx | 429 +++++++++++++++++- 1 file changed, 407 insertions(+), 22 deletions(-) diff --git a/atsf_server/web/features/nodes/components/node-detail-page.tsx b/atsf_server/web/features/nodes/components/node-detail-page.tsx index 7f8f6862..2a372ae5 100644 --- a/atsf_server/web/features/nodes/components/node-detail-page.tsx +++ b/atsf_server/web/features/nodes/components/node-detail-page.tsx @@ -23,6 +23,7 @@ import { getApplyLogs } from '@/features/apply-logs/api/apply-logs'; import { deleteNode, getNodeAgentRelease, + getNodeObservability, getNodes, requestNodeOpenrestyRestart, requestNodeAgentUpdate, @@ -31,6 +32,7 @@ import { import type { NodeAgentReleaseInfo, NodeMutationPayload, + NodeObservability, } from '@/features/nodes/types'; import { CodeBlock, @@ -94,6 +96,112 @@ async function copyToClipboard(value: string) { await navigator.clipboard.writeText(value); } +function formatBytes(value?: number | null) { + if (!value || value <= 0) { + return '—'; + } + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let current = value; + let index = 0; + while (current >= 1024 && index < units.length - 1) { + current /= 1024; + index += 1; + } + return `${current.toFixed(current >= 100 || index === 0 ? 0 : 1)} ${units[index]}`; +} + +function formatPercent(value?: number | null) { + if (value === undefined || value === null || Number.isNaN(value)) { + return '—'; + } + return `${value.toFixed(1)}%`; +} + +function formatUsageRatio(used?: number | null, total?: number | null) { + if (!used || !total || total <= 0) { + return null; + } + return Math.max(0, Math.min(100, (used / total) * 100)); +} + +function formatUptime(seconds?: number | null) { + if (!seconds || seconds <= 0) { + return '—'; + } + + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (days > 0) { + return `${days} 天 ${hours} 小时`; + } + if (hours > 0) { + return `${hours} 小时 ${minutes} 分钟`; + } + return `${minutes} 分钟`; +} + +function getHealthEventVariant( + event: NodeObservability['health_events'][number], +): 'success' | 'warning' | 'danger' | 'info' { + if (event.status === 'resolved') { + return 'success'; + } + if (event.severity === 'critical') { + return 'danger'; + } + if (event.severity === 'warning') { + return 'warning'; + } + return 'info'; +} + +function getHealthEventLabel( + event: NodeObservability['health_events'][number], +) { + return event.event_type.replaceAll('_', ' '); +} + +function MetricBar({ + label, + value, + progress, + hint, +}: { + label: string; + value: string; + progress?: number | null; + hint?: string; +}) { + return ( +
+
+
+

+ {label} +

+ {hint ? ( +

+ {hint} +

+ ) : null} +
+

+ {value} +

+
+
+
+
+
+ ); +} + export function NodeDetailPage({ nodeId }: { nodeId: string }) { const router = useRouter(); const queryClient = useQueryClient(); @@ -154,6 +262,14 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { refetchInterval: 5000, }); + const observabilityQuery = useQuery({ + queryKey: ['node-observability', nodeId], + queryFn: () => + getNodeObservability(Number(nodeId), { hours: 24, limit: 48 }), + enabled: Boolean(nodeId), + refetchInterval: 10000, + }); + useEffect(() => { if (typeof window !== 'undefined' && !serverUrl) { setServerUrl(window.location.origin); @@ -336,6 +452,20 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { ? previewAgentReleaseQuery.isFetching : stableAgentReleaseQuery.isFetching; const applyLogs = applyLogsQuery.data ?? []; + const observability = observabilityQuery.data ?? null; + const latestMetricSnapshot = observability?.metric_snapshots?.[0] ?? null; + const latestTrafficReport = observability?.traffic_reports?.[0] ?? null; + const activeHealthEvents = + observability?.health_events.filter((event) => event.status === 'active') ?? + []; + const memoryUsageRatio = formatUsageRatio( + latestMetricSnapshot?.memory_used_bytes, + latestMetricSnapshot?.memory_total_bytes, + ); + const storageUsageRatio = formatUsageRatio( + latestMetricSnapshot?.storage_used_bytes, + latestMetricSnapshot?.storage_total_bytes, + ); const isTargetVersionApplied = activeConfigVersion !== null && activeConfigVersion.version === node.current_version; @@ -363,7 +493,10 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { updateAgentMutation.mutate(selectedAgentRelease ?? null); }; - const isRefreshing = nodesQuery.isFetching || applyLogsQuery.isFetching; + const isRefreshing = + nodesQuery.isFetching || + applyLogsQuery.isFetching || + observabilityQuery.isFetching; const handleRefresh = async () => { setFeedback(null); @@ -375,6 +508,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { queryClient.invalidateQueries({ queryKey: ['config-versions'], }), + queryClient.invalidateQueries({ + queryKey: ['node-observability', nodeId], + }), ]); }; @@ -427,32 +563,273 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { ) : null} -
- -
- + + {observabilityQuery.isLoading ? ( + + ) : observabilityQuery.isError ? ( + -

- 最近心跳: - {isMeaningfulTime(node.last_seen_at) - ? ` ${formatRelativeTime( - node.last_seen_at, - )} · ${formatDateTime(node.last_seen_at)}` - : ' 暂无'} -

-
+ ) : observability?.profile ? ( +
+
+
+

+ 主机名 +

+

+ {observability.profile.hostname || node.name} +

+
+
+

+ 操作系统 +

+

+ {observability.profile.os_name || 'unknown'} + {observability.profile.os_version + ? ` ${observability.profile.os_version}` + : ''} +

+
+
+

+ 内核 / 架构 +

+

+ {observability.profile.kernel_version || 'unknown'} ·{' '} + {observability.profile.architecture || 'unknown'} +

+
+
+

+ 在线时长 +

+

+ {formatUptime(observability.profile.uptime_seconds)} +

+
+
+ +
+
+

+ CPU +

+

+ {observability.profile.cpu_model || 'unknown'} +

+

+ {observability.profile.cpu_cores || 0} 核 +

+
+
+

+ 总内存 +

+

+ {formatBytes(observability.profile.total_memory_bytes)} +

+
+
+

+ 总存储 +

+

+ {formatBytes(observability.profile.total_disk_bytes)} +

+
+
+

+ 上报时间 +

+

+ {isMeaningfulTime(observability.profile.reported_at) + ? formatDateTime(observability.profile.reported_at) + : '—'} +

+
+
+
+ ) : ( + + )}
- -
-

Agent:{node.agent_version || 'unknown'}

-

Nginx:{node.nginx_version || 'unknown'}

-

当前配置:{node.current_version || '未应用'}

-
+ + {observabilityQuery.isLoading ? ( + + ) : observabilityQuery.isError ? ( + + ) : latestMetricSnapshot ? ( +
+
+ + + + +
+ +
+
+

+ 网络流量 +

+
+

+ 入站: + {formatBytes(latestMetricSnapshot.network_rx_bytes)} +

+

+ 出站: + {formatBytes(latestMetricSnapshot.network_tx_bytes)} +

+
+
+
+

+ 磁盘 IO +

+
+

+ 读取: + {formatBytes(latestMetricSnapshot.disk_read_bytes)} +

+

+ 写入: + {formatBytes(latestMetricSnapshot.disk_write_bytes)} +

+
+
+
+
+ ) : ( + + )}
+ +
+
+ + + +
+ +
+

+ 最近心跳: + {isMeaningfulTime(node.last_seen_at) + ? ` ${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}` + : ' 暂无'} +

+

IP:{node.ip || '暂无'}

+

当前配置:{node.current_version || '未应用'}

+ {latestTrafficReport ? ( +

+ 最近窗口请求:{latestTrafficReport.request_count} · UV{' '} + {latestTrafficReport.unique_visitor_count} · 错误{' '} + {latestTrafficReport.error_count} +

+ ) : null} +
+ +
+ {activeHealthEvents.slice(0, 3).map((event) => ( +
+
+ +
+

+ {event.message || '暂无详细消息'} +

+
+ ))} + + {activeHealthEvents.length === 0 ? ( +
+ 当前没有活动中的健康事件。 +
+ ) : null} +
+
+
+
+ +
+ +
+

Agent:{node.agent_version || 'unknown'}

+

Nginx:{node.nginx_version || 'unknown'}

+

当前配置:{node.current_version || '未应用'}

+
+
+