From 25004fefaefa7479ed013ad51b421681e9094505 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 15 Mar 2026 14:11:16 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=BA=A6=E9=87=8F=E5=B7=A5=E5=85=B7=E5=87=BD=E6=95=B0=E5=92=8C?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=BB=AA=E8=A1=A8=E7=9B=98=E5=92=8C=E8=B6=8B=E5=8A=BF=E5=9B=BE?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/components/data/trend-chart.tsx | 63 +- .../components/dashboard-overview.tsx | 53 +- .../nodes/components/node-detail-page.tsx | 1990 ++++++++--------- atsf_server/web/lib/utils/metrics.ts | 83 + .../web/tests/unit/metrics-utils.test.ts | 33 + 5 files changed, 1178 insertions(+), 1044 deletions(-) create mode 100644 atsf_server/web/lib/utils/metrics.ts create mode 100644 atsf_server/web/tests/unit/metrics-utils.test.ts diff --git a/atsf_server/web/components/data/trend-chart.tsx b/atsf_server/web/components/data/trend-chart.tsx index 8992f201..4c4345cd 100644 --- a/atsf_server/web/components/data/trend-chart.tsx +++ b/atsf_server/web/components/data/trend-chart.tsx @@ -4,6 +4,8 @@ import { useMemo } from 'react'; import type { EChartsOption } from 'echarts'; import ReactECharts from 'echarts-for-react'; +import { calculateNiceAxisMax, formatCompactNumber } from '@/lib/utils/metrics'; + type TrendChartSeries = { label: string; color: string; @@ -17,17 +19,29 @@ type TrendChartProps = { labels: string[]; series: TrendChartSeries[]; height?: number; + yAxisValueFormatter?: (value: number) => string; }; -const defaultFormatter = (value: number) => value.toLocaleString('zh-CN'); +type TooltipParam = { + axisValueLabel?: string; + color?: string; + seriesName?: string; + value?: number | string | Array; +}; -export function TrendChart({ labels, series, height = 220 }: TrendChartProps) { +const defaultFormatter = (value: number) => formatCompactNumber(value); + +export function TrendChart({ + labels, + series, + height = 220, + yAxisValueFormatter, +}: TrendChartProps) { const option = useMemo(() => { - const maxValue = - Math.max( - 1, - ...series.flatMap((item) => item.values.map((value) => value || 0)), - ) * 1.1; + const axisFormatter = yAxisValueFormatter ?? defaultFormatter; + const maxValue = calculateNiceAxisMax( + series.flatMap((item) => item.values), + ); return { animationDuration: 500, @@ -47,6 +61,38 @@ export function TrendChart({ labels, series, height = 220 }: TrendChartProps) { color: '#e2e8f0', fontSize: 12, }, + formatter: (params: unknown) => { + const items = Array.isArray(params) ? (params as TooltipParam[]) : []; + if (items.length === 0) { + return ''; + } + + const header = items[0]?.axisValueLabel ?? ''; + const rows = items.map((item) => { + const matchedSeries = series.find( + (seriesItem) => seriesItem.label === item.seriesName, + ); + const formatter = + matchedSeries?.valueFormatter ?? + yAxisValueFormatter ?? + defaultFormatter; + const rawValue = Array.isArray(item.value) + ? item.value[1] + : item.value; + const numericValue = + typeof rawValue === 'number' ? rawValue : Number(rawValue ?? 0); + + return [ + '', + ``, + `${item.seriesName ?? ''}`, + `${formatter(numericValue)}`, + '', + ].join(''); + }); + + return [header, ...rows].join('
'); + }, }, legend: { show: false, @@ -75,6 +121,7 @@ export function TrendChart({ labels, series, height = 220 }: TrendChartProps) { splitNumber: 4, axisLabel: { color: '#94a3b8', + formatter: (value: number) => axisFormatter(value), }, splitLine: { lineStyle: { @@ -110,7 +157,7 @@ export function TrendChart({ labels, series, height = 220 }: TrendChartProps) { data: item.values, })), }; - }, [labels, series]); + }, [labels, series, yAxisValueFormatter]); if (labels.length === 0 || series.length === 0) { return ( diff --git a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx index 8335e5d7..e3b4aa26 100644 --- a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx +++ b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx @@ -20,35 +20,11 @@ import { getOpenrestyStatusVariant, } from '@/features/nodes/utils'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; - -function formatPercent(value?: number | null) { - if (value === undefined || value === null || Number.isNaN(value)) { - return '—'; - } - return `${value.toFixed(1)}%`; -} - -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 formatBytesPerSecond(value?: number | null, windowSeconds = 1) { - if (!value || value <= 0 || windowSeconds <= 0) { - return '—'; - } - return `${formatBytes(value / windowSeconds)}/s`; -} +import { + formatBytes, + formatBytesPerSecond, + formatPercent, +} from '@/lib/utils/metrics'; function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : '请求失败,请稍后重试。'; @@ -244,6 +220,7 @@ export function DashboardOverview() { labels={overview.trends.capacity_24h.map((point) => formatTrendHour(point.bucket_started_at), )} + yAxisValueFormatter={formatPercent} series={[ { label: '平均 CPU', @@ -279,6 +256,7 @@ export function DashboardOverview() { formatTrendHour(point.bucket_started_at), )} height={180} + yAxisValueFormatter={(value) => formatBytesPerSecond(value, 3600)} series={[ { label: 'OpenResty 入站', @@ -306,6 +284,7 @@ export function DashboardOverview() { formatTrendHour(point.bucket_started_at), )} height={180} + yAxisValueFormatter={formatBytes} series={[ { label: '磁盘读', @@ -366,16 +345,14 @@ export function DashboardOverview() { 容量压力节点

- Math.round( - Math.max( - node.cpu_usage_percent, - node.memory_usage_percent, - node.storage_usage_percent, - ), + items={buildNodeRankItems(overview.nodes, (node) => + Math.round( + Math.max( + node.cpu_usage_percent, + node.memory_usage_percent, + node.storage_usage_percent, ), + ), )} color="#ef4444" valueFormatter={(value) => `${value}%`} 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 f68693b9..76d175fa 100644 --- a/atsf_server/web/features/nodes/components/node-detail-page.tsx +++ b/atsf_server/web/features/nodes/components/node-detail-page.tsx @@ -43,6 +43,11 @@ import { } from '@/features/shared/components/resource-primitives'; import type { ReleaseChannel } from '@/features/update/types'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; +import { + formatBytes, + formatBytesPerSecond, + formatPercent, +} from '@/lib/utils/metrics'; import { buildNodeInstallCommand, getApplyLabel, @@ -74,35 +79,6 @@ 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 formatBytesPerSecond(value?: number | null, windowSeconds = 1) { - if (!value || value <= 0 || windowSeconds <= 0) { - return '—'; - } - return `${formatBytes(value / windowSeconds)}/s`; -} - -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; @@ -445,8 +421,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { const latestMetricSnapshot = observability?.metric_snapshots?.[0] ?? null; const activeHealthEvents = useMemo( () => - observability?.health_events.filter((event) => event.status === 'active') ?? - [], + observability?.health_events.filter( + (event) => event.status === 'active', + ) ?? [], [observability?.health_events], ); const statusCodeDistribution = useMemo( @@ -484,8 +461,8 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { }, [ activeHealthEvents, healthEventFilter, - observability?.health_events, - resolvedHealthEvents, + observability?.health_events, + resolvedHealthEvents, ]); const tabs = useMemo( () => @@ -684,1005 +661,1022 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { {activeTab === 'dashboard' ? ( <> -
- - - - -
- -
- - {observabilityQuery.isLoading ? ( - - ) : observabilityQuery.isError ? ( - + - ) : 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) - : '—'} -

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

- OpenResty 吞吐 -

-
-

- 入站: - {formatBytesPerSecond( - latestMetricSnapshot.openresty_rx_bytes, - 60, - )} -

-

- 出站: - {formatBytesPerSecond( - latestMetricSnapshot.openresty_tx_bytes, - 60, - )} -

-
-
-
-

- 节点网络 -

-
-

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

-

- 出站: - {formatBytes(latestMetricSnapshot.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)}%` - : '暂无错误率摘要'} -

-
-
-
- ) : ( - - )} -
-
- -
- - - formatTrendHour(point.bucket_started_at), - ) ?? [] - } - series={[ - { - label: '请求量', - color: '#f59e0b', - fillColor: 'rgba(245, 158, 11, 0.18)', - variant: 'area', - values: - observability?.trends.traffic_24h.map( - (point) => point.request_count, - ) ?? [], - }, - { - label: '错误量', - color: '#ef4444', - values: - observability?.trends.traffic_24h.map( - (point) => point.error_count, - ) ?? [], - }, - ]} - /> - - - - - formatTrendHour(point.bucket_started_at), - ) ?? [] - } - series={[ - { - label: '平均 CPU', - color: '#0f766e', - fillColor: 'rgba(15, 118, 110, 0.15)', - variant: 'area', - values: - observability?.trends.capacity_24h.map( - (point) => point.average_cpu_usage_percent, - ) ?? [], - valueFormatter: formatPercent, - }, - { - label: '平均内存', - color: '#2563eb', - values: - observability?.trends.capacity_24h.map( - (point) => point.average_memory_usage_percent, - ) ?? [], - valueFormatter: formatPercent, - }, - ]} - /> - -
- -
- - - formatTrendHour(point.bucket_started_at), - ) ?? [] - } - series={[ - { - label: 'OpenResty 入站', - color: '#22c55e', - fillColor: 'rgba(34, 197, 94, 0.14)', - variant: 'area', - values: - observability?.trends.network_24h.map( - (point) => point.openresty_rx_bytes, - ) ?? [], - valueFormatter: (value) => formatBytesPerSecond(value, 3600), - }, - { - label: 'OpenResty 出站', - color: '#38bdf8', - values: - observability?.trends.network_24h.map( - (point) => point.openresty_tx_bytes, - ) ?? [], - valueFormatter: (value) => formatBytesPerSecond(value, 3600), - }, - ]} - /> - - - - - formatTrendHour(point.bucket_started_at), - ) ?? [] - } - series={[ - { - label: '磁盘读', - color: '#a78bfa', - fillColor: 'rgba(167, 139, 250, 0.14)', - variant: 'area', - values: - observability?.trends.disk_io_24h.map( - (point) => point.disk_read_bytes, - ) ?? [], - valueFormatter: formatBytes, - }, - { - label: '磁盘写', - color: '#fb7185', - values: - observability?.trends.disk_io_24h.map( - (point) => point.disk_write_bytes, - ) ?? [], - valueFormatter: formatBytes, - }, - ]} - /> - -
- -
- -
-
-

- 主状态码 -

-

- {dominantStatusCode?.label ?? '—'} -

-

- {dominantStatusCode - ? `${dominantStatusCode.value} 次` - : '暂无状态码聚合'} -

-
-
-

- Top Domain -

-

- {dominantDomain?.label ?? '—'} -

-

- {dominantDomain - ? `${dominantDomain.value} 次` - : '暂无域名聚合'} -

-
-
-

- 已恢复事件 -

-

- {resolvedHealthEvents.length} -

-

- 最近 24 小时已恢复健康事件 -

-
-
-
-

- 状态码分布 -

- -
-
-

- Top Domain -

- -
-
-
+
+ + {observabilityQuery.isLoading ? ( + + ) : observabilityQuery.isError ? ( + + ) : 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)} +

+
+
- - {observability?.health_events.length ? ( -
-
- - - -
+
+
+

+ 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) + : '—'} +

+
+
+
+ ) : ( + + )} +
- {filteredHealthEvents.slice(0, 8).map((event) => ( -
-
+ + {observabilityQuery.isLoading ? ( + + ) : observabilityQuery.isError ? ( + + ) : latestMetricSnapshot ? ( +
+
+ + + + +
+
+ ) : ( + + )} +
+ + + {observabilityQuery.isLoading ? ( + + ) : observabilityQuery.isError ? ( + + ) : latestMetricSnapshot ? ( +
+
+
-

- {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) - ? ` ${formatDateTime(event.resolved_at)}` - : ' —'} -

+ +
+
+

+ OpenResty 吞吐 +

+
+

+ 入站: + {formatBytesPerSecond( + latestMetricSnapshot.openresty_rx_bytes, + 60, + )} +

+

+ 出站: + {formatBytesPerSecond( + latestMetricSnapshot.openresty_tx_bytes, + 60, + )} +

+
+
+
+

+ 节点网络 +

+
+

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

+

+ 出站: + {formatBytes(latestMetricSnapshot.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)}%` + : '暂无错误率摘要'} +

+
- ))} - {filteredHealthEvents.length === 0 ? ( -
- 当前筛选下没有健康事件。 + ) : ( + + )} + +
+ +
+ + + formatTrendHour(point.bucket_started_at), + ) ?? [] + } + series={[ + { + label: '请求量', + color: '#f59e0b', + fillColor: 'rgba(245, 158, 11, 0.18)', + variant: 'area', + values: + observability?.trends.traffic_24h.map( + (point) => point.request_count, + ) ?? [], + }, + { + label: '错误量', + color: '#ef4444', + values: + observability?.trends.traffic_24h.map( + (point) => point.error_count, + ) ?? [], + }, + ]} + /> + + + + + formatTrendHour(point.bucket_started_at), + ) ?? [] + } + yAxisValueFormatter={formatPercent} + series={[ + { + label: '平均 CPU', + color: '#0f766e', + fillColor: 'rgba(15, 118, 110, 0.15)', + variant: 'area', + values: + observability?.trends.capacity_24h.map( + (point) => point.average_cpu_usage_percent, + ) ?? [], + valueFormatter: formatPercent, + }, + { + label: '平均内存', + color: '#2563eb', + values: + observability?.trends.capacity_24h.map( + (point) => point.average_memory_usage_percent, + ) ?? [], + valueFormatter: formatPercent, + }, + ]} + /> + +
+ +
+ + + formatTrendHour(point.bucket_started_at), + ) ?? [] + } + yAxisValueFormatter={(value) => + formatBytesPerSecond(value, 3600) + } + series={[ + { + label: 'OpenResty 入站', + color: '#22c55e', + fillColor: 'rgba(34, 197, 94, 0.14)', + variant: 'area', + values: + observability?.trends.network_24h.map( + (point) => point.openresty_rx_bytes, + ) ?? [], + valueFormatter: (value) => + formatBytesPerSecond(value, 3600), + }, + { + label: 'OpenResty 出站', + color: '#38bdf8', + values: + observability?.trends.network_24h.map( + (point) => point.openresty_tx_bytes, + ) ?? [], + valueFormatter: (value) => + formatBytesPerSecond(value, 3600), + }, + ]} + /> + + + + + formatTrendHour(point.bucket_started_at), + ) ?? [] + } + yAxisValueFormatter={formatBytes} + series={[ + { + label: '磁盘读', + color: '#a78bfa', + fillColor: 'rgba(167, 139, 250, 0.14)', + variant: 'area', + values: + observability?.trends.disk_io_24h.map( + (point) => point.disk_read_bytes, + ) ?? [], + valueFormatter: formatBytes, + }, + { + label: '磁盘写', + color: '#fb7185', + values: + observability?.trends.disk_io_24h.map( + (point) => point.disk_write_bytes, + ) ?? [], + valueFormatter: formatBytes, + }, + ]} + /> + +
+ +
+ +
+
+

+ 主状态码 +

+

+ {dominantStatusCode?.label ?? '—'} +

+

+ {dominantStatusCode + ? `${dominantStatusCode.value} 次` + : '暂无状态码聚合'} +

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

+ Top Domain +

+

+ {dominantDomain?.label ?? '—'} +

+

+ {dominantDomain + ? `${dominantDomain.value} 次` + : '暂无域名聚合'} +

+
+
+

+ 已恢复事件 +

+

+ {resolvedHealthEvents.length} +

+

+ 最近 24 小时已恢复健康事件 +

+
+
+ +
+
+

+ 状态码分布 +

+ +
+
+

+ Top Domain +

+ +
+
+
+ + + {observability?.health_events.length ? ( +
+
+ + + +
+ + {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) + ? ` ${formatDateTime(event.resolved_at)}` + : ' —'} +

+
+
+ ))} + {filteredHealthEvents.length === 0 ? ( +
+ 当前筛选下没有健康事件。 +
+ ) : null} +
+ ) : ( + + )} +
+
) : null} {activeTab === 'info' ? ( <> -
- -
- -

- {node.update_requested - ? `已等待节点在下一次心跳后执行${node.update_channel === 'preview' ? '预览版' : '正式版'}更新。` - : node.auto_update_enabled - ? '节点已启用正式版自动更新。' - : '当前仅支持手动触发更新。'} -

-
-
- - -
-

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

-

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

-

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

-
-
- - -
- -

- {isMeaningfulTime(node.latest_apply_at) - ? `${formatRelativeTime( - node.latest_apply_at, - )} · ${formatDateTime(node.latest_apply_at)}` - : '暂无应用记录'} -

- {node.latest_apply_checksum ? ( -
-

支持文件:{node.latest_support_file_count}

-
- ) : null} -
-
-
- - setIsTargetSnapshotOpen(true)} - > - 查看目标快照 - - ) : null - } - > - {configVersionsQuery.isLoading ? ( - - ) : configVersionsQuery.isError ? ( - - ) : activeConfigVersion ? ( -
-
-

- 追平状态 -

-
+
+ +
-
-

- {isTargetVersionApplied - ? '当前节点已应用全局激活配置。' - : '当前节点版本落后于全局激活配置,可结合应用记录定位原因。'} -

-
- -
-
-

- 目标版本 -

-

- {activeConfigVersion.version} +

+ {node.update_requested + ? `已等待节点在下一次心跳后执行${node.update_channel === 'preview' ? '预览版' : '正式版'}更新。` + : node.auto_update_enabled + ? '节点已启用正式版自动更新。' + : '当前仅支持手动触发更新。'}

-
-

- Target Checksum -

-

- {activeConfigVersion.checksum} -

+ + + +
+

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

+

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

+

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

-
-

- 激活时间 -

-

- {formatDateTime(activeConfigVersion.created_at)} + + + +

+ +

+ {isMeaningfulTime(node.latest_apply_at) + ? `${formatRelativeTime( + node.latest_apply_at, + )} · ${formatDateTime(node.latest_apply_at)}` + : '暂无应用记录'}

+ {node.latest_apply_checksum ? ( +
+

支持文件:{node.latest_support_file_count}

+
+ ) : null}
-
-
- ) : ( - - )} - - - - - {restartOpenrestyMutation.isPending - ? '下发重启中...' - : node.restart_openresty_requested - ? '等待 OpenResty 重启' - : '重启 OpenResty'} - -
- } - > -
-
-

