feat(dashboard): enhance dashboard overview and world stage components

- Implemented normalization for dashboard overview data to handle null or undefined values.
- Added ECharts map integration for visualizing node data on a world map.
- Introduced loading and error states for the map component.
- Updated UI components to support dark and light themes.
- Enhanced testing for dashboard overview to cover empty states and data fetching.
- Added type definitions for echarts-maps module.
- Updated package dependencies to include echarts-maps.
This commit is contained in:
ryan
2026-03-14 14:45:04 +08:00
parent 4bfad019d0
commit f1476610f6
8 changed files with 734 additions and 166 deletions
+1
View File
@@ -160,6 +160,7 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
view := &DashboardOverviewView{
GeneratedAt: now,
Nodes: make([]DashboardNodeHealth, 0, len(nodes)),
ActiveAlerts: make([]DashboardAlert, 0),
Distributions: buildTrafficDistributions(reports, 8),
Trends: DashboardTrends{
Traffic24h: buildTrafficTrendPoints(now, reports),
+15
View File
@@ -879,3 +879,18 @@ func TestGetDashboardOverview(t *testing.T) {
t.Fatalf("unexpected riskiest node: %+v", view.Peaks.RiskiestNode)
}
}
func TestGetDashboardOverviewReturnsEmptyAlertSlice(t *testing.T) {
setupServiceTestDB(t)
view, err := GetDashboardOverview()
if err != nil {
t.Fatalf("GetDashboardOverview failed: %v", err)
}
if view.ActiveAlerts == nil {
t.Fatalf("expected active alerts to be an empty slice, got nil")
}
if len(view.ActiveAlerts) != 0 {
t.Fatalf("expected empty active alerts, got %+v", view.ActiveAlerts)
}
}
@@ -2,6 +2,38 @@ import { apiRequest } from '@/lib/api/client';
import type { DashboardOverview } from '@/features/dashboard/types';
export function getDashboardOverview() {
return apiRequest<DashboardOverview>('/dashboard/overview');
function arrayOrEmpty<T>(value: T[] | null | undefined) {
return Array.isArray(value) ? value : [];
}
function normalizeDashboardOverview(
overview: DashboardOverview | null | undefined,
): DashboardOverview | null {
if (!overview) {
return null;
}
return {
...overview,
nodes: arrayOrEmpty(overview.nodes),
active_alerts: arrayOrEmpty(overview.active_alerts),
distributions: {
...overview.distributions,
source_countries: arrayOrEmpty(overview.distributions?.source_countries),
status_codes: arrayOrEmpty(overview.distributions?.status_codes),
top_domains: arrayOrEmpty(overview.distributions?.top_domains),
},
trends: {
...overview.trends,
traffic_24h: arrayOrEmpty(overview.trends?.traffic_24h),
capacity_24h: arrayOrEmpty(overview.trends?.capacity_24h),
network_24h: arrayOrEmpty(overview.trends?.network_24h),
disk_io_24h: arrayOrEmpty(overview.trends?.disk_io_24h),
},
};
}
export async function getDashboardOverview() {
const overview = await apiRequest<DashboardOverview>('/dashboard/overview');
return normalizeDashboardOverview(overview);
}
@@ -1,8 +1,13 @@
'use client';
import type { EChartsOption } from 'echarts';
import ReactECharts from 'echarts-for-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import { useTheme } from '@/components/providers/theme-provider';
import { StatusBadge } from '@/components/ui/status-badge';
import type {
DashboardConfig,
@@ -17,20 +22,53 @@ import {
getOpenrestyStatusLabel,
getOpenrestyStatusVariant,
} from '@/features/nodes/utils';
import { cn } from '@/lib/utils/cn';
import { formatDateTime } from '@/lib/utils/date';
const stageAnchors = [
{ left: '16%', top: '24%' },
{ left: '24%', top: '58%' },
{ left: '45%', top: '22%' },
{ left: '52%', top: '54%' },
{ left: '72%', top: '30%' },
{ left: '84%', top: '68%' },
];
const fallbackCoordinates = [
[-122.4194, 37.7749],
[-46.6333, -23.5505],
[-0.1276, 51.5072],
[2.3522, 48.8566],
[77.209, 28.6139],
[121.4737, 31.2304],
[103.8198, 1.3521],
[151.2093, -33.8688],
[28.0473, -26.2041],
[139.6917, 35.6895],
] as const;
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
type Tone = 'healthy' | 'warning' | 'danger';
type MapNodeDatum = {
id: number;
name: string;
geoName: string;
route: string;
derivedFromGeo: boolean;
requestCount: number;
errorCount: number;
activeEventCount: number;
status: DashboardNodeHealth['status'];
openrestyStatus: DashboardNodeHealth['openresty_status'];
tone: Tone;
value: [number, number, number];
itemStyle: {
color: string;
borderColor: string;
borderWidth: number;
shadowBlur: number;
shadowColor: string;
};
emphasis: {
itemStyle: {
shadowBlur: number;
shadowColor: string;
borderColor: string;
borderWidth: number;
};
};
};
function formatPercent(value: number) {
if (!Number.isFinite(value)) {
@@ -46,55 +84,43 @@ function buildNodeDetailHref(id?: number | null) {
return `/node/detail?id=${id}`;
}
function projectNodeCoordinates(node: DashboardNodeHealth, index: number) {
if (
typeof node.geo_latitude === 'number' &&
typeof node.geo_longitude === 'number'
) {
const left = clamp(((node.geo_longitude + 180) / 360) * 100, 8, 92);
const top = clamp(((90 - node.geo_latitude) / 180) * 100, 12, 78);
return {
left: `${left}%`,
top: `${top}%`,
derivedFromGeo: true,
};
}
return {
...stageAnchors[index % stageAnchors.length],
derivedFromGeo: false,
};
}
function getNodeSignalTone(node: DashboardNodeHealth) {
function getNodeTone(node: DashboardNodeHealth): Tone {
if (
node.status === 'offline' ||
node.openresty_status === 'unhealthy' ||
node.active_event_count > 0
) {
return {
dot: 'bg-rose-400 shadow-[0_0_18px_rgba(251,113,133,0.75)]',
ring: 'border-rose-400/50 bg-rose-500/12 text-rose-50',
halo: 'bg-rose-500/20',
};
return 'danger';
}
if (
node.cpu_usage_percent >= 80 ||
node.memory_usage_percent >= 85 ||
node.storage_usage_percent >= 85
) {
return 'warning';
}
return 'healthy';
}
function getNodeCoordinates(node: DashboardNodeHealth, index: number) {
if (
typeof node.geo_latitude === 'number' &&
typeof node.geo_longitude === 'number'
) {
return {
dot: 'bg-amber-300 shadow-[0_0_18px_rgba(252,211,77,0.75)]',
ring: 'border-amber-300/45 bg-amber-400/12 text-amber-50',
halo: 'bg-amber-400/18',
coordinates: [node.geo_longitude, node.geo_latitude] as [number, number],
derivedFromGeo: true,
};
}
return {
dot: 'bg-emerald-300 shadow-[0_0_18px_rgba(110,231,183,0.7)]',
ring: 'border-emerald-300/40 bg-emerald-400/12 text-emerald-50',
halo: 'bg-emerald-400/18',
coordinates: [...fallbackCoordinates[index % fallbackCoordinates.length]] as [
number,
number,
],
derivedFromGeo: false,
};
}
@@ -102,34 +128,70 @@ function HeroMetric({
label,
value,
hint,
isDark,
}: {
label: string;
value: string;
hint: string;
isDark: boolean;
}) {
return (
<div className="rounded-[24px] border border-white/10 bg-white/6 px-4 py-4 backdrop-blur">
<p className="text-[11px] tracking-[0.26em] text-slate-300 uppercase">
<div
className={cn(
'rounded-[24px] border px-4 py-4 backdrop-blur',
isDark
? 'border-white/10 bg-white/6'
: 'border-sky-100/90 bg-white/80 shadow-[0_18px_40px_rgba(148,163,184,0.12)]',
)}
>
<p
className={cn(
'text-[11px] tracking-[0.26em] uppercase',
isDark ? 'text-slate-300' : 'text-slate-500',
)}
>
{label}
</p>
<p className="mt-3 text-2xl font-semibold text-white">{value}</p>
<p className="mt-2 text-sm text-slate-300">{hint}</p>
<p
className={cn(
'mt-3 text-2xl font-semibold',
isDark ? 'text-white' : 'text-slate-950',
)}
>
{value}
</p>
<p className={cn('mt-2 text-sm', isDark ? 'text-slate-300' : 'text-slate-600')}>
{hint}
</p>
</div>
);
}
function SignalLegend() {
function LegendPill({
label,
tone,
isDark,
}: {
label: string;
tone: Tone;
isDark: boolean;
}) {
const toneClass =
tone === 'healthy'
? isDark
? 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100'
: 'border-emerald-200 bg-emerald-50 text-emerald-700'
: tone === 'warning'
? isDark
? 'border-amber-300/20 bg-amber-400/10 text-amber-100'
: 'border-amber-200 bg-amber-50 text-amber-700'
: isDark
? 'border-rose-300/20 bg-rose-400/10 text-rose-100'
: 'border-rose-200 bg-rose-50 text-rose-700';
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 className={cn('rounded-full border px-3 py-1 text-[11px]', toneClass)}>
{label}
</div>
);
}
@@ -137,27 +199,51 @@ function SignalLegend() {
function CountrySignal({
item,
index,
isDark,
}: {
item: DistributionItem;
index: number;
isDark: boolean;
}) {
const accents = [
const darkAccents = [
'from-sky-400/35 to-cyan-400/10',
'from-violet-400/35 to-fuchsia-400/10',
'from-emerald-400/35 to-teal-400/10',
];
const lightAccents = [
'from-sky-100 via-white to-cyan-50',
'from-indigo-100 via-white to-fuchsia-50',
'from-emerald-100 via-white to-teal-50',
];
return (
<div
className={`rounded-2xl border border-white/10 bg-gradient-to-br px-4 py-3 backdrop-blur ${accents[index % accents.length]}`}
className={cn(
'rounded-2xl border bg-gradient-to-br px-4 py-3 backdrop-blur',
isDark
? `border-white/10 ${darkAccents[index % darkAccents.length]}`
: `border-slate-200/80 ${lightAccents[index % lightAccents.length]} shadow-[0_14px_30px_rgba(148,163,184,0.12)]`,
)}
>
<p className="text-[11px] tracking-[0.24em] text-slate-200 uppercase">
<p
className={cn(
'text-[11px] tracking-[0.24em] uppercase',
isDark ? 'text-slate-200' : 'text-slate-500',
)}
>
{item.key}
</p>
<p className="mt-2 text-lg font-semibold text-white">
<p
className={cn(
'mt-2 text-lg font-semibold',
isDark ? 'text-white' : 'text-slate-950',
)}
>
{item.value.toLocaleString('zh-CN')}
</p>
<p className="mt-1 text-xs text-slate-300">最近 24 小时来源信号</p>
<p className={cn('mt-1 text-xs', isDark ? 'text-slate-300' : 'text-slate-600')}>
最近 24 小时来源信号
</p>
</div>
);
}
@@ -177,6 +263,34 @@ export function WorldStage({
nodes: DashboardNodeHealth[];
sourceCountries: DistributionItem[];
}) {
const router = useRouter();
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === 'dark';
const [mapReady, setMapReady] = useState(false);
const [mapFailed, setMapFailed] = useState(false);
useEffect(() => {
let disposed = false;
import('echarts-maps/world.js')
.then(() => {
if (!disposed) {
setMapReady(true);
setMapFailed(false);
}
})
.catch(() => {
if (!disposed) {
setMapReady(false);
setMapFailed(true);
}
});
return () => {
disposed = true;
};
}, []);
const onlineRate =
summary.total_nodes > 0
? (summary.online_nodes / summary.total_nodes) * 100
@@ -198,23 +312,236 @@ export function WorldStage({
typeof node.geo_latitude === 'number' &&
typeof node.geo_longitude === 'number',
).length;
const signalNodes = nodes.slice(0, Math.max(stageAnchors.length, 8));
const mapPalette = useMemo(
() =>
isDark
? {
areaColor: '#13233b',
borderColor: 'rgba(125,211,252,0.18)',
shadowColor: 'rgba(8,15,31,0.35)',
labelColor: '#e2e8f0',
subLabelColor: '#94a3b8',
healthyColor: '#34d399',
warningColor: '#fbbf24',
dangerColor: '#fb7185',
healthyBorder: '#bbf7d0',
warningBorder: '#fde68a',
dangerBorder: '#fecdd3',
}
: {
areaColor: '#eaf2ff',
borderColor: 'rgba(71,85,105,0.16)',
shadowColor: 'rgba(148,163,184,0.18)',
labelColor: '#0f172a',
subLabelColor: '#475569',
healthyColor: '#10b981',
warningColor: '#f59e0b',
dangerColor: '#f43f5e',
healthyBorder: '#d1fae5',
warningBorder: '#fde68a',
dangerBorder: '#fecdd3',
},
[isDark],
);
const mapNodes = useMemo<MapNodeDatum[]>(
() =>
nodes.map((node, index) => {
const { coordinates, derivedFromGeo } = getNodeCoordinates(node, index);
const tone = getNodeTone(node);
const toneColor =
tone === 'healthy'
? mapPalette.healthyColor
: tone === 'warning'
? mapPalette.warningColor
: mapPalette.dangerColor;
const toneBorder =
tone === 'healthy'
? mapPalette.healthyBorder
: tone === 'warning'
? mapPalette.warningBorder
: mapPalette.dangerBorder;
return {
id: node.id,
name: node.name,
geoName: node.geo_name || node.name,
route: buildNodeDetailHref(node.id),
derivedFromGeo,
requestCount: node.request_count,
errorCount: node.error_count,
activeEventCount: node.active_event_count,
status: node.status,
openrestyStatus: node.openresty_status,
tone,
value: [coordinates[0], coordinates[1], Math.max(node.request_count, 1)],
itemStyle: {
color: toneColor,
borderColor: toneBorder,
borderWidth: 2,
shadowBlur: 18,
shadowColor: toneColor,
},
emphasis: {
itemStyle: {
shadowBlur: 24,
shadowColor: toneColor,
borderColor: toneBorder,
borderWidth: 2,
},
},
};
}),
[mapPalette, nodes],
);
const mapOption = useMemo<EChartsOption>(
() => ({
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: isDark
? 'rgba(15,23,42,0.96)'
: 'rgba(255,255,255,0.96)',
borderColor: isDark
? 'rgba(148,163,184,0.2)'
: 'rgba(148,163,184,0.24)',
borderWidth: 1,
textStyle: {
color: isDark ? '#e2e8f0' : '#0f172a',
fontSize: 12,
},
formatter: (params: unknown) => {
const data = (params as { data?: MapNodeDatum }).data;
if (!data) {
return '';
}
const locationLine = data.derivedFromGeo
? data.geoName
: `${data.geoName} · 备用落点`;
return [
`<div style="font-weight:600;margin-bottom:6px;">${data.name}</div>`,
`<div>${locationLine}</div>`,
`<div>请求 ${data.requestCount.toLocaleString('zh-CN')} · 错误 ${data.errorCount.toLocaleString('zh-CN')}</div>`,
`<div>活动异常 ${data.activeEventCount} · 节点 ${getNodeStatusLabel(data.status)}</div>`,
`<div>OpenResty ${getOpenrestyStatusLabel(data.openrestyStatus)}</div>`,
].join('');
},
},
geo: {
map: 'world',
roam: false,
silent: true,
top: 18,
bottom: 18,
left: 10,
right: 10,
itemStyle: {
areaColor: mapPalette.areaColor,
borderColor: mapPalette.borderColor,
borderWidth: 0.8,
shadowBlur: 12,
shadowColor: mapPalette.shadowColor,
},
emphasis: {
disabled: true,
},
},
series: [
{
type: 'effectScatter',
coordinateSystem: 'geo',
data: mapNodes,
showEffectOn: 'render',
rippleEffect: {
scale: 4,
brushType: 'stroke',
},
symbolSize: (value: unknown) => {
const size = Array.isArray(value) && typeof value[2] === 'number' ? value[2] : 1;
return Math.max(10, Math.min(24, 10 + Math.log10(size + 1) * 4.5));
},
label: {
show: mapNodes.length <= 8,
position: 'right',
distance: 8,
formatter: '{b}',
color: mapPalette.labelColor,
fontSize: 11,
backgroundColor: isDark ? 'rgba(8,15,31,0.7)' : 'rgba(255,255,255,0.88)',
borderColor: isDark
? 'rgba(148,163,184,0.16)'
: 'rgba(148,163,184,0.2)',
borderWidth: 1,
borderRadius: 999,
padding: [4, 8],
},
emphasis: {
scale: true,
label: {
show: true,
},
},
},
],
}),
[isDark, mapNodes, mapPalette],
);
return (
<section className="overflow-hidden rounded-[32px] border border-slate-800/70 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_28%),radial-gradient(circle_at_82%_18%,rgba(56,189,248,0.10),transparent_18%),linear-gradient(135deg,#08111f,#0f172a_45%,#111827)] shadow-[0_32px_80px_rgba(2,6,23,0.35)]">
<div className="border-b border-white/8 px-6 py-5 md:px-7">
<section
className={cn(
'overflow-hidden rounded-[32px] border transition-colors',
isDark
? 'border-slate-800/70 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_28%),radial-gradient(circle_at_82%_18%,rgba(56,189,248,0.10),transparent_18%),linear-gradient(135deg,#08111f,#0f172a_45%,#111827)] shadow-[0_32px_80px_rgba(2,6,23,0.35)]'
: 'border-sky-100/90 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.18),transparent_28%),radial-gradient(circle_at_82%_18%,rgba(59,130,246,0.12),transparent_20%),linear-gradient(135deg,#f8fbff,#edf5ff_45%,#ffffff)] shadow-[0_32px_80px_rgba(148,163,184,0.18)]',
)}
>
<div
className={cn(
'border-b px-6 py-5 md:px-7',
isDark ? 'border-white/8' : 'border-slate-200/70',
)}
>
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-2">
<p className="text-[11px] tracking-[0.34em] text-sky-200/80 uppercase">
<p
className={cn(
'text-[11px] tracking-[0.34em] uppercase',
isDark ? 'text-sky-200/80' : 'text-sky-700/80',
)}
>
Global Stage
</p>
<h2 className="text-2xl font-semibold text-white">全球态势板</h2>
<p className="max-w-3xl text-sm leading-6 text-slate-300">
<h2
className={cn(
'text-2xl font-semibold',
isDark ? 'text-white' : 'text-slate-950',
)}
>
全球态势板
</h2>
<p
className={cn(
'max-w-3xl text-sm leading-6',
isDark ? 'text-slate-300' : 'text-slate-600',
)}
>
先把节点健康、配置追平、活动风险和全球来源信号拉到同一张首屏,
让总览页真正承担值守入口的职责。
</p>
</div>
<div className="rounded-full border border-white/10 bg-white/6 px-4 py-2 text-sm text-slate-200 backdrop-blur">
<div
className={cn(
'rounded-full border px-4 py-2 text-sm backdrop-blur',
isDark
? 'border-white/10 bg-white/6 text-slate-200'
: 'border-slate-200/80 bg-white/80 text-slate-700',
)}
>
数据生成于 {formatDateTime(generatedAt)}
</div>
</div>
@@ -222,100 +549,110 @@ export function WorldStage({
<div className="grid gap-6 px-6 py-6 md:px-7 xl:grid-cols-[1.4fr_0.8fr]">
<div className="space-y-4">
<div className="relative min-h-[360px] overflow-hidden rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.16),rgba(15,23,42,0.42))]">
<div className="absolute inset-0 bg-[linear-gradient(rgba(148,163,184,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(148,163,184,0.08)_1px,transparent_1px)] bg-[size:40px_40px] opacity-35" />
<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">
<div
className={cn(
'relative min-h-[420px] overflow-hidden rounded-[28px] border',
isDark
? 'border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.16),rgba(15,23,42,0.42))]'
: 'border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.88),rgba(239,246,255,0.92))]',
)}
>
<div
className={cn(
'absolute left-6 top-6 z-10 rounded-full px-3 py-1 text-[11px] tracking-[0.22em] uppercase backdrop-blur',
isDark
? 'bg-sky-400/20 text-sky-100'
: 'bg-sky-100/90 text-sky-700',
)}
>
{geoConfiguredNodes > 0 ? '真实节点点位' : '节点信号覆盖'}
</div>
<SignalLegend />
<svg
viewBox="0 0 1000 420"
className="absolute inset-0 h-full w-full opacity-80"
aria-hidden="true"
>
<g fill="none" stroke="rgba(148,163,184,0.18)" strokeWidth="1.5">
<path d="M86 98C118 84 168 80 206 92C244 104 270 132 262 154C254 174 210 182 178 180C146 178 108 164 94 142C80 120 76 106 86 98Z" />
<path d="M246 210C270 220 286 248 282 276C278 304 266 332 252 360C236 352 228 328 220 306C212 282 210 252 220 232C226 220 236 212 246 210Z" />
<path d="M432 86C456 72 510 70 538 84C566 98 566 122 544 132C524 140 488 138 460 130C436 122 418 100 432 86Z" />
<path d="M494 154C524 150 552 164 568 188C586 214 592 246 582 274C572 302 546 326 518 326C494 326 474 302 468 276C460 244 458 212 466 186C472 168 480 158 494 154Z" />
<path d="M574 84C612 64 674 64 724 78C774 92 832 116 860 152C886 186 880 226 846 244C814 262 752 256 704 248C650 238 598 218 574 186C554 158 548 100 574 84Z" />
<path d="M804 298C824 288 858 290 882 302C904 312 912 332 900 346C888 360 856 364 830 360C806 356 788 338 790 320C792 310 796 302 804 298Z" />
</g>
<g stroke="rgba(56,189,248,0.22)" strokeDasharray="6 8">
<path d="M160 150C274 108 414 102 516 146C652 206 742 170 854 182" />
<path d="M248 286C358 226 430 206 544 208C668 210 760 256 858 322" />
</g>
</svg>
<div className="absolute right-4 top-4 z-10 flex flex-wrap gap-2">
<LegendPill label="绿色: 运行健康" tone="healthy" isDark={isDark} />
<LegendPill label="黄色: 容量压力" tone="warning" isDark={isDark} />
<LegendPill label="红色: 异常或离线" tone="danger" isDark={isDark} />
</div>
{signalNodes.length === 0 ? (
<div className="absolute inset-x-4 bottom-20 top-20 flex items-center justify-center">
<EmptyState
title="暂无节点地图数据"
description="节点接入后,首页会在这里展示真实落点、健康颜色和容量风险。"
<div className="absolute inset-0">
{mapReady ? (
<ReactECharts
option={mapOption}
notMerge
lazyUpdate
onEvents={{
click: (params: { data?: MapNodeDatum }) => {
if (params.data?.route) {
router.push(params.data.route);
}
},
}}
style={{ height: '100%', width: '100%' }}
/>
</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>
<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>
</Link>
);
})
)}
) : (
<div className="flex h-full items-center justify-center">
<EmptyState
title={mapFailed ? '全球地图加载失败' : '全球地图加载中'}
description={
mapFailed
? 'ECharts 世界地图资源未能成功注册,请稍后刷新重试。'
: '正在准备 ECharts 世界地图与节点信号点位。'
}
/>
</div>
)}
</div>
<div className="absolute bottom-4 left-4 right-4 grid gap-3 md:grid-cols-3">
{mapReady && nodes.length === 0 ? (
<div className="pointer-events-none absolute inset-x-6 bottom-24 z-10">
<div
className={cn(
'rounded-2xl border border-dashed px-4 py-4 text-sm backdrop-blur',
isDark
? 'border-white/15 bg-white/5 text-slate-300'
: 'border-slate-300/70 bg-white/78 text-slate-600',
)}
>
当前还没有节点接入,地图已就绪,后续会在这里展示节点真实落点与健康颜色。
</div>
</div>
) : null}
<div className="absolute bottom-4 left-4 right-4 z-10 grid gap-3 md:grid-cols-3">
{sourceCountries.length > 0 ? (
sourceCountries.slice(0, 3).map((item, index) => (
<CountrySignal
key={`${item.key}-${item.value}`}
item={item}
index={index}
isDark={isDark}
/>
))
) : (
<div className="rounded-2xl border border-dashed border-white/15 bg-white/5 px-4 py-4 text-sm text-slate-300 md:col-span-3">
<div
className={cn(
'rounded-2xl border border-dashed px-4 py-4 text-sm backdrop-blur md:col-span-3',
isDark
? 'border-white/15 bg-white/5 text-slate-300'
: 'border-slate-300/70 bg-white/78 text-slate-600',
)}
>
当前还没有可用于全球分布展示的来源国家数据。
</div>
)}
</div>
</div>
<div className="rounded-[24px] border border-white/10 bg-white/5 px-4 py-3 text-xs leading-6 text-slate-300 backdrop-blur">
<div
className={cn(
'rounded-[24px] border px-4 py-3 text-xs leading-6 backdrop-blur',
isDark
? 'border-white/10 bg-white/5 text-slate-300'
: 'border-slate-200/80 bg-white/80 text-slate-600',
)}
>
当前已有 {geoConfiguredNodes}/{summary.total_nodes} 个节点配置了真实地图坐标。
未配置坐标的节点仍会使用备用落点,不影响首页态势判断。
未配置坐标的节点会落在预设世界城市点位,保证首页仍能看到全球覆盖信号。
</div>
</div>
@@ -325,37 +662,63 @@ export function WorldStage({
label="在线覆盖"
value={formatPercent(onlineRate)}
hint={`${summary.online_nodes}/${summary.total_nodes} 节点在线`}
isDark={isDark}
/>
<HeroMetric
label="运行健康"
value={formatPercent(healthyRate)}
hint={`${summary.unhealthy_nodes} 个 OpenResty 不健康`}
isDark={isDark}
/>
<HeroMetric
label="配置追平"
value={formatPercent(syncRate)}
hint={`${summary.lagging_nodes} 个节点未追平 ${config.active_version || '当前激活版本'}`}
isDark={isDark}
/>
<HeroMetric
label="活动风险"
value={summary.active_alerts.toLocaleString('zh-CN')}
hint={`${risk.critical_alerts} Critical · ${risk.warning_alerts} Warning`}
isDark={isDark}
/>
</div>
<div className="rounded-[28px] border border-white/10 bg-white/6 px-5 py-5 backdrop-blur">
<div
className={cn(
'rounded-[28px] border px-5 py-5 backdrop-blur',
isDark
? 'border-white/10 bg-white/6'
: 'border-slate-200/80 bg-white/80 shadow-[0_18px_40px_rgba(148,163,184,0.12)]',
)}
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[11px] tracking-[0.24em] text-slate-300 uppercase">
<p
className={cn(
'text-[11px] tracking-[0.24em] uppercase',
isDark ? 'text-slate-300' : 'text-slate-500',
)}
>
节点健康清单
</p>
<p className="mt-2 text-lg font-semibold text-white">
<p
className={cn(
'mt-2 text-lg font-semibold',
isDark ? 'text-white' : 'text-slate-950',
)}
>
风险优先队列
</p>
</div>
<Link
href="/node"
className="rounded-full border border-white/12 px-3 py-1.5 text-xs text-slate-100 transition hover:bg-white/8"
className={cn(
'rounded-full border px-3 py-1.5 text-xs transition',
isDark
? 'border-white/12 text-slate-100 hover:bg-white/8'
: 'border-slate-200 text-slate-700 hover:bg-slate-100/80',
)}
>
查看全部节点
</Link>
@@ -365,14 +728,29 @@ export function WorldStage({
<Link
key={node.node_id}
href={buildNodeDetailHref(node.id)}
className="block rounded-2xl border border-white/8 bg-slate-950/20 px-4 py-4 transition hover:border-white/18 hover:bg-white/6"
className={cn(
'block rounded-2xl border px-4 py-4 transition',
isDark
? 'border-white/8 bg-slate-950/20 hover:border-white/18 hover:bg-white/6'
: 'border-slate-200/80 bg-slate-50/70 hover:border-slate-300 hover:bg-white',
)}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-white">
<p
className={cn(
'text-sm font-semibold',
isDark ? 'text-white' : 'text-slate-950',
)}
>
{node.name}
</p>
<p className="mt-1 text-xs text-slate-400">
<p
className={cn(
'mt-1 text-xs',
isDark ? 'text-slate-400' : 'text-slate-500',
)}
>
请求 {node.request_count.toLocaleString('zh-CN')} · 错误{' '}
{node.error_count.toLocaleString('zh-CN')}
</p>
@@ -393,8 +771,15 @@ export function WorldStage({
</Link>
))}
{nodes.length === 0 ? (
<div className="rounded-2xl border border-dashed border-white/12 bg-slate-950/20 px-4 py-5 text-sm text-slate-300">
当前没有节点健康数据,等节点开始 heartbeat 后这里会出现世界覆盖信号。
<div
className={cn(
'rounded-2xl border border-dashed px-4 py-5 text-sm',
isDark
? 'border-white/12 bg-slate-950/20 text-slate-300'
: 'border-slate-300/70 bg-slate-50/75 text-slate-600',
)}
>
当前没有节点健康数据,等节点开始 heartbeat 后这里会出现风险优先队列。
</div>
) : null}
</div>
+1
View File
@@ -22,6 +22,7 @@
"clsx": "^2.1.1",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"echarts-maps": "^1.1.0",
"marked": "^17.0.4",
"next": "15.2.2",
"react": "19.0.0",
+8
View File
@@ -23,6 +23,9 @@ importers:
echarts-for-react:
specifier: ^3.0.6
version: 3.0.6(echarts@6.0.0)(react@19.0.0)
echarts-maps:
specifier: ^1.1.0
version: 1.1.0
marked:
specifier: ^17.0.4
version: 17.0.4
@@ -1433,6 +1436,9 @@ packages:
echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
react: ^15.0.0 || >=16.0.0
echarts-maps@1.1.0:
resolution: {integrity: sha512-kr3rP50zETiib4QUQZBep9VdfE0i3NKL+6UfTcxyV1mpYzokW2RJmx3WnYeY6ColOsvtUkL9fAdsFz5jUDhCeA==}
echarts@6.0.0:
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
@@ -4162,6 +4168,8 @@ snapshots:
react: 19.0.0
size-sensor: 1.0.3
echarts-maps@1.1.0: {}
echarts@6.0.0:
dependencies:
tslib: 2.3.0
@@ -2,18 +2,56 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ThemeProvider } from '@/components/providers/theme-provider';
import { DashboardOverview } from '@/features/dashboard/components/dashboard-overview';
vi.mock('echarts-for-react', () => ({
default: () => <div data-testid="echarts-mock" />,
}));
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
}),
}));
describe('DashboardOverview', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
function stubMatchMedia() {
vi.stubGlobal(
'matchMedia',
vi.fn().mockImplementation(() => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
);
}
function renderDashboardOverview() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
render(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<DashboardOverview />
</ThemeProvider>
</QueryClientProvider>,
);
}
it('renders dashboard summary cards', async () => {
stubMatchMedia();
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
@@ -203,19 +241,7 @@ describe('DashboardOverview', () => {
}),
);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
render(
<QueryClientProvider client={queryClient}>
<DashboardOverview />
</QueryClientProvider>,
);
renderDashboardOverview();
expect(await screen.findByText('全球态势板')).toBeInTheDocument();
expect(await screen.findByText('系统健康摘要')).toBeInTheDocument();
@@ -226,4 +252,103 @@ describe('DashboardOverview', () => {
expect(await screen.findByText('处置建议')).toBeInTheDocument();
expect(await screen.findByText('节点健康列表')).toBeInTheDocument();
});
it('renders empty state when dashboard arrays are returned as null', async () => {
stubMatchMedia();
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/dashboard/overview')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: {
generated_at: '2026-03-14T08:00:00Z',
summary: {
total_nodes: 0,
online_nodes: 0,
offline_nodes: 0,
pending_nodes: 0,
unhealthy_nodes: 0,
active_alerts: 0,
lagging_nodes: 0,
},
traffic: {
request_count: 0,
unique_visitors: 0,
error_count: 0,
estimated_qps: 0,
reported_nodes: 0,
},
capacity: {
average_cpu_usage_percent: 0,
average_memory_usage_percent: 0,
high_cpu_nodes: 0,
high_memory_nodes: 0,
high_storage_nodes: 0,
},
config: {
active_version: '',
lagging_nodes: 0,
pending_nodes: 0,
},
risk: {
critical_alerts: 0,
warning_alerts: 0,
info_alerts: 0,
offline_nodes: 0,
unhealthy_nodes: 0,
lagging_nodes: 0,
high_cpu_nodes: 0,
high_memory_nodes: 0,
high_storage_nodes: 0,
},
peaks: {
peak_request_hour: {
bucket_started_at: '',
request_count: 0,
error_count: 0,
},
peak_error_hour: {
bucket_started_at: '',
request_count: 0,
error_count: 0,
},
busiest_node: null,
riskiest_node: null,
},
distributions: {
source_countries: null,
status_codes: null,
top_domains: null,
},
trends: {
traffic_24h: null,
capacity_24h: null,
network_24h: null,
disk_io_24h: null,
},
nodes: null,
active_alerts: null,
},
}),
),
);
}
return Promise.reject(new Error(`Unhandled fetch: ${url}`));
}),
);
renderDashboardOverview();
expect(await screen.findByText('暂无活动异常')).toBeInTheDocument();
expect(await screen.findByText('暂无节点')).toBeInTheDocument();
expect(await screen.findByText('暂无来源分布数据')).toBeInTheDocument();
});
});
+1
View File
@@ -0,0 +1 @@
declare module 'echarts-maps/world.js';