mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 23:56:37 +08:00
style(wavelet): 全球态势板对齐系统 Card/Badge 风格
移除旧前端渐变外壳,指标卡与图例复用总览页虚线 Card 与状态 Badge 样式。
This commit is contained in:
@@ -6,8 +6,9 @@ import * as echarts from 'echarts/core';
|
|||||||
import { MapChart, ScatterChart } from 'echarts/charts';
|
import { MapChart, ScatterChart } from 'echarts/charts';
|
||||||
import { GeoComponent, TooltipComponent } from 'echarts/components';
|
import { GeoComponent, TooltipComponent } from 'echarts/components';
|
||||||
import { CanvasRenderer } from 'echarts/renderers';
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
import { useRouter } from 'next/navigation';
|
import {useTheme} from 'next-themes';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import {useRouter} from 'next/navigation';
|
||||||
|
import {useEffect, useMemo, useRef, useState} from 'react';
|
||||||
|
|
||||||
import {EmptyState} from '@/components/layout/empty';
|
import {EmptyState} from '@/components/layout/empty';
|
||||||
import type {DashboardNodeHealth, DistributionItem} from '@/lib/services/openflare';
|
import type {DashboardNodeHealth, DistributionItem} from '@/lib/services/openflare';
|
||||||
@@ -192,14 +193,14 @@ function ensureWorldMapRegistered() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WorldStageMap({
|
export function WorldStageMap({
|
||||||
isDark,
|
|
||||||
nodes,
|
nodes,
|
||||||
sourceCountries,
|
sourceCountries,
|
||||||
}: {
|
}: {
|
||||||
isDark: boolean;
|
|
||||||
nodes: DashboardNodeHealth[];
|
nodes: DashboardNodeHealth[];
|
||||||
sourceCountries: DistributionItem[];
|
sourceCountries: DistributionItem[];
|
||||||
}) {
|
}) {
|
||||||
|
const {resolvedTheme} = useTheme();
|
||||||
|
const isDark = resolvedTheme === 'dark';
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const chartContainerRef = useRef<HTMLDivElement | null>(null);
|
const chartContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [mapReady, setMapReady] = useState(false);
|
const [mapReady, setMapReady] = useState(false);
|
||||||
@@ -251,30 +252,26 @@ export function WorldStageMap({
|
|||||||
() =>
|
() =>
|
||||||
isDark
|
isDark
|
||||||
? {
|
? {
|
||||||
areaColor: '#13233b',
|
areaColor: '#1c1f26',
|
||||||
borderColor: 'rgba(125,211,252,0.14)',
|
borderColor: 'rgba(148,163,184,0.16)',
|
||||||
labelColor: '#e2e8f0',
|
labelColor: '#e2e8f0',
|
||||||
sourceAreaColor: 'rgba(56, 189, 248, 0.18)',
|
|
||||||
sourceAreaBorder: 'rgba(125, 211, 252, 0.45)',
|
|
||||||
healthyColor: '#34d399',
|
healthyColor: '#34d399',
|
||||||
warningColor: '#fbbf24',
|
warningColor: '#fbbf24',
|
||||||
dangerColor: '#fb7185',
|
dangerColor: '#f87171',
|
||||||
healthyBorder: '#bbf7d0',
|
healthyBorder: '#6ee7b7',
|
||||||
warningBorder: '#fde68a',
|
warningBorder: '#fcd34d',
|
||||||
dangerBorder: '#fecdd3',
|
dangerBorder: '#fca5a5',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
areaColor: '#eaf2ff',
|
areaColor: '#f4f4f5',
|
||||||
borderColor: 'rgba(71,85,105,0.12)',
|
borderColor: 'rgba(148,163,184,0.28)',
|
||||||
labelColor: '#0f172a',
|
labelColor: '#18181b',
|
||||||
sourceAreaColor: 'rgba(14, 165, 233, 0.22)',
|
|
||||||
sourceAreaBorder: 'rgba(14, 165, 233, 0.46)',
|
|
||||||
healthyColor: '#10b981',
|
healthyColor: '#10b981',
|
||||||
warningColor: '#f59e0b',
|
warningColor: '#f59e0b',
|
||||||
dangerColor: '#f43f5e',
|
dangerColor: '#ef4444',
|
||||||
healthyBorder: '#d1fae5',
|
healthyBorder: '#6ee7b7',
|
||||||
warningBorder: '#fde68a',
|
warningBorder: '#fcd34d',
|
||||||
dangerBorder: '#fecdd3',
|
dangerBorder: '#fca5a5',
|
||||||
},
|
},
|
||||||
[isDark],
|
[isDark],
|
||||||
);
|
);
|
||||||
@@ -342,11 +339,11 @@ export function WorldStageMap({
|
|||||||
const areaOpacity = Number((0.14 + intensity * 0.58).toFixed(3));
|
const areaOpacity = Number((0.14 + intensity * 0.58).toFixed(3));
|
||||||
const borderOpacity = Number((0.22 + intensity * 0.48).toFixed(3));
|
const borderOpacity = Number((0.22 + intensity * 0.48).toFixed(3));
|
||||||
const areaColor = isDark
|
const areaColor = isDark
|
||||||
? `rgba(56, 189, 248, ${areaOpacity})`
|
? `rgba(96, 165, 250, ${areaOpacity})`
|
||||||
: `rgba(14, 165, 233, ${areaOpacity})`;
|
: `rgba(59, 130, 246, ${areaOpacity})`;
|
||||||
const borderColor = isDark
|
const borderColor = isDark
|
||||||
? `rgba(125, 211, 252, ${borderOpacity})`
|
? `rgba(147, 197, 253, ${borderOpacity})`
|
||||||
: `rgba(14, 165, 233, ${borderOpacity})`;
|
: `rgba(59, 130, 246, ${borderOpacity})`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: item.key,
|
name: item.key,
|
||||||
@@ -397,14 +394,14 @@ export function WorldStageMap({
|
|||||||
trigger: 'item',
|
trigger: 'item',
|
||||||
transitionDuration: 0,
|
transitionDuration: 0,
|
||||||
backgroundColor: isDark
|
backgroundColor: isDark
|
||||||
? 'rgba(15,23,42,0.96)'
|
? 'rgba(24,24,27,0.96)'
|
||||||
: 'rgba(255,255,255,0.96)',
|
: 'rgba(255,255,255,0.98)',
|
||||||
borderColor: isDark
|
borderColor: isDark
|
||||||
? 'rgba(148,163,184,0.2)'
|
? 'rgba(63,63,70,0.8)'
|
||||||
: 'rgba(148,163,184,0.24)',
|
: 'rgba(228,228,231,0.9)',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
textStyle: {
|
textStyle: {
|
||||||
color: isDark ? '#e2e8f0' : '#0f172a',
|
color: isDark ? '#fafafa' : '#18181b',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
},
|
},
|
||||||
formatter: (params: unknown) => {
|
formatter: (params: unknown) => {
|
||||||
@@ -508,11 +505,11 @@ export function WorldStageMap({
|
|||||||
distance: 8,
|
distance: 8,
|
||||||
formatter: '{b}',
|
formatter: '{b}',
|
||||||
backgroundColor: isDark
|
backgroundColor: isDark
|
||||||
? 'rgba(8,15,31,0.7)'
|
? 'rgba(24,24,27,0.88)'
|
||||||
: 'rgba(255,255,255,0.88)',
|
: 'rgba(255,255,255,0.94)',
|
||||||
borderColor: isDark
|
borderColor: isDark
|
||||||
? 'rgba(148,163,184,0.16)'
|
? 'rgba(63,63,70,0.7)'
|
||||||
: 'rgba(148,163,184,0.2)',
|
: 'rgba(228,228,231,0.9)',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
padding: [4, 8],
|
padding: [4, 8],
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import {useEffect, useRef, useState, type ComponentType} from 'react';
|
||||||
|
import {
|
||||||
import {useTheme} from 'next-themes';
|
Activity,
|
||||||
|
Cpu,
|
||||||
|
Globe2,
|
||||||
|
HardDrive,
|
||||||
|
MemoryStick,
|
||||||
|
Server,
|
||||||
|
ShieldCheck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import {EmptyState} from '@/components/layout/empty';
|
import {EmptyState} from '@/components/layout/empty';
|
||||||
|
import {Badge} from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import {Progress} from '@/components/ui/progress';
|
||||||
import type {
|
import type {
|
||||||
DashboardCapacity,
|
DashboardCapacity,
|
||||||
DashboardNodeHealth,
|
DashboardNodeHealth,
|
||||||
@@ -15,99 +31,63 @@ import type {
|
|||||||
} from '@/lib/services/openflare';
|
} from '@/lib/services/openflare';
|
||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
|
|
||||||
type Tone = 'healthy' | 'warning' | 'danger' | 'source';
|
import {formatCompactNumber, formatPercent} from './dashboard-utils';
|
||||||
|
|
||||||
const WorldStageMap = dynamic(
|
const WorldStageMap = dynamic(
|
||||||
() =>
|
() => import('./world-stage-map').then((module) => module.WorldStageMap),
|
||||||
import('./world-stage-map').then((module) => module.WorldStageMap),
|
|
||||||
{ssr: false},
|
{ssr: false},
|
||||||
);
|
);
|
||||||
|
|
||||||
function formatPercent(value: number) {
|
type LegendTone = 'source' | 'healthy' | 'warning' | 'danger';
|
||||||
if (!Number.isFinite(value)) {
|
|
||||||
return '0%';
|
const legendToneClass: Record<LegendTone, string> = {
|
||||||
}
|
source: 'bg-blue-500/10 border-blue-500/20 text-blue-600 dark:text-blue-400',
|
||||||
return `${value.toFixed(value >= 100 ? 0 : 1)}%`;
|
healthy:
|
||||||
|
'bg-emerald-500/10 border-emerald-500/20 text-emerald-600 dark:text-emerald-400',
|
||||||
|
warning:
|
||||||
|
'bg-amber-500/10 border-amber-500/20 text-amber-600 dark:text-amber-400',
|
||||||
|
danger:
|
||||||
|
'bg-destructive/10 border-destructive/20 text-destructive',
|
||||||
|
};
|
||||||
|
|
||||||
|
function LegendBadge({label, tone}: {label: string; tone: LegendTone}) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn('text-[10px] font-medium', legendToneClass[tone])}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HeroMetric({
|
function StageMetricCard({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
hint,
|
hint,
|
||||||
isDark,
|
icon: Icon,
|
||||||
|
progress,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
hint: string;
|
hint: string;
|
||||||
isDark: boolean;
|
icon: ComponentType<{className?: string}>;
|
||||||
|
progress?: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<Card className="border-dashed shadow-none">
|
||||||
className={cn(
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
'rounded-[24px] border px-5 py-5 backdrop-blur',
|
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||||
isDark
|
<Icon className="size-4 text-primary" />
|
||||||
? 'border-white/10 bg-white/6'
|
</CardHeader>
|
||||||
: 'border-sky-100/90 bg-white/80 shadow-[0_18px_40px_rgba(148,163,184,0.12)]',
|
<CardContent className="space-y-2">
|
||||||
)}
|
<div className="text-2xl font-semibold tracking-tight">{value}</div>
|
||||||
>
|
{typeof progress === 'number' ? (
|
||||||
<p
|
<Progress value={progress} className="h-1.5" />
|
||||||
className={cn(
|
) : null}
|
||||||
'text-[11px] tracking-[0.26em] uppercase',
|
<p className="text-[10px] text-muted-foreground">{hint}</p>
|
||||||
isDark ? 'text-slate-300' : 'text-slate-500',
|
</CardContent>
|
||||||
)}
|
</Card>
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'mt-3 text-[30px] leading-none 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 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'
|
|
||||||
: tone === 'source'
|
|
||||||
? isDark
|
|
||||||
? 'border-sky-300/20 bg-sky-400/10 text-sky-100'
|
|
||||||
: 'border-sky-200 bg-sky-50 text-sky-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={cn('rounded-full border px-3 py-1 text-[11px]', toneClass)}>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,8 +104,6 @@ export function WorldStage({
|
|||||||
nodes: DashboardNodeHealth[];
|
nodes: DashboardNodeHealth[];
|
||||||
sourceCountries: DistributionItem[];
|
sourceCountries: DistributionItem[];
|
||||||
}) {
|
}) {
|
||||||
const {resolvedTheme} = useTheme();
|
|
||||||
const isDark = resolvedTheme === 'dark';
|
|
||||||
const mapViewportRef = useRef<HTMLDivElement | null>(null);
|
const mapViewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [shouldRenderMap, setShouldRenderMap] = useState(false);
|
const [shouldRenderMap, setShouldRenderMap] = useState(false);
|
||||||
|
|
||||||
@@ -147,7 +125,7 @@ export function WorldStage({
|
|||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ rootMargin: '180px 0px' },
|
{rootMargin: '180px 0px'},
|
||||||
);
|
);
|
||||||
|
|
||||||
observer.observe(mapViewport);
|
observer.observe(mapViewport);
|
||||||
@@ -173,171 +151,108 @@ export function WorldStage({
|
|||||||
typeof node.geo_longitude === 'number',
|
typeof node.geo_longitude === 'number',
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
|
const mapModeLabel =
|
||||||
|
sourceCountries.length > 0
|
||||||
|
? '访客来源热度'
|
||||||
|
: geoConfiguredNodes > 0
|
||||||
|
? '节点地理坐标'
|
||||||
|
: '节点覆盖信号';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<div className="grid gap-6 xl:grid-cols-[1.32fr_0.88fr]">
|
||||||
className={cn(
|
<Card className="border-dashed shadow-none">
|
||||||
'overflow-hidden rounded-[32px] border transition-colors',
|
<CardHeader className="space-y-3">
|
||||||
isDark
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
? '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>
|
||||||
: '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)]',
|
<CardTitle className="text-sm font-semibold flex items-center gap-1.5">
|
||||||
)}
|
<Globe2 className="size-4 text-primary" />
|
||||||
>
|
全球态势板
|
||||||
<div
|
</CardTitle>
|
||||||
className={cn(
|
<CardDescription className="text-xs">
|
||||||
'border-b px-6 py-6 md:px-7 md:py-7',
|
节点地理分布与访客来源热度一览
|
||||||
isDark ? 'border-white/8' : 'border-slate-200/70',
|
</CardDescription>
|
||||||
)}
|
</div>
|
||||||
>
|
<Badge variant="outline" className="text-[10px] text-muted-foreground">
|
||||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
{mapModeLabel}
|
||||||
<div className="space-y-2">
|
</Badge>
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'text-[11px] tracking-[0.34em] uppercase',
|
|
||||||
isDark ? 'text-sky-200/80' : 'text-sky-700/80',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Global Stage
|
|
||||||
</p>
|
|
||||||
<h2
|
|
||||||
className={cn(
|
|
||||||
'text-2xl font-semibold',
|
|
||||||
isDark ? 'text-white' : 'text-slate-950',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
全球态势板
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex flex-wrap gap-1.5">
|
||||||
</div>
|
<LegendBadge label="国家底色: 来源热度" tone="source" />
|
||||||
|
<LegendBadge label="绿色: 运行正常" tone="healthy" />
|
||||||
<div className="grid gap-6 px-6 py-7 md:px-7 md:py-8 xl:grid-cols-[1.32fr_0.88fr]">
|
<LegendBadge label="黄色: 资源承压" tone="warning" />
|
||||||
<div className="space-y-5">
|
<LegendBadge label="红色: 异常待处理" tone="danger" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
ref={mapViewportRef}
|
||||||
'relative min-h-[540px] overflow-hidden rounded-[28px] border py-6 lg:min-h-[600px]',
|
className="relative min-h-[480px] overflow-hidden rounded-lg border border-dashed bg-muted/20 lg:min-h-[540px]"
|
||||||
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
|
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||||
className={cn(
|
{shouldRenderMap ? (
|
||||||
'absolute top-6 left-6 z-10 rounded-full px-3 py-1 text-[11px] tracking-[0.22em] uppercase backdrop-blur',
|
<WorldStageMap nodes={nodes} sourceCountries={sourceCountries} />
|
||||||
isDark
|
) : (
|
||||||
? 'bg-sky-400/20 text-sky-100'
|
<EmptyState
|
||||||
: 'bg-sky-100/90 text-sky-700',
|
title="全球地图准备中"
|
||||||
|
description="地图会在进入可视区域后再加载,避免首页滚动和交互被首屏图表拖慢。"
|
||||||
|
iconSize="sm"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
{sourceCountries.length > 0
|
|
||||||
? '访客来源热度'
|
|
||||||
: geoConfiguredNodes > 0
|
|
||||||
? '节点地理坐标'
|
|
||||||
: '节点覆盖信号'}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute top-4 right-4 z-10 flex flex-wrap gap-2">
|
|
||||||
<LegendPill
|
|
||||||
label="国家底色: 来源热度"
|
|
||||||
tone="source"
|
|
||||||
isDark={isDark}
|
|
||||||
/>
|
|
||||||
<LegendPill
|
|
||||||
label="绿色: 运行正常"
|
|
||||||
tone="healthy"
|
|
||||||
isDark={isDark}
|
|
||||||
/>
|
|
||||||
<LegendPill
|
|
||||||
label="黄色: 资源承压"
|
|
||||||
tone="warning"
|
|
||||||
isDark={isDark}
|
|
||||||
/>
|
|
||||||
<LegendPill
|
|
||||||
label="红色: 异常待处理"
|
|
||||||
tone="danger"
|
|
||||||
isDark={isDark}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={mapViewportRef}
|
|
||||||
className="absolute inset-x-3 top-16 bottom-6 flex items-center justify-center md:inset-x-4 md:top-18 md:bottom-8"
|
|
||||||
>
|
|
||||||
<div className="h-full w-full min-w-0 overflow-hidden">
|
|
||||||
{shouldRenderMap ? (
|
|
||||||
<WorldStageMap
|
|
||||||
isDark={isDark}
|
|
||||||
nodes={nodes}
|
|
||||||
sourceCountries={sourceCountries}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center">
|
|
||||||
<EmptyState
|
|
||||||
title="全球地图准备中"
|
|
||||||
description="地图会在进入可视区域后再加载,避免首页滚动和交互被首屏图表拖慢。"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shouldRenderMap && nodes.length === 0 ? (
|
{shouldRenderMap && nodes.length === 0 ? (
|
||||||
<div className="pointer-events-none absolute inset-x-6 bottom-10 z-10">
|
<div className="pointer-events-none absolute inset-x-4 bottom-4 z-10">
|
||||||
<div
|
<div className="rounded-lg border border-dashed bg-background/90 px-4 py-3 text-xs text-muted-foreground backdrop-blur-sm">
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-2">
|
<StageMetricCard
|
||||||
<HeroMetric
|
label="在线覆盖"
|
||||||
label="在线覆盖"
|
value={formatPercent(onlineRate)}
|
||||||
value={formatPercent(onlineRate)}
|
hint={`${summary.online_nodes}/${summary.total_nodes} 个节点在线`}
|
||||||
hint={`${summary.online_nodes}/${summary.total_nodes} 个节点在线`}
|
icon={Server}
|
||||||
isDark={isDark}
|
progress={onlineRate}
|
||||||
/>
|
/>
|
||||||
<HeroMetric
|
<StageMetricCard
|
||||||
label="运行健康"
|
label="运行健康"
|
||||||
value={formatPercent(healthyRate)}
|
value={formatPercent(healthyRate)}
|
||||||
hint={`${summary.unhealthy_nodes} 个节点存在 OpenResty 异常`}
|
hint={`${summary.unhealthy_nodes} 个节点存在 OpenResty 异常`}
|
||||||
isDark={isDark}
|
icon={ShieldCheck}
|
||||||
/>
|
progress={healthyRate}
|
||||||
<HeroMetric
|
/>
|
||||||
label="最近窗口请求"
|
<StageMetricCard
|
||||||
value={traffic.request_count.toLocaleString('zh-CN')}
|
label="最近窗口请求"
|
||||||
hint={`QPS ${traffic.estimated_qps.toFixed(1)} · ${traffic.reported_nodes} 个节点已上报`}
|
value={formatCompactNumber(traffic.request_count)}
|
||||||
isDark={isDark}
|
hint={`QPS ${traffic.estimated_qps.toFixed(1)} · ${traffic.reported_nodes} 个节点已上报`}
|
||||||
/>
|
icon={Activity}
|
||||||
<HeroMetric
|
/>
|
||||||
label="平均 CPU"
|
<StageMetricCard
|
||||||
value={formatPercent(capacity.average_cpu_usage_percent)}
|
label="平均 CPU"
|
||||||
hint={`${capacity.high_cpu_nodes} 个节点 CPU 偏高`}
|
value={formatPercent(capacity.average_cpu_usage_percent)}
|
||||||
isDark={isDark}
|
hint={`${capacity.high_cpu_nodes} 个节点 CPU 偏高`}
|
||||||
/>
|
icon={Cpu}
|
||||||
<HeroMetric
|
progress={capacity.average_cpu_usage_percent}
|
||||||
label="平均内存"
|
/>
|
||||||
value={formatPercent(capacity.average_memory_usage_percent)}
|
<StageMetricCard
|
||||||
hint={`${capacity.high_memory_nodes} 个高内存节点`}
|
label="平均内存"
|
||||||
isDark={isDark}
|
value={formatPercent(capacity.average_memory_usage_percent)}
|
||||||
/>
|
hint={`${capacity.high_memory_nodes} 个高内存节点`}
|
||||||
<HeroMetric
|
icon={MemoryStick}
|
||||||
label="高存储节点"
|
progress={capacity.average_memory_usage_percent}
|
||||||
value={capacity.high_storage_nodes.toLocaleString('zh-CN')}
|
/>
|
||||||
hint={`${summary.offline_nodes} 离线 · ${summary.pending_nodes} 待接入`}
|
<StageMetricCard
|
||||||
isDark={isDark}
|
label="高存储节点"
|
||||||
/>
|
value={formatCompactNumber(capacity.high_storage_nodes)}
|
||||||
</div>
|
hint={`${summary.offline_nodes} 离线 · ${summary.pending_nodes} 待接入`}
|
||||||
</div>
|
icon={HardDrive}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,7 @@ sidebar: false
|
|||||||
|
|
||||||
- Wavelet 总览仪表盘图表对齐旧前端 ECharts 样式:24 小时请求/容量趋势补充请求量与错误量展示,新增网络与磁盘趋势、Top 节点榜单、来源分布、状态码分布与 Top Domain 板块。
|
- Wavelet 总览仪表盘图表对齐旧前端 ECharts 样式:24 小时请求/容量趋势补充请求量与错误量展示,新增网络与磁盘趋势、Top 节点榜单、来源分布、状态码分布与 Top Domain 板块。
|
||||||
- Wavelet 总览仪表盘迁移旧前端「全球态势板」:世界地图展示节点健康与来源国家热度,并保留在线覆盖、流量与容量摘要指标。
|
- Wavelet 总览仪表盘迁移旧前端「全球态势板」:世界地图展示节点健康与来源国家热度,并保留在线覆盖、流量与容量摘要指标。
|
||||||
|
- Wavelet 全球态势板视觉对齐系统风格:改用 `Card` / `Badge` / `Progress` 与虚线边框指标卡,地图配色与 tooltip 跟随主题 token。
|
||||||
|
|
||||||
- 将 `openflare-server` 后端业务域迁移至 `Wavelet/internal/apps/openflare/`,通过 `/api/*` legacy 兼容层保持旧前端 API 路径不变。
|
- 将 `openflare-server` 后端业务域迁移至 `Wavelet/internal/apps/openflare/`,通过 `/api/*` legacy 兼容层保持旧前端 API 路径不变。
|
||||||
- 新增 OpenFlare 业务表 goose 迁移(`of_options`、`of_origins`、`of_proxy_routes`、`of_nodes`、`of_waf_*`、`of_tls_*`、`of_config_versions`、`of_pages_*`、`of_apply_logs` 等)。
|
- 新增 OpenFlare 业务表 goose 迁移(`of_options`、`of_origins`、`of_proxy_routes`、`of_nodes`、`of_waf_*`、`of_tls_*`、`of_config_versions`、`of_pages_*`、`of_apply_logs` 等)。
|
||||||
|
|||||||
Reference in New Issue
Block a user