- 健康状态 -

-
- - {node.restart_openresty_requested ? ( - - ) : null} -
-

- {node.restart_openresty_requested - ? '已等待节点在下一次心跳后执行 OpenResty 重启。' - : '系统会在每次心跳前自动采集健康状态。'} -

+
-
-

- 状态消息 -

-

- {node.openresty_message || '当前未上报额外错误。'} -

-
-
- - -
- - void handleCopy( - nodeInstallCommand, - '节点专属部署命令已复制。', - ) - } - > - 复制部署命令 - - ) : null - } - > -
-
-
-

- Node ID -

-

- {node.node_id} -

-
-
-

- Agent Token -

-

- {node.agent_token || '暂无'} -

-
-
- - - setServerUrl(event.target.value)} - /> - - - {nodeInstallCommand ? ( - - {nodeInstallCommand} - - ) : null} -
-
- - -
-
-

- IP 地址 -

-

{node.ip || '暂无'}

-
-
-

- 最近错误 -

-

- {node.last_error || '无'} -

-
-
-

- OpenResty 状态消息 -

-

- {node.openresty_message || '无'} -

-
-
-

- 创建时间 -

-

{formatDateTime(node.created_at)}

-
-
-

- 更新时间 -

-

{formatDateTime(node.updated_at)}

