feat: add geo metadata to nodes and dashboard

- Extend DashboardNodeHealth and NodeItem interfaces to include geo_name, geo_latitude, and geo_longitude.
- Update NodeDetailPage and NodesPage components to handle geo metadata in forms and payloads.
- Implement validation for geo fields in NodesPage using Zod.
- Enhance dashboard overview tests to include geo metadata for nodes.
- Create WorldStage component to visualize node health and geo locations on a world map.
- Update design and development documentation to reflect the addition of geo metadata for nodes.
This commit is contained in:
ryan
2026-03-14 12:59:22 +08:00
parent b991e2e635
commit fc3db065db
16 changed files with 1188 additions and 238 deletions
@@ -3,6 +3,7 @@
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { RankChart } from '@/components/data/rank-chart';
import { TrendChart } from '@/components/data/trend-chart';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
@@ -15,6 +16,7 @@ import type {
DashboardNodeHealth,
DashboardPeakNode,
} from '@/features/dashboard/types';
import { WorldStage } from '@/features/dashboard/components/world-stage';
import {
getNodeStatusLabel,
getNodeStatusVariant,
@@ -30,6 +32,21 @@ function formatPercent(value?: number | null) {
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 getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
@@ -42,6 +59,20 @@ function formatTrendHour(value: string) {
return `${date.getHours().toString().padStart(2, '0')}:00`;
}
function formatPeakHour(value: string) {
if (!value) {
return '暂无';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '暂无';
}
return `${formatTrendHour(value)} - ${date
.getMinutes()
.toString()
.padStart(2, '0')}`;
}
function getAlertVariant(
alert: DashboardAlert,
): 'success' | 'warning' | 'danger' | 'info' {
@@ -57,18 +88,89 @@ function getAlertVariant(
return 'info';
}
function formatPeakHour(value: string) {
if (!value) {
return '暂无';
function formatPeakNode(node: DashboardPeakNode | null) {
if (!node) {
return {
title: '暂无',
hint: '当前没有可用节点数据',
};
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '暂无';
return {
title: node.node_name,
hint: `请求 ${node.request_count} · 错误 ${node.error_count} · CPU ${formatPercent(node.cpu_usage_percent)}`,
};
}
function buildNodeDetailHref(id?: number | null) {
if (!id) {
return '/node';
}
return `${formatTrendHour(value)} - ${date
.getMinutes()
.toString()
.padStart(2, '0')}`;
return `/node/detail?id=${id}`;
}
function buildNodeRankItems(
nodes: DashboardNodeHealth[],
selector: (node: DashboardNodeHealth) => number,
limit = 5,
) {
return [...nodes]
.sort((left, right) => {
const leftValue = selector(left);
const rightValue = selector(right);
if (leftValue === rightValue) {
return left.name.localeCompare(right.name, 'zh-CN');
}
return rightValue - leftValue;
})
.slice(0, limit)
.filter((node) => selector(node) > 0)
.map((node) => ({
label: node.name,
value: selector(node),
}));
}
function OverviewMetric({
label,
value,
hint,
accent = 'sky',
}: {
label: string;
value: string | number;
hint?: string;
accent?: 'sky' | 'amber' | 'emerald' | 'rose';
}) {
const accentClass =
accent === 'amber'
? 'from-amber-400/14 to-transparent'
: accent === 'emerald'
? 'from-emerald-400/14 to-transparent'
: accent === 'rose'
? 'from-rose-400/14 to-transparent'
: 'from-sky-400/14 to-transparent';
return (
<div
className={`rounded-3xl border border-[var(--border-default)] bg-[linear-gradient(180deg,var(--surface-elevated),var(--surface-card))] px-5 py-5 shadow-[var(--shadow-soft)]`}
>
<div className={`rounded-2xl bg-gradient-to-r ${accentClass} p-0.5`}>
<div className="rounded-[15px] bg-transparent">
<p className="text-xs tracking-[0.24em] text-[var(--foreground-muted)] uppercase">
{label}
</p>
<p className="mt-3 text-3xl font-semibold text-[var(--foreground-primary)]">
{value}
</p>
{hint ? (
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
{hint}
</p>
) : null}
</div>
</div>
</div>
);
}
function RiskSignal({
@@ -86,23 +188,25 @@ function RiskSignal({
}) {
const toneClass =
tone === 'danger'
? 'border-rose-400/30 bg-rose-500/10 text-rose-100'
? 'border-rose-400/28 bg-rose-500/10 text-rose-100'
: tone === 'warning'
? 'border-amber-400/30 bg-amber-500/10 text-amber-100'
? 'border-amber-400/28 bg-amber-500/10 text-amber-100'
: tone === 'success'
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100'
: 'border-sky-400/30 bg-sky-500/10 text-sky-100';
? 'border-emerald-400/28 bg-emerald-500/10 text-emerald-100'
: 'border-sky-400/28 bg-sky-500/10 text-sky-100';
const content = (
<div className={`rounded-3xl border px-4 py-4 ${toneClass}`}>
<p className="text-xs tracking-[0.22em] uppercase opacity-75">{label}</p>
<p className="text-xs tracking-[0.22em] opacity-75 uppercase">{label}</p>
<p className="mt-3 text-3xl font-semibold">{value}</p>
<p className="mt-2 text-sm opacity-80">{hint}</p>
</div>
);
if (!href) {
return content;
}
return (
<Link href={href} className="block transition hover:opacity-90">
{content}
@@ -145,52 +249,6 @@ function PeakCard({
);
}
function formatPeakNode(node: DashboardPeakNode | null) {
if (!node) {
return {
title: '暂无',
hint: '当前没有可用节点数据',
};
}
return {
title: node.node_name,
hint: `请求 ${node.request_count} · 错误 ${node.error_count} · CPU ${formatPercent(node.cpu_usage_percent)}`,
};
}
function buildNodeDetailHref(id?: number | null) {
if (!id) {
return '/node';
}
return `/node/detail?id=${id}`;
}
function OverviewMetric({
label,
value,
hint,
}: {
label: string;
value: string | number;
hint?: string;
}) {
return (
<div className="rounded-3xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-5 py-5">
<p className="text-xs tracking-[0.24em] text-[var(--foreground-muted)] uppercase">
{label}
</p>
<p className="mt-3 text-3xl font-semibold text-[var(--foreground-primary)]">
{value}
</p>
{hint ? (
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
{hint}
</p>
) : null}
</div>
);
}
function NodeHealthRow({ node }: { node: DashboardNodeHealth }) {
return (
<Link
@@ -240,7 +298,7 @@ function NodeHealthRow({ node }: { node: DashboardNodeHealth }) {
最近窗口请求
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{node.request_count}
{node.request_count.toLocaleString('zh-CN')}
</p>
</div>
<div>
@@ -302,50 +360,98 @@ export function DashboardOverview() {
return (
<div className="space-y-6">
<AppCard
title="系统运行总览"
description="优先回答当前系统是否健康、容量是否紧张、是否存在配置落后和异常节点。"
action={
<div className="text-right text-sm text-[var(--foreground-secondary)]">
生成时间:{formatDateTime(overview.generated_at)}
</div>
}
>
<div className="grid gap-4 xl:grid-cols-5">
<OverviewMetric
label="在线节点"
value={`${overview.summary.online_nodes}/${overview.summary.total_nodes}`}
hint={`${overview.summary.offline_nodes} 离线 · ${overview.summary.pending_nodes} 待接入`}
/>
<OverviewMetric
label="活动异常"
value={overview.summary.active_alerts}
hint={`${overview.summary.unhealthy_nodes} 个 OpenResty 不健康`}
/>
<OverviewMetric
label="配置落后"
value={overview.summary.lagging_nodes}
hint={overview.config.active_version || '当前无激活版本'}
/>
<OverviewMetric
label="最近窗口请求"
value={overview.traffic.request_count}
hint={`QPS ${overview.traffic.estimated_qps.toFixed(1)} · UV ${overview.traffic.unique_visitors}`}
/>
<OverviewMetric
label="平均 CPU"
value={formatPercent(overview.capacity.average_cpu_usage_percent)}
hint={`${overview.capacity.high_cpu_nodes} 个高 CPU 节点`}
/>
</div>
</AppCard>
<WorldStage
generatedAt={overview.generated_at}
summary={overview.summary}
risk={overview.risk}
config={overview.config}
nodes={overview.nodes}
sourceCountries={overview.distributions.source_countries}
/>
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
<AppCard
title="风险态势"
description="把异常、容量压力和配置落后聚到同一层,方便先确定今天该盯哪里。"
title="系统健康摘要"
description="先回答系统是否健康、容量是否紧张、是否存在追平偏差和异常流量。"
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<OverviewMetric
label="在线节点"
value={`${overview.summary.online_nodes}/${overview.summary.total_nodes}`}
hint={`${overview.summary.offline_nodes} 离线 · ${overview.summary.pending_nodes} 待接入`}
accent="sky"
/>
<OverviewMetric
label="活动异常"
value={overview.summary.active_alerts}
hint={`${overview.summary.unhealthy_nodes} 个 OpenResty 不健康`}
accent="rose"
/>
<OverviewMetric
label="配置落后"
value={overview.summary.lagging_nodes}
hint={overview.config.active_version || '当前无激活版本'}
accent="amber"
/>
<OverviewMetric
label="最近窗口请求"
value={overview.traffic.request_count.toLocaleString('zh-CN')}
hint={`QPS ${overview.traffic.estimated_qps.toFixed(1)} · UV ${overview.traffic.unique_visitors.toLocaleString('zh-CN')}`}
accent="emerald"
/>
<OverviewMetric
label="平均 CPU"
value={formatPercent(overview.capacity.average_cpu_usage_percent)}
hint={`${overview.capacity.high_cpu_nodes} 个高 CPU 节点`}
accent="amber"
/>
<OverviewMetric
label="平均内存"
value={formatPercent(overview.capacity.average_memory_usage_percent)}
hint={`${overview.capacity.high_memory_nodes} 个高内存节点`}
accent="sky"
/>
</div>
</AppCard>
<AppCard
title="峰值与风险摘要"
description="快速回答什么时候最忙、哪里最危险,以及首页能直接行动的入口。"
>
<div className="grid gap-4 md:grid-cols-2">
<PeakCard
label="请求峰值时段"
value={formatPeakHour(
overview.peaks.peak_request_hour.bucket_started_at,
)}
hint={`峰值请求 ${overview.peaks.peak_request_hour.request_count.toLocaleString('zh-CN')}`}
/>
<PeakCard
label="错误峰值时段"
value={formatPeakHour(
overview.peaks.peak_error_hour.bucket_started_at,
)}
hint={`峰值错误 ${overview.peaks.peak_error_hour.error_count.toLocaleString('zh-CN')}`}
/>
<PeakCard
label="最忙节点"
value={formatPeakNode(overview.peaks.busiest_node).title}
hint={formatPeakNode(overview.peaks.busiest_node).hint}
href={buildNodeDetailHref(
nodeIdMap.get(overview.peaks.busiest_node?.node_id ?? ''),
)}
/>
<PeakCard
label="优先排查节点"
value={formatPeakNode(overview.peaks.riskiest_node).title}
hint={formatPeakNode(overview.peaks.riskiest_node).hint}
href={buildNodeDetailHref(
nodeIdMap.get(overview.peaks.riskiest_node?.node_id ?? ''),
)}
/>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<RiskSignal
label="Critical"
value={overview.risk.critical_alerts}
@@ -380,12 +486,6 @@ export function DashboardOverview() {
tone="warning"
hint={`${overview.risk.high_cpu_nodes} 个高 CPU · ${overview.risk.high_memory_nodes} 个高内存`}
/>
<RiskSignal
label="高存储压力"
value={overview.risk.high_storage_nodes}
tone="warning"
hint={`${overview.risk.offline_nodes} 个离线节点`}
/>
<RiskSignal
label="离线节点"
value={overview.risk.offline_nodes}
@@ -395,44 +495,6 @@ export function DashboardOverview() {
/>
</div>
</AppCard>
<AppCard
title="峰值摘要"
description="快速回答什么时候最忙、什么时候最危险,以及该先看哪台节点。"
>
<div className="grid gap-4 md:grid-cols-2">
<PeakCard
label="请求峰值时段"
value={formatPeakHour(
overview.peaks.peak_request_hour.bucket_started_at,
)}
hint={`峰值请求 ${overview.peaks.peak_request_hour.request_count}`}
/>
<PeakCard
label="错误峰值时段"
value={formatPeakHour(
overview.peaks.peak_error_hour.bucket_started_at,
)}
hint={`峰值错误 ${overview.peaks.peak_error_hour.error_count}`}
/>
<PeakCard
label="最忙节点"
value={formatPeakNode(overview.peaks.busiest_node).title}
hint={formatPeakNode(overview.peaks.busiest_node).hint}
href={buildNodeDetailHref(
nodeIdMap.get(overview.peaks.busiest_node?.node_id ?? ''),
)}
/>
<PeakCard
label="优先排查节点"
value={formatPeakNode(overview.peaks.riskiest_node).title}
hint={formatPeakNode(overview.peaks.riskiest_node).hint}
href={buildNodeDetailHref(
nodeIdMap.get(overview.peaks.riskiest_node?.node_id ?? ''),
)}
/>
</div>
</AppCard>
</div>
<div className="grid gap-6 xl:grid-cols-2">
@@ -497,99 +559,290 @@ export function DashboardOverview() {
</AppCard>
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
<AppCard
title="节点健康列表"
description="按异常数量和资源压力排序,优先显示最需要关注的节点。"
action={
<Link
href="/node"
className="inline-flex items-center rounded-full border border-[var(--border-default)] px-3 py-1.5 text-xs text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
进入节点页
</Link>
}
title="24 小时网络与磁盘趋势"
description="把 OpenResty 吞吐和磁盘读写放到同一屏,方便判断是流量拉升还是资源抖动。"
>
{overview.nodes.length === 0 ? (
<div className="space-y-6">
<TrendChart
labels={overview.trends.network_24h.map((point) =>
formatTrendHour(point.bucket_started_at),
)}
height={180}
series={[
{
label: 'OpenResty 入站',
color: '#22c55e',
fillColor: 'rgba(34, 197, 94, 0.14)',
variant: 'area',
values: overview.trends.network_24h.map(
(point) => point.openresty_rx_bytes,
),
valueFormatter: formatBytes,
},
{
label: 'OpenResty 出站',
color: '#38bdf8',
values: overview.trends.network_24h.map(
(point) => point.openresty_tx_bytes,
),
valueFormatter: formatBytes,
},
]}
/>
<TrendChart
labels={overview.trends.disk_io_24h.map((point) =>
formatTrendHour(point.bucket_started_at),
)}
height={180}
series={[
{
label: '磁盘读',
color: '#a78bfa',
fillColor: 'rgba(167, 139, 250, 0.14)',
variant: 'area',
values: overview.trends.disk_io_24h.map(
(point) => point.disk_read_bytes,
),
valueFormatter: formatBytes,
},
{
label: '磁盘写',
color: '#fb7185',
values: overview.trends.disk_io_24h.map(
(point) => point.disk_write_bytes,
),
valueFormatter: formatBytes,
},
]}
/>
</div>
</AppCard>
<AppCard
title="活动异常"
description="优先展示当前仍在触发中的问题。"
>
{overview.active_alerts.length === 0 ? (
<EmptyState
title="暂无节点"
description="节点接入后,这里会展示系统健康与容量摘要。"
title="暂无活动异常"
description="当前系统没有正在触发的节点健康事件。"
/>
) : (
<div className="space-y-4">
{overview.nodes.slice(0, 8).map((node) => (
<NodeHealthRow key={node.node_id} node={node} />
<div className="space-y-3">
{overview.active_alerts.map((alert) => (
<Link
key={`${alert.node_id}-${alert.event_type}-${alert.last_triggered_at}`}
href={buildNodeDetailHref(nodeIdMap.get(alert.node_id))}
className="block rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 transition hover:border-[var(--border-strong)] hover:bg-[var(--surface-muted)]"
>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge
label={alert.event_type.replaceAll('_', ' ')}
variant={getAlertVariant(alert)}
/>
<p className="text-sm font-medium text-[var(--foreground-primary)]">
{alert.node_name}
</p>
</div>
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
{alert.message || '暂无详细消息'}
</p>
<p className="mt-2 text-xs text-[var(--foreground-muted)]">
{formatRelativeTime(alert.last_triggered_at)} ·{' '}
{formatDateTime(alert.last_triggered_at)}
</p>
</Link>
))}
</div>
)}
</AppCard>
</div>
<div className="space-y-6">
<AppCard
title="流量与容量"
description="聚合最近窗口流量和资源压力,作为首页一级信号。"
>
<div className="grid gap-4">
<OverviewMetric
label="最近窗口错误"
value={overview.traffic.error_count}
hint={`${overview.traffic.reported_nodes} 个节点上报`}
/>
<OverviewMetric
label="平均内存"
value={formatPercent(
overview.capacity.average_memory_usage_percent,
<div className="grid gap-6 xl:grid-cols-3">
<AppCard
title="来源分布"
description="聚合最近 24 小时主要来源国家,优先识别流量重心变化。"
>
<RankChart
items={overview.distributions.source_countries.map((item) => ({
label: item.key,
value: item.value,
}))}
color="#38bdf8"
emptyMessage="暂无来源分布数据"
/>
</AppCard>
<AppCard
title="状态码分布"
description="快速判断成功响应是否仍是主流,以及错误码是否有抬升。"
>
<RankChart
items={overview.distributions.status_codes.map((item) => ({
label: `HTTP ${item.key}`,
value: item.value,
}))}
color="#f59e0b"
emptyMessage="暂无状态码分布"
/>
</AppCard>
<AppCard
title="Top Domain"
description="观察主要流量集中在哪些域名,方便判断业务热度与异常集中点。"
>
<RankChart
items={overview.distributions.top_domains.map((item) => ({
label: item.key,
value: item.value,
}))}
color="#34d399"
emptyMessage="暂无域名分布"
/>
</AppCard>
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<AppCard
title="Top 节点榜单"
description="把流量峰值、错误热点和容量压力节点并排展示,方便值守时快速判断先处理谁。"
>
<div className="grid gap-6 xl:grid-cols-3">
<div>
<p className="mb-3 text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
流量最高节点
</p>
<RankChart
items={buildNodeRankItems(
overview.nodes,
(node) => node.request_count,
)}
hint={`${overview.capacity.high_memory_nodes} 个高内存节点`}
/>
<OverviewMetric
label="高存储压力"
value={overview.capacity.high_storage_nodes}
hint="存储使用率 >= 85%"
color="#38bdf8"
emptyMessage="暂无流量榜单"
/>
</div>
</AppCard>
<AppCard
title="活动异常"
description="优先展示当前仍在触发中的问题。"
>
{overview.active_alerts.length === 0 ? (
<EmptyState
title="暂无活动异常"
description="当前系统没有正在触发的节点健康事件。"
<div>
<p className="mb-3 text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
错误热点节点
</p>
<RankChart
items={buildNodeRankItems(
overview.nodes,
(node) => node.error_count,
)}
color="#f97316"
emptyMessage="暂无错误热点"
/>
) : (
<div className="space-y-3">
{overview.active_alerts.map((alert) => (
<Link
key={`${alert.node_id}-${alert.event_type}-${alert.last_triggered_at}`}
href={buildNodeDetailHref(nodeIdMap.get(alert.node_id))}
className="block rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 transition hover:border-[var(--border-strong)] hover:bg-[var(--surface-muted)]"
>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge
label={alert.event_type.replaceAll('_', ' ')}
variant={getAlertVariant(alert)}
/>
<p className="text-sm font-medium text-[var(--foreground-primary)]">
{alert.node_name}
</p>
</div>
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
{alert.message || '暂无详细消息'}
</p>
<p className="mt-2 text-xs text-[var(--foreground-muted)]">
{formatRelativeTime(alert.last_triggered_at)} ·{' '}
{formatDateTime(alert.last_triggered_at)}
</p>
</Link>
))}
</div>
)}
</AppCard>
</div>
</div>
<div>
<p className="mb-3 text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
容量压力节点
</p>
<RankChart
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}%`}
emptyMessage="暂无容量压力数据"
/>
</div>
</div>
</AppCard>
<AppCard
title="处置建议"
description="把首页信号翻译成可行动的下一步,降低值守判断成本。"
>
<div className="space-y-4">
<div className="rounded-3xl border border-rose-400/18 bg-rose-500/8 px-4 py-4">
<p className="text-xs tracking-[0.22em] text-rose-200 uppercase">
第一优先级
</p>
<p className="mt-3 text-lg font-semibold text-[var(--foreground-primary)]">
先处理活动异常与 OpenResty 不健康节点
</p>
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
当前有 {overview.summary.active_alerts} 个活动异常,{` `}
{overview.summary.unhealthy_nodes} 个 OpenResty 不健康节点。
</p>
</div>
<div className="rounded-3xl border border-amber-400/18 bg-amber-500/8 px-4 py-4">
<p className="text-xs tracking-[0.22em] text-amber-200 uppercase">
第二优先级
</p>
<p className="mt-3 text-lg font-semibold text-[var(--foreground-primary)]">
检查配置落后与容量逼近节点
</p>
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
当前有 {overview.summary.lagging_nodes} 个配置落后节点,{` `}
{overview.capacity.high_cpu_nodes +
overview.capacity.high_memory_nodes +
overview.capacity.high_storage_nodes}{' '}
个节点出现明显容量压力。
</p>
</div>
<div className="rounded-3xl border border-sky-400/18 bg-sky-500/8 px-4 py-4">
<p className="text-xs tracking-[0.22em] text-sky-200 uppercase">
观察项
</p>
<p className="mt-3 text-lg font-semibold text-[var(--foreground-primary)]">
关注峰值时段与来源分布变化
</p>
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
请求峰值出现在 {formatPeakHour(
overview.peaks.peak_request_hour.bucket_started_at,
)}
,当前主要来源集中在
{overview.distributions.source_countries
.slice(0, 2)
.map((item) => item.key)
.join(' / ') || ' 暂无来源数据'}
。
</p>
</div>
</div>
</AppCard>
</div>
<AppCard
title="节点健康列表"
description="按异常数量和资源压力排序,优先显示最需要关注的节点。"
action={
<Link
href="/node"
className="inline-flex items-center rounded-full border border-[var(--border-default)] px-3 py-1.5 text-xs text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
进入节点页
</Link>
}
>
{overview.nodes.length === 0 ? (
<EmptyState
title="暂无节点"
description="节点接入后,这里会展示系统健康与容量摘要。"
/>
) : (
<div className="space-y-4">
{overview.nodes.slice(0, 8).map((node) => (
<NodeHealthRow key={node.node_id} node={node} />
))}
</div>
)}
</AppCard>
</div>
);
}