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 小时已恢复健康事件
-
-
-
-
+
+
+ {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 小时已恢复健康事件
+
+
+
+
+
+
+
+
+ {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 ? (
-
- ) : (
-
-
-
-
- | 版本 |
- 结果 |
- Checksum |
- 时间 |
- 消息 |
-
-
-
- {applyLogs.slice(0, 10).map((log) => (
-
- |
- {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 ? (
+
+ ) : (
+
+
+
+
+ | 版本 |
+ 结果 |
+ Checksum |
+ 时间 |
+ 消息 |
+
+
+
+ {applyLogs.slice(0, 10).map((log) => (
+
+ |
+ {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);
+ });
+});