-
-
-
-
- - setIsTargetSnapshotOpen(true)} + > + 查看目标快照 + + ) : null + } > - 查看完整记录 - - } - > - {applyLogsQuery.isLoading ? ( - - ) : applyLogsQuery.isError ? ( - - ) : applyLogs.length === 0 ? ( - - ) : ( -
- - - - - - - - - - - - {applyLogs.slice(0, 10).map((log) => ( - - - - - - - - ))} - -
版本结果Checksum时间消息
- {log.version} - - - - {log.checksum ? `${log.checksum.slice(0, 12)}...` : '—'} - - {formatRelativeTime(log.created_at)} ·{' '} - {formatDateTime(log.created_at)} - -
-

- {log.main_config_checksum - ? `主配置:${log.main_config_checksum}` - : ''} -

-

- {log.route_config_checksum - ? `路由配置:${log.route_config_checksum}` - : ''} -

-

- {log.support_file_count - ? `支持文件:${log.support_file_count}` - : ''} -

-

{log.message || '—'}

-
-
+ {configVersionsQuery.isLoading ? ( + + ) : configVersionsQuery.isError ? ( + + ) : activeConfigVersion ? ( +
+
+

+ 追平状态 +

+
+ +
+

+ {isTargetVersionApplied + ? '当前节点已应用全局激活配置。' + : '当前节点版本落后于全局激活配置,可结合应用记录定位原因。'} +

+
+ +
+
+

+ 目标版本 +

+

+ {activeConfigVersion.version} +

+
+
+

+ Target Checksum +

+

+ {activeConfigVersion.checksum} +

+
+
+

+ 激活时间 +

+

+ {formatDateTime(activeConfigVersion.created_at)} +

+
+
+
+ ) : ( + + )} + + + + + {restartOpenrestyMutation.isPending + ? '下发重启中...' + : node.restart_openresty_requested + ? '等待 OpenResty 重启' + : '重启 OpenResty'} + +
+ } + > +
+
+

+ 健康状态 +

+
+ + {node.restart_openresty_requested ? ( + + ) : null} +
+

+ {node.restart_openresty_requested + ? '已等待节点在下一次心跳后执行 OpenResty 重启。' + : '系统会在每次心跳前自动采集健康状态。'} +

+
+ +
+

+ 状态消息 +

+

+ {node.openresty_message || '当前未上报额外错误。'} +

+
+
+
+ +
+ + void handleCopy( + nodeInstallCommand, + '节点专属部署命令已复制。', + ) + } + > + 复制部署命令 + + ) : null + } + > +
+
+
+

