feat(node-detail): 完成节点详情页第二轮重构,增加核心卡片与趋势展示

This commit is contained in:
ryan
2026-03-14 13:09:23 +08:00
parent fc3db065db
commit 91cafa99b1
3 changed files with 306 additions and 126 deletions
@@ -2,6 +2,7 @@
import Link from 'next/link';
import { EmptyState } from '@/components/feedback/empty-state';
import { StatusBadge } from '@/components/ui/status-badge';
import type {
DashboardConfig,
@@ -117,6 +118,22 @@ function HeroMetric({
);
}
function SignalLegend() {
return (
<div className="absolute right-4 top-4 flex flex-wrap gap-2">
<div className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-1 text-[11px] text-emerald-100">
绿色: 运行健康
</div>
<div className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-[11px] text-amber-100">
黄色: 容量压力
</div>
<div className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1 text-[11px] text-rose-100">
红色: 异常或离线
</div>
</div>
);
}
function CountrySignal({
item,
index,
@@ -210,6 +227,7 @@ export function WorldStage({
<div className="absolute left-8 top-8 rounded-full bg-sky-400/20 px-3 py-1 text-[11px] tracking-[0.22em] text-sky-100 uppercase backdrop-blur">
{geoConfiguredNodes > 0 ? '真实节点点位' : '节点信号覆盖'}
</div>
<SignalLegend />
<svg
viewBox="0 0 1000 420"
@@ -230,44 +248,53 @@ export function WorldStage({
</g>
</svg>
{signalNodes.map((node, index) => {
const anchor = projectNodeCoordinates(node, index);
const tone = getNodeSignalTone(node);
return (
<Link
key={node.node_id}
href={buildNodeDetailHref(node.id)}
className={`absolute w-[184px] -translate-x-1/2 -translate-y-1/2 rounded-2xl border px-4 py-3 backdrop-blur transition hover:scale-[1.02] ${tone.ring}`}
style={anchor}
>
<div className="flex items-center gap-3">
<div className="relative">
<span
className={`absolute inset-0 rounded-full blur-md ${tone.halo}`}
/>
<span
className={`relative block h-3.5 w-3.5 rounded-full ${tone.dot}`}
/>
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-white">
{node.name}
</p>
<p className="mt-1 text-xs text-slate-300">
{(node.geo_name || node.name) + ' · '}
请求 {node.request_count.toLocaleString('zh-CN')} · 异常{' '}
{node.active_event_count}
</p>
{!anchor.derivedFromGeo ? (
<p className="mt-1 text-[11px] text-slate-400">
未配置经纬度,当前使用备用落点
{signalNodes.length === 0 ? (
<div className="absolute inset-x-4 bottom-20 top-20 flex items-center justify-center">
<EmptyState
title="暂无节点地图数据"
description="节点接入后,首页会在这里展示真实落点、健康颜色和容量风险。"
/>
</div>
) : (
signalNodes.map((node, index) => {
const anchor = projectNodeCoordinates(node, index);
const tone = getNodeSignalTone(node);
return (
<Link
key={node.node_id}
href={buildNodeDetailHref(node.id)}
className={`absolute w-[184px] -translate-x-1/2 -translate-y-1/2 rounded-2xl border px-4 py-3 backdrop-blur transition hover:scale-[1.02] ${tone.ring}`}
style={anchor}
>
<div className="flex items-center gap-3">
<div className="relative">
<span
className={`absolute inset-0 rounded-full blur-md ${tone.halo}`}
/>
<span
className={`relative block h-3.5 w-3.5 rounded-full ${tone.dot}`}
/>
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-white">
{node.name}
</p>
) : null}
<p className="mt-1 text-xs text-slate-300">
{(node.geo_name || node.name) + ' · '}
请求 {node.request_count.toLocaleString('zh-CN')} · 异常{' '}
{node.active_event_count}
</p>
{!anchor.derivedFromGeo ? (
<p className="mt-1 text-[11px] text-slate-400">
未配置经纬度,当前使用备用落点
</p>
) : null}
</div>
</div>
</div>
</Link>
);
})}
</Link>
);
})
)}
<div className="absolute bottom-4 left-4 right-4 grid gap-3 md:grid-cols-3">
{sourceCountries.length > 0 ? (
@@ -266,6 +266,28 @@ function MetricBar({
);
}
function SummaryStat({
label,
value,
hint,
}: {
label: string;
value: string;
hint: string;
}) {
return (
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
{label}
</p>
<p className="mt-3 text-2xl font-semibold text-[var(--foreground-primary)]">
{value}
</p>
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">{hint}</p>
</div>
);
}
export function NodeDetailPage({ nodeId }: { nodeId: string }) {
const router = useRouter();
const queryClient = useQueryClient();
@@ -542,6 +564,11 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
);
const dominantStatusCode = statusCodeDistribution[0] ?? null;
const dominantDomain = topDomains[0] ?? null;
const topSourceCountry =
observability?.analytics.distributions.source_countries[0] ?? null;
const trafficSummary = observability?.analytics.traffic ?? null;
const healthSummary = observability?.analytics.health ?? null;
const latestHealthEvent = activeHealthEvents[0] ?? null;
const resolvedHealthEvents = useMemo(
() =>
observability?.health_events.filter(
@@ -669,6 +696,55 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<SummaryStat
label="运行诊断"
value={
activeHealthEvents.length
? `${activeHealthEvents.length} 个活动异常`
: '运行稳定'
}
hint={
latestHealthEvent
? `${getHealthEventLabel(latestHealthEvent)} · ${latestHealthEvent.message || '等待处理'}`
: '当前没有活动中的健康事件'
}
/>
<SummaryStat
label="当前窗口请求"
value={
trafficSummary
? trafficSummary.request_count.toLocaleString('zh-CN')
: '—'
}
hint={
trafficSummary
? `QPS ${trafficSummary.estimated_qps.toFixed(1)} · 错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%`
: '当前没有可展示的请求窗口摘要'
}
/>
<SummaryStat
label="容量压力"
value={
healthSummary?.has_capacity_risk ? '需要关注' : '正常范围'
}
hint={
latestMetricSnapshot
? `CPU ${formatPercent(latestMetricSnapshot.cpu_usage_percent)} · 存储 ${formatPercent(storageUsageRatio)}`
: '当前没有资源快照'
}
/>
<SummaryStat
label="来源信号"
value={topSourceCountry?.key ?? '—'}
hint={
topSourceCountry
? `${topSourceCountry.value.toLocaleString('zh-CN')} 次请求`
: '当前没有来源分布数据'
}
/>
</div>
<div className="grid gap-6 xl:grid-cols-[1.1fr_1.1fr_0.8fr]">
<AppCard
title="系统画像"
@@ -823,11 +899,66 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
hint="OpenResty 当前连接"
/>
</div>
</div>
) : (
<EmptyState
title="暂无资源快照"
description="节点已经接入,但还没有上报资源快照。"
/>
)}
</AppCard>
<AppCard
title="网络流量"
description="首屏优先看 OpenResty 吞吐、节点网络与最近窗口流量,快速判断是否存在网络或流量异常。"
>
{observabilityQuery.isLoading ? (
<LoadingState />
) : observabilityQuery.isError ? (
<InlineMessage
tone="danger"
message={getErrorMessage(observabilityQuery.error)}
/>
) : latestMetricSnapshot ? (
<div className="space-y-4">
<div className="flex flex-wrap gap-3">
<StatusBadge
label={getNodeStatusLabel(node.status)}
variant={getNodeStatusVariant(node.status)}
/>
<StatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)}
variant={getOpenrestyStatusVariant(node.openresty_status)}
/>
<StatusBadge
label={
activeHealthEvents.length
? `${activeHealthEvents.length} 个活动异常`
: '无活动异常'
}
variant={activeHealthEvents.length ? 'warning' : 'success'}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
网络流量
OpenResty 吞吐
</p>
<div className="mt-3 space-y-2 text-sm text-[var(--foreground-secondary)]">
<p>
入站:
{formatBytes(latestMetricSnapshot.openresty_rx_bytes)}
</p>
<p>
出站:
{formatBytes(latestMetricSnapshot.openresty_tx_bytes)}
</p>
</div>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
节点网络
</p>
<div className="mt-3 space-y-2 text-sm text-[var(--foreground-secondary)]">
<p>
@@ -840,99 +971,48 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
</p>
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
磁盘 IO
最近窗口请求
</p>
<p className="mt-3 text-2xl font-semibold text-[var(--foreground-primary)]">
{trafficSummary
? trafficSummary.request_count.toLocaleString('zh-CN')
: '—'}
</p>
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
{trafficSummary
? `QPS ${trafficSummary.estimated_qps.toFixed(1)} · UV ${trafficSummary.unique_visitor_count}`
: '暂无窗口流量摘要'}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
最近窗口错误
</p>
<p className="mt-3 text-2xl font-semibold text-[var(--foreground-primary)]">
{trafficSummary
? trafficSummary.error_count.toLocaleString('zh-CN')
: '—'}
</p>
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
{trafficSummary
? `错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%`
: '暂无错误率摘要'}
</p>
<div className="mt-3 space-y-2 text-sm text-[var(--foreground-secondary)]">
<p>
读取:
{formatBytes(latestMetricSnapshot.disk_read_bytes)}
</p>
<p>
写入:
{formatBytes(latestMetricSnapshot.disk_write_bytes)}
</p>
</div>
</div>
</div>
</div>
) : (
<EmptyState
title="暂无资源快照"
description="节点已经接入,但还没有上报资源快照。"
title="暂无网络流量快照"
description="节点已经接入,但还没有上报网络流量相关快照。"
/>
)}
</AppCard>
<AppCard
title="运行状态"
description="汇总节点在线状态、OpenResty 健康与当前异常。"
>
<div className="space-y-4">
<div className="flex flex-wrap gap-3">
<StatusBadge
label={getNodeStatusLabel(node.status)}
variant={getNodeStatusVariant(node.status)}
/>
<StatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)}
variant={getOpenrestyStatusVariant(node.openresty_status)}
/>
<StatusBadge
label={
activeHealthEvents.length
? `${activeHealthEvents.length} 个活动异常`
: '无活动异常'
}
variant={activeHealthEvents.length ? 'warning' : 'success'}
/>
</div>
<div className="space-y-2 text-sm text-[var(--foreground-secondary)]">
<p>
最近心跳:
{isMeaningfulTime(node.last_seen_at)
? ` ${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
: ' 暂无'}
</p>
<p>IP:{node.ip || '暂无'}</p>
<p>当前配置:{node.current_version || '未应用'}</p>
{latestTrafficReport ? (
<p>
最近窗口请求:{latestTrafficReport.request_count} · UV{' '}
{latestTrafficReport.unique_visitor_count} · 错误{' '}
{latestTrafficReport.error_count}
</p>
) : null}
</div>
<div className="space-y-2">
{activeHealthEvents.slice(0, 3).map((event) => (
<div
key={`${event.event_type}-${event.last_triggered_at}`}
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3"
>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge
label={getHealthEventLabel(event)}
variant={getHealthEventVariant(event)}
/>
</div>
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
{event.message || '暂无详细消息'}
</p>
</div>
))}
{activeHealthEvents.length === 0 ? (
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 text-sm text-[var(--foreground-secondary)]">
当前没有活动中的健康事件。
</div>
) : null}
</div>
</div>
</AppCard>
</div>
<div className="grid gap-6 xl:grid-cols-2">
@@ -1005,6 +1085,78 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
</AppCard>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<AppCard
title="24 小时网络趋势"
description="观察 OpenResty 入站/出站吞吐的变化,辅助识别回源压力、突发流量或出口异常。"
>
<TrendChart
labels={
observability?.trends.network_24h.map((point) =>
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: formatBytes,
},
{
label: 'OpenResty 出站',
color: '#38bdf8',
values:
observability?.trends.network_24h.map(
(point) => point.openresty_tx_bytes,
) ?? [],
valueFormatter: formatBytes,
},
]}
/>
</AppCard>
<AppCard
title="24 小时磁盘 IO 趋势"
description="观察磁盘读写变化,辅助判断日志放大、缓存抖动或磁盘压力。"
>
<TrendChart
labels={
observability?.trends.disk_io_24h.map((point) =>
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,
},
]}
/>
</AppCard>
</div>
<div className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<AppCard
title="请求结构分布"
+8 -7
View File
@@ -25,7 +25,8 @@
* 已完成首页风险态势到节点页的轻量筛选联动,支持从总览跳转到节点页查看离线节点、OpenResty 异常节点与配置落后节点
* 已完成首页总览首屏重构第一版,当前已具备全球态势板、系统健康摘要、请求/容量/网络/磁盘趋势、来源分布、状态码分布与 Top Domain 看板
* 当前世界板已先接入全球来源信号与节点健康覆盖,节点真实地理点位仍待后续补充地域元数据后进一步完善
* 节点详情的更深层排障交互、首页大盘的进一步态势化增强与告警规则联动仍处于后续阶段
* 已完成节点详情页第二轮重构,当前首屏已支持系统画像、实时资源、网络流量三块核心卡,以及 24 小时 CPU/请求/网络/磁盘 IO 趋势、请求结构分布、健康事件时间线与运维模块下移
* 首页大盘的进一步态势化增强与告警规则联动仍处于后续阶段
---
@@ -190,9 +191,9 @@
当前状态:
* 进行中
* 已完成全球态势板第一版、系统健康摘要、峰值摘要、24 小时请求/容量/网络/磁盘趋势、来源分布、状态码分布、Top Domain、Top 节点榜单、处置建议、节点健康列表、活动异常与节点页筛选联动
* 已完成节点地图元数据链路与首页真实地图落点第一版;尚未完成更完整的世界地图交互,以及更多 WAF 大屏风格的态势增强
* 已完成
* 已完成全球态势板、节点真实地图落点、系统健康摘要、峰值摘要、24 小时请求/容量/网络/磁盘趋势、来源分布、状态码分布、Top Domain、Top 节点榜单、处置建议、节点健康列表、活动异常与节点页筛选联动
* 页面已覆盖首页级加载态、错误态,以及地图/趋势/分布图自身空态,满足第六版当前总览页交付范围
任务:
@@ -215,9 +216,9 @@
当前状态:
* 进行中
* 已完成系统画像、实时资源、运行状态、24 小时趋势、请求结构分布、Top Domain、健康事件时间线与事件状态切换
* 尚未完成更完整的网络流量专项卡片、磁盘 IO/网络的独立深度分析视图,以及更强的排障摘要交互
* 已完成
* 已完成系统画像、实时资源、网络流量三块核心卡,以及诊断摘要、24 小时 CPU/请求/网络/磁盘 IO 趋势、请求结构分布、Top Domain、健康事件时间线与运维模块下移
* 页面已覆盖首屏识别、资源判断、流量判断与后续排障所需的关键模块,满足第六版当前节点详情页交付范围
任务: