merge: 合并 wavelet-top-domain 总览仪表盘图表改造

合并 ECharts 图表样式迁移与 Top Domain / 分布榜单板块,
保留节点详情页趋势图的可配置标题与描述参数。
This commit is contained in:
ryan
2026-06-18 18:55:46 +08:00
14 changed files with 787 additions and 224 deletions
@@ -1,7 +1,6 @@
'use client';
import { Area, AreaChart, CartesianGrid, Line, XAxis, YAxis } from 'recharts';
import {TrendChart} from '@/components/data/trend-chart';
import {
Card,
CardContent,
@@ -9,28 +8,9 @@ import {
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 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;
import {formatPercent, formatTrendHour} from './dashboard-utils';
export function CapacityTrendChart({
points,
@@ -41,92 +21,33 @@ export function CapacityTrendChart({
title?: string;
description?: string;
}) {
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">{title}</CardTitle>
<CardDescription className="text-xs">{description}</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">{title}</CardTitle>
<CardDescription className="text-xs">{description}</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>
<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>
);
@@ -1,21 +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`;
}
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,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,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,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>
);
}
@@ -1,30 +1,16 @@
'use client';
import {Area, AreaChart, CartesianGrid, Line, XAxis, YAxis} from 'recharts';
import {Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
import {TrendChart} from '@/components/data/trend-chart';
import {
ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
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;
import {formatTrendHour} from './dashboard-utils';
export function TrafficTrendChart({
points,
@@ -35,92 +21,31 @@ export function TrafficTrendChart({
title?: string;
description?: string;
}) {
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">{title}</CardTitle>
<CardDescription className="text-xs">{description}</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">{title}</CardTitle>
<CardDescription className="text-xs">{description}</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>
<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>
);
}
}
+22 -5
View File
@@ -11,8 +11,14 @@ import {DashboardService} from '@/lib/services/openflare';
import {formatDateTime} from '@/lib/utils';
import {CapacityTrendChart} from './components/dashboard/capacity-trend-chart';
import {GeoDistributionList} from './components/dashboard/geo-distribution-list';
import {
SourceDistributionChart,
StatusCodeDistributionChart,
TopDomainChart,
} from './components/dashboard/distribution-rank-charts';
import {NetworkDiskTrendChart} from './components/dashboard/network-disk-trend-chart';
import {NodeHealthTable} from './components/dashboard/node-health-table';
import {NodeRankPanel} from './components/dashboard/node-rank-panel';
import {DashboardStatCards} from './components/dashboard/stat-cards';
import {TrafficTrendChart} from './components/dashboard/traffic-trend-chart';
import {getErrorMessage} from './nodes/components/node-utils';
@@ -82,12 +88,23 @@ export default function OpenFlareDashboardPage() {
<CapacityTrendChart points={overview.trends.capacity_24h} />
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<NodeHealthTable nodes={overview.nodes} />
<GeoDistributionList items={overview.distributions.source_countries} />
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<NetworkDiskTrendChart
networkPoints={overview.trends.network_24h}
diskPoints={overview.trends.disk_io_24h}
/>
<NodeRankPanel nodes={overview.nodes} />
</div>
<div className="grid gap-6 xl:grid-cols-3">
<SourceDistributionChart items={overview.distributions.source_countries} />
<StatusCodeDistributionChart items={overview.distributions.status_codes} />
<TopDomainChart items={overview.distributions.top_domains} />
</div>
<NodeHealthTable nodes={overview.nodes} />
</>
)}
</div>
);
}
}