refactor(repo): consolidate openflare-server to root and move subprojects to internal/apps

- Merge all files inside openflare-server to the repository root directory.
- Relocate agent, relay, and flared subprojects from internal/ to internal/apps/.
- Combine docker-compose files and update build context paths to root.
- Update GitHub workflows and Dockerfiles to refer to new directories and package names.
- Rewrite Go package imports across all files.
- Resolve database renew test race condition and clean up docs.
This commit is contained in:
ryan
2026-06-19 14:23:29 +08:00
parent 19d476ed7f
commit 63cd906cfc
1064 changed files with 366 additions and 1397 deletions
@@ -0,0 +1,54 @@
'use client';
import {TrendChart} from '@/components/data/trend-chart';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import type {CapacityTrendPoint} from '@/lib/services/openflare';
import {formatPercent, formatTrendHour} from './dashboard-utils';
export function CapacityTrendChart({
points,
title = '24 小时容量趋势',
description = '按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。',
}: {
points: CapacityTrendPoint[];
title?: string;
description?: string;
}) {
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
<CardDescription className="text-xs">{description}</CardDescription>
</CardHeader>
<CardContent>
<TrendChart
labels={points.map((point) => formatTrendHour(point.bucket_started_at))}
yAxisValueFormatter={formatPercent}
series={[
{
label: '平均 CPU',
color: '#0f766e',
fillColor: 'rgba(15, 118, 110, 0.15)',
variant: 'area',
values: points.map((point) => point.average_cpu_usage_percent),
valueFormatter: formatPercent,
},
{
label: '平均内存',
color: '#2563eb',
values: points.map((point) => point.average_memory_usage_percent),
valueFormatter: formatPercent,
},
]}
/>
</CardContent>
</Card>
);
}
@@ -0,0 +1,15 @@
export {
calculateNiceAxisMax,
formatBytes,
formatBytesPerSecond,
formatCompactNumber,
formatPercent,
} from '@/lib/utils/metrics';
export function formatTrendHour(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return `${date.getHours().toString().padStart(2, '0')}:00`;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,93 @@
'use client';
import {RankChart} from '@/components/data/rank-chart';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import type {DistributionItem, TrafficDistributions} from '@/lib/services/openflare';
function toRankItems(items: DistributionItem[]) {
return items.map((item) => ({
label: item.key,
value: item.value,
}));
}
export function SourceDistributionChart({
items,
}: {
items: TrafficDistributions['source_countries'];
}) {
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">来源分布</CardTitle>
<CardDescription className="text-xs">
聚合最近 24 小时主要来源国家。
</CardDescription>
</CardHeader>
<CardContent>
<RankChart
items={toRankItems(items)}
color="#38bdf8"
emptyMessage="暂无来源分布数据"
/>
</CardContent>
</Card>
);
}
export function StatusCodeDistributionChart({
items,
}: {
items: TrafficDistributions['status_codes'];
}) {
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">状态码分布</CardTitle>
<CardDescription className="text-xs">
快速判断成功响应是否仍是主流,以及错误码是否有抬升。
</CardDescription>
</CardHeader>
<CardContent>
<RankChart
items={toRankItems(items).map((item) => ({
...item,
label: `HTTP ${item.label}`,
}))}
color="#f59e0b"
emptyMessage="暂无状态码分布"
/>
</CardContent>
</Card>
);
}
export function TopDomainChart({
items,
}: {
items: TrafficDistributions['top_domains'];
}) {
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">Top Domain</CardTitle>
<CardDescription className="text-xs">
观察主要流量集中在哪些域名。
</CardDescription>
</CardHeader>
<CardContent>
<RankChart
items={toRankItems(items)}
color="#34d399"
emptyMessage="暂无域名分布"
/>
</CardContent>
</Card>
);
}
@@ -0,0 +1,54 @@
'use client';
import {Globe2} from 'lucide-react';
import {Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
import {Progress} from '@/components/ui/progress';
import type {DistributionItem} from '@/lib/services/openflare';
import {formatCompactNumber} from './dashboard-utils';
export function GeoDistributionList({ items }: { items: DistributionItem[] }) {
const sortedItems = [...items]
.sort((left, right) => right.value - left.value)
.slice(0, 8);
const maxValue = sortedItems[0]?.value ?? 0;
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold flex items-center gap-1.5">
<Globe2 className="size-4 text-primary" />
来源国家分布
</CardTitle>
<CardDescription className="text-xs">
聚合最近 24 小时主要来源国家。
</CardDescription>
</CardHeader>
<CardContent>
{sortedItems.length === 0 ? (
<div className="flex min-h-[180px] items-center justify-center text-xs text-muted-foreground">
暂无来源分布数据
</div>
) : (
<div className="space-y-3">
{sortedItems.map((item) => {
const ratio = maxValue > 0 ? (item.value / maxValue) * 100 : 0;
return (
<div key={item.key} className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="font-medium">{item.key || '未知'}</span>
<span className="font-mono tabular-nums text-muted-foreground">
{formatCompactNumber(item.value)}
</span>
</div>
<Progress value={ratio} className="h-1.5" />
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,73 @@
'use client';
import {TrendChart} from '@/components/data/trend-chart';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import type {DiskIOTrendPoint, NetworkTrendPoint} from '@/lib/services/openflare';
import {formatBytes, formatBytesPerSecond, formatTrendHour} from './dashboard-utils';
export function NetworkDiskTrendChart({
networkPoints,
diskPoints,
}: {
networkPoints: NetworkTrendPoint[];
diskPoints: DiskIOTrendPoint[];
}) {
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">24 小时网络与磁盘趋势</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<TrendChart
labels={networkPoints.map((point) => formatTrendHour(point.bucket_started_at))}
height={180}
yAxisValueFormatter={(value) => formatBytesPerSecond(value, 3600)}
series={[
{
label: 'OpenResty 入站',
color: '#22c55e',
fillColor: 'rgba(34, 197, 94, 0.14)',
variant: 'area',
values: networkPoints.map((point) => point.openresty_rx_bytes),
valueFormatter: (value) => formatBytesPerSecond(value, 3600),
},
{
label: 'OpenResty 出站',
color: '#38bdf8',
values: networkPoints.map((point) => point.openresty_tx_bytes),
valueFormatter: (value) => formatBytesPerSecond(value, 3600),
},
]}
/>
<TrendChart
labels={diskPoints.map((point) => formatTrendHour(point.bucket_started_at))}
height={180}
yAxisValueFormatter={formatBytes}
series={[
{
label: '磁盘读',
color: '#a78bfa',
fillColor: 'rgba(167, 139, 250, 0.14)',
variant: 'area',
values: diskPoints.map((point) => point.disk_read_bytes),
valueFormatter: formatBytes,
},
{
label: '磁盘写',
color: '#fb7185',
values: diskPoints.map((point) => point.disk_write_bytes),
valueFormatter: formatBytes,
},
]}
/>
</CardContent>
</Card>
);
}
@@ -0,0 +1,132 @@
'use client';
import Link from 'next/link';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
import type {DashboardNodeHealth} from '@/lib/services/openflare';
import {NodeStatusBadge} from '../../nodes/components/node-status-badge';
import {
formatRelativeTime,
getNodeStatusLabel,
getNodeStatusTone,
getOpenrestyStatusLabel,
getOpenrestyStatusTone,
isWSConnectedLastSeen,
} from '../../nodes/components/node-utils';
import {formatCompactNumber, formatPercent} from './dashboard-utils';
export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
const sortedNodes = [...nodes].sort((left, right) => {
if (right.active_event_count !== left.active_event_count) {
return right.active_event_count - left.active_event_count;
}
const leftPressure = Math.max(
left.cpu_usage_percent,
left.memory_usage_percent,
left.storage_usage_percent,
);
const rightPressure = Math.max(
right.cpu_usage_percent,
right.memory_usage_percent,
right.storage_usage_percent,
);
return rightPressure - leftPressure;
});
return (
<Card className="border-dashed shadow-none">
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle className="text-sm font-semibold">节点健康列表</CardTitle>
<CardDescription className="text-xs">
按异常数量和资源压力排序。
</CardDescription>
</div>
<Button variant="outline" size="sm" className="h-8 text-xs" asChild>
<Link href="/nodes">进入节点页</Link>
</Button>
</CardHeader>
<CardContent>
{sortedNodes.length === 0 ? (
<EmptyStateWithBorder
title="暂无节点"
description="节点接入后,这里会展示系统健康与容量摘要。"
/>
) : (
<div className="border border-dashed rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="border-dashed hover:bg-transparent">
<TableHead className="py-2 h-8">节点</TableHead>
<TableHead className="py-2 h-8">状态</TableHead>
<TableHead className="py-2 h-8">CPU / 内存</TableHead>
<TableHead className="py-2 h-8">请求 / 错误</TableHead>
<TableHead className="py-2 h-8">活动异常</TableHead>
<TableHead className="py-2 h-8">最近心跳</TableHead>
<TableHead className="py-2 h-8 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedNodes.slice(0, 12).map((node) => (
<TableRow key={node.node_id} className="border-dashed align-top">
<TableCell className="py-3">
<div className="space-y-1">
<p className="font-medium">{node.name}</p>
<p className="text-xs text-muted-foreground">{node.node_id}</p>
<p className="text-xs text-muted-foreground">
{node.geo_name || '未配置地图点位'}
</p>
</div>
</TableCell>
<TableCell className="py-3">
<div className="flex flex-wrap gap-1.5">
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
tone={getNodeStatusTone(node.status)}
/>
<NodeStatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)}
tone={getOpenrestyStatusTone(node.openresty_status)}
/>
</div>
</TableCell>
<TableCell className="py-3 text-sm text-muted-foreground">
{formatPercent(node.cpu_usage_percent)} /{' '}
{formatPercent(node.memory_usage_percent)}
</TableCell>
<TableCell className="py-3 text-sm text-muted-foreground">
{formatCompactNumber(node.request_count)} /{' '}
{formatCompactNumber(node.error_count)}
</TableCell>
<TableCell className="py-3">
<NodeStatusBadge
label={String(node.active_event_count)}
tone={node.active_event_count > 0 ? 'danger' : 'success'}
/>
</TableCell>
<TableCell className="py-3 text-xs text-muted-foreground">
{isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接'
: node.last_seen_at
? formatRelativeTime(node.last_seen_at)
: '暂无'}
</TableCell>
<TableCell className="py-3 text-right">
<Button variant="ghost" size="sm" className="h-8 text-xs" asChild>
<Link href={`/nodes/detail?id=${node.id}`}>详情</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,73 @@
'use client';
import {RankChart} from '@/components/data/rank-chart';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import type {DashboardNodeHealth} from '@/lib/services/openflare';
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),
}));
}
export function NodeRankPanel({nodes}: {nodes: DashboardNodeHealth[]}) {
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">Top 节点榜单</CardTitle>
</CardHeader>
<CardContent className="grid gap-6">
<div>
<p className="mb-3 text-xs tracking-[0.22em] text-muted-foreground uppercase">
流量最高节点
</p>
<RankChart
items={buildNodeRankItems(nodes, (node) => node.request_count)}
color="#38bdf8"
emptyMessage="暂无流量榜单"
/>
</div>
<div>
<p className="mb-3 text-xs tracking-[0.22em] text-muted-foreground uppercase">
容量压力节点
</p>
<RankChart
items={buildNodeRankItems(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>
</CardContent>
</Card>
);
}
@@ -0,0 +1,93 @@
'use client';
import {Activity, Cpu, Gauge, Server, Users} from 'lucide-react';
import {Card, CardContent, CardHeader} from '@/components/ui/card';
import {Progress} from '@/components/ui/progress';
import type {DashboardCapacity, DashboardSummary, DashboardTraffic,} from '@/lib/services/openflare';
import {formatCompactNumber, formatPercent} from './dashboard-utils';
export function DashboardStatCards({
summary,
traffic,
capacity,
}: {
summary: DashboardSummary;
traffic: DashboardTraffic;
capacity: DashboardCapacity;
}) {
const onlineRate =
summary.total_nodes > 0
? (summary.online_nodes / summary.total_nodes) * 100
: 0;
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card className="border-dashed shadow-none">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<span className="text-xs font-medium text-muted-foreground">24h 请求量</span>
<Activity className="size-4 text-primary" />
</CardHeader>
<CardContent className="space-y-2">
<div className="text-2xl font-semibold tracking-tight">
{formatCompactNumber(traffic.request_count)}
</div>
<p className="text-[10px] text-muted-foreground">
独立访客 {formatCompactNumber(traffic.unique_visitors)} · 错误{' '}
{formatCompactNumber(traffic.error_count)} · 估算 QPS{' '}
{traffic.estimated_qps.toFixed(2)}
</p>
</CardContent>
</Card>
<Card className="border-dashed shadow-none">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<span className="text-xs font-medium text-muted-foreground">集群容量</span>
<Cpu className="size-4 text-primary" />
</CardHeader>
<CardContent className="space-y-2">
<div className="text-2xl font-semibold tracking-tight">
{formatPercent(capacity.average_cpu_usage_percent)}
<span className="ml-2 text-sm font-normal text-muted-foreground">
CPU
</span>
</div>
<Progress value={capacity.average_cpu_usage_percent} className="h-1.5" />
<p className="text-[10px] text-muted-foreground">
平均内存 {formatPercent(capacity.average_memory_usage_percent)} · 高负载节点 CPU{' '}
{capacity.high_cpu_nodes} / 内存 {capacity.high_memory_nodes} / 存储{' '}
{capacity.high_storage_nodes}
</p>
</CardContent>
</Card>
<Card className="border-dashed shadow-none md:col-span-2 xl:col-span-1">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<span className="text-xs font-medium text-muted-foreground">节点概览</span>
<Server className="size-4 text-primary" />
</CardHeader>
<CardContent className="space-y-2">
<div className="text-2xl font-semibold tracking-tight">
{summary.online_nodes}
<span className="text-sm font-normal text-muted-foreground">
{' '}
/ {summary.total_nodes} 在线
</span>
</div>
<Progress value={onlineRate} className="h-1.5" />
<p className="text-[10px] text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="inline-flex items-center gap-1">
<Users className="size-3" />
待接入 {summary.pending_nodes}
</span>
<span className="inline-flex items-center gap-1">
<Gauge className="size-3" />
异常 {summary.unhealthy_nodes} · 离线 {summary.offline_nodes}
</span>
</p>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,51 @@
'use client';
import {TrendChart} from '@/components/data/trend-chart';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import type {TrafficTrendPoint} from '@/lib/services/openflare';
import {formatTrendHour} from './dashboard-utils';
export function TrafficTrendChart({
points,
title = '24 小时请求趋势',
description = '观察整体请求量和错误量是否出现异常抬升。',
}: {
points: TrafficTrendPoint[];
title?: string;
description?: string;
}) {
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
<CardDescription className="text-xs">{description}</CardDescription>
</CardHeader>
<CardContent>
<TrendChart
labels={points.map((point) => formatTrendHour(point.bucket_started_at))}
series={[
{
label: '请求量',
color: '#f59e0b',
fillColor: 'rgba(245, 158, 11, 0.18)',
variant: 'area',
values: points.map((point) => point.request_count),
},
{
label: '错误量',
color: '#ef4444',
values: points.map((point) => point.error_count),
},
]}
/>
</CardContent>
</Card>
);
}
@@ -0,0 +1,589 @@
'use client';
import ReactEChartsCore from 'echarts-for-react/lib/core';
import type {EChartsCoreOption} from 'echarts/core';
import * as echarts from 'echarts/core';
import {MapChart, ScatterChart} from 'echarts/charts';
import {GeoComponent, TooltipComponent} from 'echarts/components';
import {CanvasRenderer} from 'echarts/renderers';
import {useTheme} from 'next-themes';
import {useRouter} from 'next/navigation';
import {useEffect, useMemo, useRef, useState} from 'react';
import {EmptyState} from '@/components/layout/empty';
import type {DashboardNodeHealth, DistributionItem} from '@/lib/services/openflare';
import {getNodeStatusLabel, getOpenrestyStatusLabel,} from '../../nodes/components/node-utils';
import worldGeoJson from './data/world-geo.json';
echarts.use([
GeoComponent,
TooltipComponent,
MapChart,
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?: WorldFeature[];
};
type WorldFeature = {
properties?: {
name?: string;
};
geometry?: {
type?: 'Polygon' | 'MultiPolygon';
coordinates?: number[][][] | number[][][][];
};
};
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;
};
};
};
type CountryRegionDatum = {
name: string;
value: number;
itemStyle: {
areaColor: string;
borderColor: string;
};
emphasis: {
itemStyle: {
areaColor: string;
borderColor: string;
};
};
};
let worldMapRegistrationAttempted = false;
let worldMapRegistrationSucceeded = false;
const baseWorldMapLayoutSizePercent = 118;
const baseWorldMapZoom = 1;
const worldMapAspectRatio = 5 / 3;
function buildNodeDetailHref(id?: number | null) {
if (!id) {
return '/nodes';
}
return `/nodes/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({
nodes,
sourceCountries,
}: {
nodes: DashboardNodeHealth[];
sourceCountries: DistributionItem[];
}) {
const {resolvedTheme} = useTheme();
const isDark = resolvedTheme === 'dark';
const router = useRouter();
const chartContainerRef = useRef<HTMLDivElement | null>(null);
const [mapReady, setMapReady] = useState(false);
const [mapFailed, setMapFailed] = useState(false);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const ready = ensureWorldMapRegistered();
setMapReady(ready);
setMapFailed(!ready);
}, []);
useEffect(() => {
if (!mapReady) {
return;
}
const container = chartContainerRef.current;
if (!container) {
return;
}
const updateSize = () => {
const nextWidth = container.clientWidth;
const nextHeight = container.clientHeight;
if (nextWidth <= 0 || nextHeight <= 0) {
return;
}
setContainerSize((previous) =>
previous.width === nextWidth && previous.height === nextHeight
? previous
: {width: nextWidth, height: nextHeight},
);
};
updateSize();
const frameId = window.requestAnimationFrame(updateSize);
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateSize);
return () => {
window.cancelAnimationFrame(frameId);
window.removeEventListener('resize', updateSize);
};
}
const observer = new ResizeObserver(() => {
updateSize();
});
observer.observe(container);
return () => {
window.cancelAnimationFrame(frameId);
observer.disconnect();
};
}, [mapReady]);
const mapPalette = useMemo(
() =>
isDark
? {
areaColor: '#1e293b',
borderColor: 'rgba(148,163,184,0.32)',
labelColor: '#e2e8f0',
healthyColor: '#34d399',
warningColor: '#fbbf24',
dangerColor: '#f87171',
healthyBorder: '#6ee7b7',
warningBorder: '#fcd34d',
dangerBorder: '#fca5a5',
}
: {
areaColor: '#e2e8f0',
borderColor: 'rgba(100,116,139,0.45)',
labelColor: '#18181b',
healthyColor: '#10b981',
warningColor: '#f59e0b',
dangerColor: '#ef4444',
healthyBorder: '#6ee7b7',
warningBorder: '#fcd34d',
dangerBorder: '#fca5a5',
},
[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 countryRegions = useMemo<CountryRegionDatum[]>(() => {
const maxRequests = Math.max(
1,
...sourceCountries.map((item) => item.value || 0),
);
return sourceCountries
.filter((item) => item.key && item.value > 0)
.map((item) => {
const intensity = Math.max(0.18, item.value / maxRequests);
const areaOpacity = Number((0.14 + intensity * 0.58).toFixed(3));
const borderOpacity = Number((0.22 + intensity * 0.48).toFixed(3));
const areaColor = isDark
? `rgba(96, 165, 250, ${areaOpacity})`
: `rgba(59, 130, 246, ${areaOpacity})`;
const borderColor = isDark
? `rgba(147, 197, 253, ${borderOpacity})`
: `rgba(59, 130, 246, ${borderOpacity})`;
return {
name: item.key,
value: item.value,
itemStyle: {
areaColor,
borderColor,
},
emphasis: {
itemStyle: {
areaColor,
borderColor,
},
},
};
});
}, [isDark, sourceCountries]);
const responsiveMapScale = useMemo(() => {
const { width, height } = containerSize;
if (width <= 0 || height <= 0) {
return 1;
}
const expectedWidth = height * worldMapAspectRatio;
const fitWidth = Math.min(width, expectedWidth);
const widthScale = Math.min(Math.max(fitWidth / 180, 0.72), 1.08);
const heightScale = Math.min(Math.max(height / 252, 0.72), 1.08);
return Number(Math.min(widthScale, heightScale).toFixed(3));
}, [containerSize]);
const computedLayoutSize = useMemo(
() =>
`${Math.round(baseWorldMapLayoutSizePercent * responsiveMapScale)}%`,
[responsiveMapScale],
);
const computedZoom = useMemo(
() => Number((baseWorldMapZoom * responsiveMapScale).toFixed(3)),
[responsiveMapScale],
);
const mapOption = useMemo<EChartsCoreOption>(
() => ({
animation: false,
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
transitionDuration: 0,
backgroundColor: isDark
? 'rgba(24,24,27,0.96)'
: 'rgba(255,255,255,0.98)',
borderColor: isDark
? 'rgba(63,63,70,0.8)'
: 'rgba(228,228,231,0.9)',
borderWidth: 1,
textStyle: {
color: isDark ? '#fafafa' : '#18181b',
fontSize: 12,
},
formatter: (params: unknown) => {
const payload = params as {
data?: MapNodeDatum | CountryRegionDatum;
seriesType?: string;
name?: string;
};
const data = payload.data;
if (
payload.seriesType !== 'scatter' &&
data &&
'value' in data &&
typeof data.value === 'number'
) {
return [
`<div style="font-weight:600;margin-bottom:6px;">${payload.name ?? data.name}</div>`,
`<div>最近 24 小时来源请求 ${data.value.toLocaleString('zh-CN')}</div>`,
].join('');
}
if (!data || !('requestCount' in 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: true,
silent: true,
layoutCenter: ['50%', '50%'],
layoutSize: computedLayoutSize,
zoom: computedZoom,
scaleLimit: {
min: Math.max(Number((computedZoom * 0.78).toFixed(3)), 0.35),
max: Math.max(Number((computedZoom * 2.2).toFixed(3)), 1.4),
},
regions: countryRegions,
itemStyle: {
areaColor: mapPalette.areaColor,
borderColor: mapPalette.borderColor,
borderWidth: 0.7,
},
emphasis: {
disabled: true,
},
},
series: [
{
type: 'map',
map: 'world',
geoIndex: 0,
data: countryRegions,
silent: true,
z: 1,
emphasis: {
disabled: true,
},
},
{
type: 'scatter',
coordinateSystem: 'geo',
data: mapNodes,
z: 3,
progressive: 64,
large: true,
largeThreshold: 24,
symbolSize: (value: unknown) => {
const size =
Array.isArray(value) && typeof value[2] === 'number'
? value[2]
: 1;
const responsiveBase = 8 + Math.log10(size + 1) * 3.6;
return Math.max(
7,
Math.min(18, responsiveBase * Math.max(responsiveMapScale, 0.88)),
);
},
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(24,24,27,0.88)'
: 'rgba(255,255,255,0.94)',
borderColor: isDark
? 'rgba(63,63,70,0.7)'
: 'rgba(228,228,231,0.9)',
borderWidth: 1,
borderRadius: 999,
padding: [4, 8],
},
},
},
],
}),
[
computedLayoutSize,
computedZoom,
countryRegions,
isDark,
mapNodes,
mapPalette,
responsiveMapScale,
],
);
if (!mapReady) {
return (
<div className="flex h-full items-center justify-center">
<EmptyState
title={mapFailed ? '全球地图加载失败' : '全球地图加载中'}
description={
mapFailed
? 'ECharts 世界地图资源未能成功注册,请稍后刷新重试。'
: '正在按需初始化全球地图,这一步会延后到首屏内容稳定后执行。'
}
/>
</div>
);
}
const chartReady = containerSize.width > 0 && containerSize.height > 0;
return (
<div ref={chartContainerRef} className="h-full min-h-0 w-full">
{chartReady ? (
<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: containerSize.height,
width: containerSize.width,
}}
/>
) : (
<div className="flex h-full items-center justify-center">
<EmptyState
title="全球地图加载中"
description="正在测量地图容器尺寸..."
iconSize="sm"
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,302 @@
'use client';
import dynamic from 'next/dynamic';
import {type ComponentType, useEffect, useRef, useState} from 'react';
import {Activity, Cpu, Globe2, HardDrive, MemoryStick, Network, Server, ShieldCheck,} from 'lucide-react';
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 {
DashboardCapacity,
DashboardNodeHealth,
DashboardSummary,
DashboardTraffic,
DistributionItem,
} from '@/lib/services/openflare';
import {cn} from '@/lib/utils';
import {formatCompactNumber, formatPercent} from './dashboard-utils';
const WorldStageMap = dynamic(
() => import('./world-stage-map').then((module) => module.WorldStageMap),
{ssr: false},
);
const LEGEND_ITEMS = [
{dot: 'bg-blue-500', label: '来源'},
{dot: 'bg-emerald-500', label: '正常'},
{dot: 'bg-amber-500', label: '承压'},
{dot: 'bg-destructive', label: '异常'},
] as const;
function SummaryMetric({
label,
value,
hint,
icon: Icon,
progress,
className,
}: {
label: string;
value: string;
hint: string;
icon: ComponentType<{className?: string}>;
progress?: number;
className?: string;
}) {
return (
<div
className={cn(
'flex min-w-0 flex-col gap-2 rounded-lg border border-dashed bg-muted/15 p-3',
className,
)}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-xs text-muted-foreground">{label}</span>
<Icon className="size-3.5 shrink-0 text-primary/60" />
</div>
<span className="text-xl font-semibold tabular-nums leading-none tracking-tight">
{value}
</span>
{typeof progress === 'number' ? (
<Progress value={progress} className="h-1" />
) : null}
<span className="truncate text-[10px] text-muted-foreground">{hint}</span>
</div>
);
}
function DetailMetric({
label,
value,
hint,
icon: Icon,
progress,
}: {
label: string;
value: string;
hint: string;
icon: ComponentType<{className?: string}>;
progress?: number;
}) {
return (
<div className="flex min-w-0 flex-col gap-1 rounded-lg border border-dashed bg-background/60 px-3 py-2.5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-[11px] text-muted-foreground">{label}</p>
<p className="mt-1 text-lg font-semibold tabular-nums leading-none tracking-tight">
{value}
</p>
</div>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
</div>
{typeof progress === 'number' ? (
<Progress value={progress} className="h-1" />
) : null}
<p className="truncate text-[10px] text-muted-foreground">{hint}</p>
</div>
);
}
export function WorldStage({
summary,
traffic,
capacity,
nodes,
sourceCountries,
}: {
summary: DashboardSummary;
traffic: DashboardTraffic;
capacity: DashboardCapacity;
nodes: DashboardNodeHealth[];
sourceCountries: DistributionItem[];
}) {
const mapViewportRef = useRef<HTMLDivElement | null>(null);
const [shouldRenderMap, setShouldRenderMap] = useState(false);
useEffect(() => {
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: '120px 0px'},
);
observer.observe(mapViewport);
return () => {
observer.disconnect();
};
}, [shouldRenderMap]);
const onlineRate =
summary.total_nodes > 0
? (summary.online_nodes / summary.total_nodes) * 100
: 0;
const healthyNodes = Math.max(
0,
summary.online_nodes - summary.unhealthy_nodes,
);
const healthyRate =
summary.total_nodes > 0 ? (healthyNodes / summary.total_nodes) * 100 : 0;
const geoConfiguredNodes = nodes.filter(
(node) =>
typeof node.geo_latitude === 'number' &&
typeof node.geo_longitude === 'number',
).length;
const mapModeLabel =
sourceCountries.length > 0
? '访客来源'
: geoConfiguredNodes > 0
? '节点坐标'
: '覆盖信号';
const headlineMetrics = [
{
label: '在线覆盖',
value: formatPercent(onlineRate),
hint: `${summary.online_nodes}/${summary.total_nodes} 在线`,
icon: Server,
progress: onlineRate,
},
{
label: '运行健康',
value: formatPercent(healthyRate),
hint: `${summary.unhealthy_nodes} 个异常`,
icon: ShieldCheck,
progress: healthyRate,
},
{
label: '最近窗口请求',
value: formatCompactNumber(traffic.request_count),
hint: `QPS ${traffic.estimated_qps.toFixed(1)} · ${traffic.reported_nodes} 个节点上报`,
icon: Activity,
},
{
label: '节点坐标',
value: formatCompactNumber(geoConfiguredNodes),
hint:
geoConfiguredNodes > 0
? `${sourceCountries.length} 个来源国家`
: '未配置时使用预设落点',
icon: Network,
},
] as const;
const detailMetrics = [
{
label: '平均 CPU',
value: formatPercent(capacity.average_cpu_usage_percent),
hint: `${capacity.high_cpu_nodes} 个偏高`,
icon: Cpu,
progress: capacity.average_cpu_usage_percent,
},
{
label: '平均内存',
value: formatPercent(capacity.average_memory_usage_percent),
hint: `${capacity.high_memory_nodes} 个偏高`,
icon: MemoryStick,
progress: capacity.average_memory_usage_percent,
},
{
label: '高存储',
value: formatCompactNumber(capacity.high_storage_nodes),
hint: `${summary.offline_nodes} 离线 · ${summary.pending_nodes} 待接入`,
icon: HardDrive,
},
] as const;
return (
<Card className="overflow-hidden border-dashed shadow-none">
<CardHeader className="gap-3 space-y-0 pb-3">
<div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-3">
<div className="min-w-0">
<CardTitle className="flex items-center gap-1.5 text-sm font-semibold">
<Globe2 className="size-4 shrink-0 text-primary" />
全球态势板
<Badge
variant="outline"
className="ml-1 text-[10px] font-normal text-muted-foreground"
>
{mapModeLabel}
</Badge>
</CardTitle>
<CardDescription className="mt-1 text-xs">
汇总节点分布、来源国家热度与最近窗口运行状态。
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-full border border-dashed bg-muted/15 px-3 py-1.5">
{LEGEND_ITEMS.map((item) => (
<span
key={item.label}
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground"
>
<span className={cn('size-1.5 rounded-full', item.dot)} />
{item.label}
</span>
))}
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4 pt-0">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{headlineMetrics.map((metric) => (
<SummaryMetric key={metric.label} {...metric} />
))}
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_18rem] xl:items-stretch">
<div className="flex min-w-0 items-center justify-center rounded-lg border border-dashed bg-muted/10 p-3">
<div
ref={mapViewportRef}
className="relative aspect-[5/3] min-h-[260px] w-full max-w-[720px] overflow-hidden rounded-md bg-background/60 sm:min-h-0"
>
{shouldRenderMap ? (
<WorldStageMap nodes={nodes} sourceCountries={sourceCountries} />
) : (
<div className="flex h-full items-center justify-center px-4">
<EmptyState
title="地图准备中"
description="进入可视区域后加载"
iconSize="sm"
/>
</div>
)}
{shouldRenderMap && nodes.length === 0 ? (
<div className="pointer-events-none absolute inset-x-3 bottom-2 z-10">
<p className="rounded-md border border-dashed bg-background/90 px-2.5 py-1.5 text-[10px] text-muted-foreground backdrop-blur-sm">
暂无节点,接入后将展示地理分布
</p>
</div>
) : null}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-1">
{detailMetrics.map((metric) => (
<DetailMetric key={metric.label} {...metric} />
))}
</div>
</div>
</CardContent>
</Card>
);
}