migrate frontend

This commit is contained in:
ryan
2026-06-18 17:43:11 +08:00
parent a9fd0bd128
commit 31b114b0cc
104 changed files with 16735 additions and 874 deletions
@@ -0,0 +1,129 @@
'use client';
import { Area, AreaChart, CartesianGrid, Line, XAxis, YAxis } from 'recharts';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
import type { CapacityTrendPoint } from '@/lib/services/openflare';
import { formatPercent, formatTrendHour } from './dashboard-utils';
const chartConfig = {
cpu: {
label: '平均 CPU',
color: 'hsl(var(--chart-2))',
},
memory: {
label: '平均内存',
color: 'hsl(var(--chart-3))',
},
} satisfies ChartConfig;
export function CapacityTrendChart({ points }: { points: CapacityTrendPoint[] }) {
const data = points.map((point) => ({
hour: formatTrendHour(point.bucket_started_at),
cpu: point.average_cpu_usage_percent,
memory: point.average_memory_usage_percent,
}));
if (data.length === 0) {
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">24 小时容量趋势</CardTitle>
<CardDescription className="text-xs">
按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex h-[280px] items-center justify-center text-xs text-muted-foreground">
暂无趋势数据
</div>
</CardContent>
</Card>
);
}
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">24 小时容量趋势</CardTitle>
<CardDescription className="text-xs">
按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。
</CardDescription>
</CardHeader>
<CardContent className="pl-2 pr-4">
<div className="h-[280px] w-full">
<ChartContainer config={chartConfig} className="h-full w-full">
<AreaChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="capacityCpuFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-cpu)" stopOpacity={0.2} />
<stop offset="95%" stopColor="var(--color-cpu)" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="hour"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={24}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
domain={[0, 100]}
tickFormatter={(value) => formatPercent(Number(value))}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
formatter={(value, name) => (
<span className="font-mono tabular-nums">
{formatPercent(Number(value))}
<span className="ml-1 text-muted-foreground">
{name === 'cpu' ? 'CPU' : '内存'}
</span>
</span>
)}
/>
}
/>
<Area
type="monotone"
dataKey="cpu"
stroke="var(--color-cpu)"
fill="url(#capacityCpuFill)"
strokeWidth={2}
/>
<Line
type="monotone"
dataKey="memory"
stroke="var(--color-memory)"
strokeWidth={2}
dot={false}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,21 @@
export function formatTrendHour(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return `${date.getHours().toString().padStart(2, '0')}:00`;
}
export function formatPercent(value: number) {
if (!Number.isFinite(value)) {
return '—';
}
return `${value.toFixed(1)}%`;
}
export function formatCompactNumber(value: number) {
if (!Number.isFinite(value)) {
return '—';
}
return value.toLocaleString('zh-CN');
}
@@ -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,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="/openflare/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={`/openflare/nodes/detail?id=${node.id}`}>详情</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</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,122 @@
'use client';
import {Area, AreaChart, CartesianGrid, Line, XAxis, YAxis} from 'recharts';
import {Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
import {
ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
import type {TrafficTrendPoint} from '@/lib/services/openflare';
import {formatCompactNumber, formatTrendHour} from './dashboard-utils';
const chartConfig = {
requests: {
label: '请求量',
color: 'hsl(var(--chart-1))',
},
errors: {
label: '错误量',
color: 'hsl(var(--chart-5))',
},
} satisfies ChartConfig;
export function TrafficTrendChart({ points }: { points: TrafficTrendPoint[] }) {
const data = points.map((point) => ({
hour: formatTrendHour(point.bucket_started_at),
requests: point.request_count,
errors: point.error_count,
}));
if (data.length === 0) {
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">24 小时请求趋势</CardTitle>
<CardDescription className="text-xs">
观察整体请求量和错误量是否出现异常抬升。
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex h-[280px] items-center justify-center text-xs text-muted-foreground">
暂无趋势数据
</div>
</CardContent>
</Card>
);
}
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">24 小时请求趋势</CardTitle>
<CardDescription className="text-xs">
观察整体请求量和错误量是否出现异常抬升。
</CardDescription>
</CardHeader>
<CardContent className="pl-2 pr-4">
<div className="h-[280px] w-full">
<ChartContainer config={chartConfig} className="h-full w-full">
<AreaChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="trafficRequestsFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-requests)" stopOpacity={0.25} />
<stop offset="95%" stopColor="var(--color-requests)" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="hour"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={24}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => formatCompactNumber(Number(value))}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
formatter={(value, name) => (
<span className="font-mono tabular-nums">
{formatCompactNumber(Number(value))}
<span className="ml-1 text-muted-foreground">
{name === 'requests' ? '请求' : '错误'}
</span>
</span>
)}
/>
}
/>
<Area
type="monotone"
dataKey="requests"
stroke="var(--color-requests)"
fill="url(#trafficRequestsFill)"
strokeWidth={2}
/>
<Line
type="monotone"
dataKey="errors"
stroke="var(--color-errors)"
strokeWidth={2}
dot={false}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
</div>
</CardContent>
</Card>
);
}