+ Node ID +

+

+ {node.node_id} +

+
+
+

+ Agent Token +

+

+ {node.agent_token || '暂无'} +

+
+
+ + + setServerUrl(event.target.value)} + /> + + + {nodeInstallCommand ? ( + + {nodeInstallCommand} + + ) : null} +
+
+ + +
+
+

+ IP 地址 +

+

{node.ip || '暂无'}

+
+
+

+ 最近错误 +

+

+ {node.last_error || '无'} +

+
+
+

+ OpenResty 状态消息 +

+

+ {node.openresty_message || '无'} +

+
+
+

+ 创建时间 +

+

{formatDateTime(node.created_at)}

+
+
+

+ 更新时间 +

+

{formatDateTime(node.updated_at)}

+
+
+
- )} - + + + 查看完整记录 + + } + > + {applyLogsQuery.isLoading ? ( + + ) : applyLogsQuery.isError ? ( + + ) : applyLogs.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {applyLogs.slice(0, 10).map((log) => ( + + + + + + + + ))} + +
版本结果Checksum时间消息
+ {log.version} + + + + {log.checksum + ? `${log.checksum.slice(0, 12)}...` + : '—'} + + {formatRelativeTime(log.created_at)} ·{' '} + {formatDateTime(log.created_at)} + +
+

+ {log.main_config_checksum + ? `主配置:${log.main_config_checksum}` + : ''} +

+

+ {log.route_config_checksum + ? `路由配置:${log.route_config_checksum}` + : ''} +

+

+ {log.support_file_count + ? `支持文件:${log.support_file_count}` + : ''} +

+

{log.message || '—'}

+
+
+
+ )} +
) : null}
diff --git a/atsf_server/web/lib/utils/metrics.ts b/atsf_server/web/lib/utils/metrics.ts new file mode 100644 index 00000000..ffa293b2 --- /dev/null +++ b/atsf_server/web/lib/utils/metrics.ts @@ -0,0 +1,83 @@ +const compactNumberFormatter = new Intl.NumberFormat('zh-CN', { + maximumFractionDigits: 1, +}); + +type FormatBytesOptions = { + emptyText?: string; + zeroText?: string; +}; + +export function formatCompactNumber(value?: number | null) { + if (value === undefined || value === null || Number.isNaN(value)) { + return '—'; + } + return compactNumberFormatter.format(value); +} + +export function formatPercent(value?: number | null) { + if (value === undefined || value === null || Number.isNaN(value)) { + return '—'; + } + return `${formatCompactNumber(value)}%`; +} + +export function formatBytes( + value?: number | null, + options: FormatBytesOptions = {}, +) { + const { emptyText = '—', zeroText = '0 B' } = options; + + if (value === undefined || value === null || Number.isNaN(value)) { + return emptyText; + } + if (value <= 0) { + return zeroText; + } + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let current = value; + let index = 0; + while (current >= 1024 && index < units.length - 1) { + current /= 1024; + index += 1; + } + + const digits = current >= 100 || index === 0 ? 0 : 1; + return `${new Intl.NumberFormat('zh-CN', { + maximumFractionDigits: digits, + minimumFractionDigits: 0, + }).format(current)} ${units[index]}`; +} + +export function formatBytesPerSecond( + value?: number | null, + windowSeconds = 1, + options?: FormatBytesOptions, +) { + if (value === undefined || value === null || Number.isNaN(value)) { + return options?.emptyText ?? '—'; + } + if (windowSeconds <= 0) { + return options?.emptyText ?? '—'; + } + return `${formatBytes(value / windowSeconds, options)}/s`; +} + +export function calculateNiceAxisMax(values: number[]) { + const rawMax = Math.max( + 0, + ...values.map((value) => (Number.isFinite(value) && value > 0 ? value : 0)), + ); + + if (rawMax <= 0) { + return 1; + } + + const paddedMax = rawMax * 1.1; + const magnitude = 10 ** Math.floor(Math.log10(paddedMax)); + const normalized = paddedMax / magnitude; + const steps = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]; + const niceStep = steps.find((step) => normalized <= step) ?? 10; + + return niceStep * magnitude; +} diff --git a/atsf_server/web/tests/unit/metrics-utils.test.ts b/atsf_server/web/tests/unit/metrics-utils.test.ts new file mode 100644 index 00000000..6cdaede3 --- /dev/null +++ b/atsf_server/web/tests/unit/metrics-utils.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { + calculateNiceAxisMax, + formatBytes, + formatBytesPerSecond, + formatPercent, +} from '@/lib/utils/metrics'; + +describe('metrics utils', () => { + it('formats byte values with readable units', () => { + expect(formatBytes(0)).toBe('0 B'); + expect(formatBytes(1536)).toBe('1.5 KB'); + expect(formatBytes(183 * 1024 * 1024)).toBe('183 MB'); + expect(formatBytes(14.7 * 1024 * 1024)).toBe('14.7 MB'); + }); + + it('formats byte rates with per-second units', () => { + expect(formatBytesPerSecond(0, 3600)).toBe('0 B/s'); + expect(formatBytesPerSecond(1630858.9, 3600)).toBe('453 B/s'); + expect(formatBytesPerSecond(22620490956.8, 3600)).toBe('6 MB/s'); + }); + + it('formats percent values without long decimals', () => { + expect(formatPercent(58.36809884639982)).toBe('58.4%'); + }); + + it('rounds chart max values to friendly axis steps', () => { + expect(calculateNiceAxisMax([0, 58.36809884639982])).toBe(80); + expect(calculateNiceAxisMax([0, 1630858.9])).toBe(2000000); + expect(calculateNiceAxisMax([0, 22620490956.8])).toBe(25000000000); + }); +});