mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 01:36:37 +08:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user