feat: add geo_manual_override to NodeItem and NodeMutationPayload

fix: update default GeoIPProvider to ipinfo in settings

feat: implement WorldStageMap component for visualizing node health on a world map

feat: create NodeEditorModal for editing node details with manual geo location options

docs: update app-config documentation to reflect changes in GeoIPProvider default value
This commit is contained in:
ryan
2026-03-14 15:50:02 +08:00
parent 4a50762092
commit f33e9514dc
18 changed files with 1411 additions and 943 deletions
@@ -0,0 +1,372 @@
'use client';
import ReactEChartsCore from 'echarts-for-react/lib/core';
import type { EChartsCoreOption } from 'echarts/core';
import * as echarts from 'echarts/core';
import { ScatterChart } from 'echarts/charts';
import { GeoComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { useRouter } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import worldGeoJson from '@/features/dashboard/data/world-geo.json';
import type { DashboardNodeHealth } from '@/features/dashboard/types';
import {
getNodeStatusLabel,
getOpenrestyStatusLabel,
} from '@/features/nodes/utils';
echarts.use([GeoComponent, TooltipComponent, ScatterChart, CanvasRenderer]);
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;
type Tone = 'healthy' | 'warning' | 'danger';
type WorldGeoJson = {
type: string;
features?: unknown[];
};
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'];
value: [number, number, number];
itemStyle: {
color: string;
borderColor: string;
borderWidth: number;
};
emphasis: {
itemStyle: {
borderColor: string;
borderWidth: number;
};
};
};
let worldMapRegistrationAttempted = false;
let worldMapRegistrationSucceeded = false;
function buildNodeDetailHref(id?: number | null) {
if (!id) {
return '/node';
}
return `/node/detail?id=${id}`;
}
function getNodeTone(node: DashboardNodeHealth): Tone {
if (
node.status === 'offline' ||
node.openresty_status === 'unhealthy' ||
node.active_event_count > 0
) {
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 {
coordinates: [node.geo_longitude, node.geo_latitude] as [number, number],
derivedFromGeo: true,
};
}
return {
coordinates: [...fallbackCoordinates[index % fallbackCoordinates.length]] as [
number,
number,
],
derivedFromGeo: false,
};
}
function ensureWorldMapRegistered() {
if (worldMapRegistrationSucceeded || echarts.getMap('world')) {
worldMapRegistrationSucceeded = true;
return true;
}
if (worldMapRegistrationAttempted) {
return false;
}
worldMapRegistrationAttempted = true;
try {
const geoJson = worldGeoJson as WorldGeoJson;
if (geoJson.type !== 'FeatureCollection' || !Array.isArray(geoJson.features)) {
throw new Error('invalid world geojson payload');
}
echarts.registerMap(
'world',
geoJson as unknown as Parameters<typeof echarts.registerMap>[1],
);
if (!echarts.getMap('world')) {
throw new Error('world map registration failed');
}
worldMapRegistrationSucceeded = true;
return true;
} catch (error) {
const registrationError =
error instanceof Error ? error : new Error('unknown world map registration error');
console.error('Failed to register ECharts world map', registrationError);
return false;
}
}
export function WorldStageMap({
isDark,
nodes,
}: {
isDark: boolean;
nodes: DashboardNodeHealth[];
}) {
const router = useRouter();
const [mapReady, setMapReady] = useState(false);
const [mapFailed, setMapFailed] = useState(false);
useEffect(() => {
const ready = ensureWorldMapRegistered();
setMapReady(ready);
setMapFailed(!ready);
}, []);
const mapPalette = useMemo(
() =>
isDark
? {
areaColor: '#13233b',
borderColor: 'rgba(125,211,252,0.14)',
labelColor: '#e2e8f0',
healthyColor: '#34d399',
warningColor: '#fbbf24',
dangerColor: '#fb7185',
healthyBorder: '#bbf7d0',
warningBorder: '#fde68a',
dangerBorder: '#fecdd3',
}
: {
areaColor: '#eaf2ff',
borderColor: 'rgba(71,85,105,0.12)',
labelColor: '#0f172a',
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,
value: [coordinates[0], coordinates[1], Math.max(node.request_count, 1)],
itemStyle: {
color: toneColor,
borderColor: toneBorder,
borderWidth: 1.5,
},
emphasis: {
itemStyle: {
borderColor: toneBorder,
borderWidth: 2,
},
},
};
}),
[mapPalette, nodes],
);
const mapOption = useMemo<EChartsCoreOption>(
() => ({
animation: false,
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
transitionDuration: 0,
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,
layoutCenter: ['50%', '50%'],
layoutSize: '132%',
zoom: 1.12,
itemStyle: {
areaColor: mapPalette.areaColor,
borderColor: mapPalette.borderColor,
borderWidth: 0.7,
},
emphasis: {
disabled: true,
},
},
series: [
{
type: 'scatter',
coordinateSystem: 'geo',
data: mapNodes,
progressive: 64,
large: true,
largeThreshold: 24,
symbolSize: (value: unknown) => {
const size = Array.isArray(value) && typeof value[2] === 'number' ? value[2] : 1;
return Math.max(8, Math.min(18, 8 + Math.log10(size + 1) * 3.6));
},
label: {
show: false,
color: mapPalette.labelColor,
fontSize: 11,
},
emphasis: {
scale: 1.12,
label: {
show: mapNodes.length > 0 && mapNodes.length <= 5,
position: 'right',
distance: 8,
formatter: '{b}',
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],
},
},
},
],
}),
[isDark, mapNodes, mapPalette],
);
if (!mapReady) {
return (
<div className="flex h-full items-center justify-center">
<EmptyState
title={mapFailed ? '全球地图加载失败' : '全球地图加载中'}
description={
mapFailed
? 'ECharts 世界地图资源未能成功注册,请稍后刷新重试。'
: '正在按需初始化全球地图,这一步会延后到首屏内容稳定后执行。'
}
/>
</div>
);
}
return (
<ReactEChartsCore
echarts={echarts}
option={mapOption}
notMerge
lazyUpdate
opts={{ renderer: 'canvas' }}
onEvents={{
click: (params: { data?: MapNodeDatum }) => {
if (params.data?.route) {
router.push(params.data.route);
}
},
}}
style={{ height: '100%', width: '100%' }}
/>
);
}
@@ -1,15 +1,11 @@
'use client';
import * as echarts from 'echarts';
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 dynamic from 'next/dynamic';
import { useEffect, useRef, useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import { useTheme } from '@/components/providers/theme-provider';
import worldGeoJson from '@/features/dashboard/data/world-geo.json';
import { StatusBadge } from '@/components/ui/status-badge';
import type {
DashboardConfig,
@@ -27,100 +23,15 @@ import {
import { cn } from '@/lib/utils/cn';
import { formatDateTime } from '@/lib/utils/date';
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;
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;
};
};
};
type WorldGeoJson = {
type: string;
features?: unknown[];
};
let worldMapRegistrationAttempted = false;
let worldMapRegistrationSucceeded = false;
let worldMapRegistrationError: Error | null = null;
function ensureWorldMapRegistered() {
if (worldMapRegistrationSucceeded || echarts.getMap('world')) {
worldMapRegistrationSucceeded = true;
worldMapRegistrationError = null;
return true;
}
if (worldMapRegistrationAttempted) {
return false;
}
worldMapRegistrationAttempted = true;
try {
const geoJson = worldGeoJson as WorldGeoJson;
if (geoJson.type !== 'FeatureCollection' || !Array.isArray(geoJson.features)) {
throw new Error('invalid world geojson payload');
}
echarts.registerMap(
'world',
geoJson as unknown as Parameters<typeof echarts.registerMap>[1],
);
if (!echarts.getMap('world')) {
throw new Error('world map registration failed');
}
worldMapRegistrationSucceeded = true;
worldMapRegistrationError = null;
return true;
} catch (error) {
worldMapRegistrationSucceeded = false;
worldMapRegistrationError =
error instanceof Error ? error : new Error('unknown world map registration error');
console.error('Failed to register ECharts world map', worldMapRegistrationError);
return false;
}
}
const WorldStageMap = dynamic(
() =>
import('@/features/dashboard/components/world-stage-map').then(
(module) => module.WorldStageMap,
),
{ ssr: false },
);
function formatPercent(value: number) {
if (!Number.isFinite(value)) {
@@ -136,46 +47,6 @@ function buildNodeDetailHref(id?: number | null) {
return `/node/detail?id=${id}`;
}
function getNodeTone(node: DashboardNodeHealth): Tone {
if (
node.status === 'offline' ||
node.openresty_status === 'unhealthy' ||
node.active_event_count > 0
) {
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 {
coordinates: [node.geo_longitude, node.geo_latitude] as [number, number],
derivedFromGeo: true,
};
}
return {
coordinates: [...fallbackCoordinates[index % fallbackCoordinates.length]] as [
number,
number,
],
derivedFromGeo: false,
};
}
function HeroMetric({
label,
value,
@@ -190,7 +61,7 @@ function HeroMetric({
return (
<div
className={cn(
'rounded-[24px] border px-4 py-4 backdrop-blur',
'rounded-[24px] border px-5 py-5 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)]',
@@ -206,7 +77,7 @@ function HeroMetric({
</p>
<p
className={cn(
'mt-3 text-2xl font-semibold',
'mt-3 text-[30px] font-semibold leading-none',
isDark ? 'text-white' : 'text-slate-950',
)}
>
@@ -315,17 +186,38 @@ 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);
const mapViewportRef = useRef<HTMLDivElement | null>(null);
const [shouldRenderMap, setShouldRenderMap] = useState(false);
useEffect(() => {
const ready = ensureWorldMapRegistered();
setMapReady(ready);
setMapFailed(!ready);
}, []);
if (shouldRenderMap) {
return;
}
const mapViewport = mapViewportRef.current;
if (!mapViewport || typeof IntersectionObserver === 'undefined') {
setShouldRenderMap(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setShouldRenderMap(true);
observer.disconnect();
}
},
{ rootMargin: '180px 0px' },
);
observer.observe(mapViewport);
return () => {
observer.disconnect();
};
}, [shouldRenderMap]);
const onlineRate =
summary.total_nodes > 0
@@ -349,185 +241,6 @@ export function WorldStage({
typeof node.geo_longitude === 'number',
).length;
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,
regions: [],
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={cn(
@@ -539,7 +252,7 @@ export function WorldStage({
>
<div
className={cn(
'border-b px-6 py-5 md:px-7',
'border-b px-6 py-6 md:px-7 md:py-7',
isDark ? 'border-white/8' : 'border-slate-200/70',
)}
>
@@ -567,8 +280,8 @@ export function WorldStage({
isDark ? 'text-slate-300' : 'text-slate-600',
)}
>
先把节点健康、配置追平、活动风险和全球来源信号拉到同一张首屏,
让总览页真正承担值守入口的职责。
在同一视图汇总节点在线率、运行状态、配置一致性与全球流量来源,
让首屏可以直接承担值守与研判入口。
</p>
</div>
<div
@@ -584,11 +297,11 @@ export function WorldStage({
</div>
</div>
<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="grid gap-6 px-6 py-7 md:px-7 md:py-8 xl:grid-cols-[1.4fr_0.8fr]">
<div className="space-y-5">
<div
className={cn(
'relative min-h-[420px] overflow-hidden rounded-[28px] border',
'relative min-h-[456px] overflow-hidden rounded-[28px] border py-5',
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))]',
@@ -602,45 +315,34 @@ export function WorldStage({
: 'bg-sky-100/90 text-sky-700',
)}
>
{geoConfiguredNodes > 0 ? '真实节点点位' : '节点信号覆盖'}
{geoConfiguredNodes > 0 ? '节点地理坐标' : '节点覆盖信号'}
</div>
<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} />
<LegendPill label="绿色: 运行正常" tone="healthy" isDark={isDark} />
<LegendPill label="黄色: 资源承压" tone="warning" isDark={isDark} />
<LegendPill label="红色: 异常待处理" tone="danger" isDark={isDark} />
</div>
<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 className="flex h-full items-center justify-center">
<EmptyState
title={mapFailed ? '全球地图加载失败' : '全球地图加载中'}
description={
mapFailed
? 'ECharts 世界地图资源未能成功注册,请稍后刷新重试。'
: '正在准备 ECharts 世界地图与节点信号点位。'
}
/>
</div>
)}
<div
ref={mapViewportRef}
className="absolute inset-x-3 top-14 bottom-24 flex items-center justify-center md:inset-x-4 md:top-16 md:bottom-28"
>
<div className="aspect-[2/1] h-auto max-h-full w-full">
{shouldRenderMap ? (
<WorldStageMap isDark={isDark} nodes={nodes} />
) : (
<div className="flex h-full items-center justify-center">
<EmptyState
title="全球地图准备中"
description="地图会在进入可视区域后再加载,避免首页滚动和交互被首屏图表拖慢。"
/>
</div>
)}
</div>
</div>
{mapReady && nodes.length === 0 ? (
{shouldRenderMap && nodes.length === 0 ? (
<div className="pointer-events-none absolute inset-x-6 bottom-24 z-10">
<div
className={cn(
@@ -650,12 +352,12 @@ export function WorldStage({
: '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">
<div className="absolute bottom-5 left-4 right-4 z-10 grid gap-3 md:grid-cols-3">
{sourceCountries.length > 0 ? (
sourceCountries.slice(0, 3).map((item, index) => (
<CountrySignal
@@ -674,7 +376,7 @@ export function WorldStage({
: 'border-slate-300/70 bg-white/78 text-slate-600',
)}
>
当前还没有可用于全球分布展示的来源国家数据。
暂无可用于全球来源展示的国家分布数据。
</div>
)}
</div>
@@ -682,41 +384,41 @@ export function WorldStage({
<div
className={cn(
'rounded-[24px] border px-4 py-3 text-xs leading-6 backdrop-blur',
'rounded-[24px] border px-5 py-4 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} 个节点配置了真实地图坐标。
未配置坐标的节点会落在预设世界城市点位,保证首页仍能看到全球覆盖信号。
当前已有 {geoConfiguredNodes}/{summary.total_nodes} 个节点配置了地理坐标。
未配置坐标的节点会映射到预设城市点位,用于维持全球覆盖视图的连续性。
</div>
</div>
<div className="space-y-4">
<div className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
<HeroMetric
label="在线覆盖"
value={formatPercent(onlineRate)}
hint={`${summary.online_nodes}/${summary.total_nodes} 节点在线`}
hint={`${summary.online_nodes}/${summary.total_nodes} 个节点在线`}
isDark={isDark}
/>
<HeroMetric
label="运行健康"
value={formatPercent(healthyRate)}
hint={`${summary.unhealthy_nodes} 个 OpenResty 不健康`}
hint={`${summary.unhealthy_nodes} 个节点存在 OpenResty 异常`}
isDark={isDark}
/>
<HeroMetric
label="配置追平"
value={formatPercent(syncRate)}
hint={`${summary.lagging_nodes} 个节点未追平 ${config.active_version || '当前激活版本'}`}
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`}
hint={`${risk.critical_alerts} 个严重告警 · ${risk.warning_alerts} 个预警`}
isDark={isDark}
/>
</div>
@@ -737,7 +439,7 @@ export function WorldStage({
isDark ? 'text-slate-300' : 'text-slate-500',
)}
>
节点健康清单
节点风险清单
</p>
<p
className={cn(
@@ -745,7 +447,7 @@ export function WorldStage({
isDark ? 'text-white' : 'text-slate-950',
)}
>
风险优先队列
优先处置节点
</p>
</div>
<Link
@@ -788,7 +490,7 @@ export function WorldStage({
isDark ? 'text-slate-400' : 'text-slate-500',
)}
>
请求 {node.request_count.toLocaleString('zh-CN')} · 错误{' '}
请求量 {node.request_count.toLocaleString('zh-CN')} · 错误数{' '}
{node.error_count.toLocaleString('zh-CN')}
</p>
</div>
@@ -816,7 +518,7 @@ export function WorldStage({
: 'border-slate-300/70 bg-slate-50/75 text-slate-600',
)}
>
当前没有节点健康数据,等节点开始 heartbeat 后这里会出现风险优先队列。
当前暂无节点运行数据。节点开始上报 heartbeat 后,这里会展示待优先处理的节点。
</div>
) : null}
</div>