mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-09 00:56:37 +08:00
merge: 合并 wavelet-top-domain 总览仪表盘图表改造
合并 ECharts 图表样式迁移与 Top Domain / 分布榜单板块, 保留节点详情页趋势图的可配置标题与描述参数。
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user