diff --git a/Wavelet/frontend/app/(main)/components/dashboard/capacity-trend-chart.tsx b/Wavelet/frontend/app/(main)/components/dashboard/capacity-trend-chart.tsx
index c6897f78..fffac7c8 100644
--- a/Wavelet/frontend/app/(main)/components/dashboard/capacity-trend-chart.tsx
+++ b/Wavelet/frontend/app/(main)/components/dashboard/capacity-trend-chart.tsx
@@ -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 (
-
-
- {title}
- {description}
-
-
-
- 暂无趋势数据
-
-
-
- );
- }
-
return (
{title}
{description}
-
-
-
-
-
-
-
-
-
-
-
-
- formatPercent(Number(value))}
- />
- (
-
- {formatPercent(Number(value))}
-
- {name === 'cpu' ? 'CPU' : '内存'}
-
-
- )}
- />
- }
- />
-
-
- } />
-
-
-
+
+ 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,
+ },
+ ]}
+ />
);
diff --git a/Wavelet/frontend/app/(main)/components/dashboard/dashboard-utils.ts b/Wavelet/frontend/app/(main)/components/dashboard/dashboard-utils.ts
index 6ce1ba94..9ae71e0d 100644
--- a/Wavelet/frontend/app/(main)/components/dashboard/dashboard-utils.ts
+++ b/Wavelet/frontend/app/(main)/components/dashboard/dashboard-utils.ts
@@ -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');
}
\ No newline at end of file
diff --git a/Wavelet/frontend/app/(main)/components/dashboard/distribution-rank-charts.tsx b/Wavelet/frontend/app/(main)/components/dashboard/distribution-rank-charts.tsx
new file mode 100644
index 00000000..5311c62b
--- /dev/null
+++ b/Wavelet/frontend/app/(main)/components/dashboard/distribution-rank-charts.tsx
@@ -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 (
+
+
+ 来源分布
+
+ 聚合最近 24 小时主要来源国家。
+
+
+
+
+
+
+ );
+}
+
+export function StatusCodeDistributionChart({
+ items,
+}: {
+ items: TrafficDistributions['status_codes'];
+}) {
+ return (
+
+
+ 状态码分布
+
+ 快速判断成功响应是否仍是主流,以及错误码是否有抬升。
+
+
+
+ ({
+ ...item,
+ label: `HTTP ${item.label}`,
+ }))}
+ color="#f59e0b"
+ emptyMessage="暂无状态码分布"
+ />
+
+
+ );
+}
+
+export function TopDomainChart({
+ items,
+}: {
+ items: TrafficDistributions['top_domains'];
+}) {
+ return (
+
+
+ Top Domain
+
+ 观察主要流量集中在哪些域名。
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/Wavelet/frontend/app/(main)/components/dashboard/network-disk-trend-chart.tsx b/Wavelet/frontend/app/(main)/components/dashboard/network-disk-trend-chart.tsx
new file mode 100644
index 00000000..6a6e2f9b
--- /dev/null
+++ b/Wavelet/frontend/app/(main)/components/dashboard/network-disk-trend-chart.tsx
@@ -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 (
+
+
+ 24 小时网络与磁盘趋势
+
+
+ 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),
+ },
+ ]}
+ />
+
+ 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,
+ },
+ ]}
+ />
+
+
+ );
+}
\ No newline at end of file
diff --git a/Wavelet/frontend/app/(main)/components/dashboard/node-rank-panel.tsx b/Wavelet/frontend/app/(main)/components/dashboard/node-rank-panel.tsx
new file mode 100644
index 00000000..36ca60a8
--- /dev/null
+++ b/Wavelet/frontend/app/(main)/components/dashboard/node-rank-panel.tsx
@@ -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 (
+
+
+ Top 节点榜单
+
+
+
+
+ 流量最高节点
+
+
node.request_count)}
+ color="#38bdf8"
+ emptyMessage="暂无流量榜单"
+ />
+
+
+
+ 容量压力节点
+
+
+ Math.round(
+ Math.max(
+ node.cpu_usage_percent,
+ node.memory_usage_percent,
+ node.storage_usage_percent,
+ ),
+ ),
+ )}
+ color="#ef4444"
+ valueFormatter={(value) => `${value}%`}
+ emptyMessage="暂无容量压力数据"
+ />
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/Wavelet/frontend/app/(main)/components/dashboard/traffic-trend-chart.tsx b/Wavelet/frontend/app/(main)/components/dashboard/traffic-trend-chart.tsx
index 4b3795d2..19182080 100644
--- a/Wavelet/frontend/app/(main)/components/dashboard/traffic-trend-chart.tsx
+++ b/Wavelet/frontend/app/(main)/components/dashboard/traffic-trend-chart.tsx
@@ -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 (
-
-
- {title}
- {description}
-
-
-
- 暂无趋势数据
-
-
-
- );
- }
-
return (
{title}
{description}
-
-
-
-
-
-
-
-
-
-
-
-
- formatCompactNumber(Number(value))}
- />
- (
-
- {formatCompactNumber(Number(value))}
-
- {name === 'requests' ? '请求' : '错误'}
-
-
- )}
- />
- }
- />
-
-
- } />
-
-
-
+
+ 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),
+ },
+ ]}
+ />
);
-}
+}
\ No newline at end of file
diff --git a/Wavelet/frontend/app/(main)/page.tsx b/Wavelet/frontend/app/(main)/page.tsx
index af0067e7..abeda9f5 100644
--- a/Wavelet/frontend/app/(main)/page.tsx
+++ b/Wavelet/frontend/app/(main)/page.tsx
@@ -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() {
-
-
-
+
+
+
+
+
+
+
+
+
+
+
>
)}
);
-}
+}
\ No newline at end of file
diff --git a/Wavelet/frontend/components/data/rank-chart.tsx b/Wavelet/frontend/components/data/rank-chart.tsx
new file mode 100644
index 00000000..7b26c93b
--- /dev/null
+++ b/Wavelet/frontend/components/data/rank-chart.tsx
@@ -0,0 +1,131 @@
+'use client';
+
+import {useMemo} from 'react';
+import type {EChartsOption} from 'echarts';
+import ReactECharts from 'echarts-for-react';
+
+type RankChartItem = {
+ label: string;
+ value: number;
+};
+
+type RankChartProps = {
+ items: RankChartItem[];
+ color: string;
+ valueFormatter?: (value: number) => string;
+ emptyMessage?: string;
+};
+
+const defaultFormatter = (value: number) => value.toLocaleString('zh-CN');
+
+function getChartValue(params: unknown) {
+ if (typeof params !== 'object' || params === null || !('value' in params)) {
+ return 0;
+ }
+ const rawValue = (params as {value?: unknown}).value;
+ if (Array.isArray(rawValue)) {
+ const candidate = rawValue[0];
+ return typeof candidate === 'number' ? candidate : 0;
+ }
+ return typeof rawValue === 'number' ? rawValue : 0;
+}
+
+export function RankChart({
+ items,
+ color,
+ valueFormatter = defaultFormatter,
+ emptyMessage = '暂无分布数据',
+}: RankChartProps) {
+ const option = useMemo(
+ () => ({
+ animationDuration: 400,
+ grid: {
+ left: 16,
+ right: 24,
+ top: 12,
+ bottom: 12,
+ containLabel: true,
+ },
+ tooltip: {
+ trigger: 'axis',
+ axisPointer: {
+ type: 'shadow',
+ },
+ backgroundColor: 'rgba(15, 23, 42, 0.92)',
+ borderWidth: 0,
+ textStyle: {
+ color: '#e2e8f0',
+ fontSize: 12,
+ },
+ formatter: (params: unknown) => {
+ const item = Array.isArray(params) ? params[0] : params;
+ const data = item as {name?: string; value?: number};
+ return `${data.name ?? ''}
${valueFormatter(data.value ?? 0)}`;
+ },
+ },
+ xAxis: {
+ type: 'value',
+ axisLabel: {
+ color: '#94a3b8',
+ },
+ splitLine: {
+ lineStyle: {
+ color: 'rgba(148, 163, 184, 0.16)',
+ type: 'dashed',
+ },
+ },
+ },
+ yAxis: {
+ type: 'category',
+ data: items.map((item) => item.label),
+ axisTick: {show: false},
+ axisLine: {show: false},
+ axisLabel: {
+ color: '#cbd5e1',
+ width: 120,
+ overflow: 'truncate',
+ },
+ },
+ series: [
+ {
+ type: 'bar',
+ data: items.map((item) => item.value),
+ barWidth: 12,
+ showBackground: true,
+ backgroundStyle: {
+ color: 'rgba(148, 163, 184, 0.12)',
+ borderRadius: 999,
+ },
+ itemStyle: {
+ color,
+ borderRadius: 999,
+ },
+ label: {
+ show: true,
+ position: 'right',
+ color: '#e2e8f0',
+ formatter: (params: unknown) => valueFormatter(getChartValue(params)),
+ },
+ },
+ ],
+ }),
+ [color, items, valueFormatter],
+ );
+
+ if (items.length === 0) {
+ return (
+
+ {emptyMessage}
+
+ );
+ }
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/Wavelet/frontend/components/data/trend-chart.tsx b/Wavelet/frontend/components/data/trend-chart.tsx
new file mode 100644
index 00000000..3da5938e
--- /dev/null
+++ b/Wavelet/frontend/components/data/trend-chart.tsx
@@ -0,0 +1,204 @@
+'use client';
+
+import {useMemo} from 'react';
+import type {EChartsOption} from 'echarts';
+import ReactECharts from 'echarts-for-react';
+
+import {calculateNiceAxisMax, formatCompactNumber} from '@/lib/utils/metrics';
+
+type TrendChartSeries = {
+ label: string;
+ color: string;
+ fillColor?: string;
+ values: number[];
+ variant?: 'line' | 'area';
+ valueFormatter?: (value: number) => string;
+};
+
+type TrendChartProps = {
+ labels: string[];
+ series: TrendChartSeries[];
+ height?: number;
+ yAxisValueFormatter?: (value: number) => string;
+};
+
+type TooltipParam = {
+ axisValueLabel?: string;
+ color?: string;
+ seriesName?: string;
+ value?: number | string | Array;
+};
+
+const defaultFormatter = (value: number) => formatCompactNumber(value);
+
+export function TrendChart({
+ labels,
+ series,
+ height = 220,
+ yAxisValueFormatter,
+}: TrendChartProps) {
+ const option = useMemo(() => {
+ const axisFormatter = yAxisValueFormatter ?? defaultFormatter;
+ const maxValue = calculateNiceAxisMax(series.flatMap((item) => item.values));
+
+ return {
+ animationDuration: 500,
+ animationEasing: 'cubicOut',
+ grid: {
+ left: 16,
+ right: 16,
+ top: 20,
+ bottom: 20,
+ containLabel: true,
+ },
+ tooltip: {
+ trigger: 'axis',
+ backgroundColor: 'rgba(15, 23, 42, 0.92)',
+ borderWidth: 0,
+ textStyle: {
+ color: '#e2e8f0',
+ fontSize: 12,
+ },
+ formatter: (params: unknown) => {
+ const items = Array.isArray(params) ? (params as TooltipParam[]) : [];
+ if (items.length === 0) {
+ return '';
+ }
+
+ const header = items[0]?.axisValueLabel ?? '';
+ const rows = items.map((item) => {
+ const matchedSeries = series.find(
+ (seriesItem) => seriesItem.label === item.seriesName,
+ );
+ const formatter =
+ matchedSeries?.valueFormatter ??
+ yAxisValueFormatter ??
+ defaultFormatter;
+ const rawValue = Array.isArray(item.value) ? item.value[1] : item.value;
+ const numericValue =
+ typeof rawValue === 'number' ? rawValue : Number(rawValue ?? 0);
+
+ return [
+ '',
+ ``,
+ `${item.seriesName ?? ''}`,
+ `${formatter(numericValue)}`,
+ '',
+ ].join('');
+ });
+
+ return [header, ...rows].join('
');
+ },
+ },
+ legend: {
+ show: false,
+ },
+ xAxis: {
+ type: 'category',
+ boundaryGap: false,
+ data: labels,
+ axisLine: {
+ lineStyle: {
+ color: 'rgba(148, 163, 184, 0.24)',
+ },
+ },
+ axisTick: {
+ show: false,
+ },
+ axisLabel: {
+ color: '#94a3b8',
+ margin: 14,
+ },
+ },
+ yAxis: {
+ type: 'value',
+ min: 0,
+ max: maxValue,
+ splitNumber: 4,
+ axisLabel: {
+ color: '#94a3b8',
+ formatter: (value: number) => axisFormatter(value),
+ },
+ splitLine: {
+ lineStyle: {
+ color: 'rgba(148, 163, 184, 0.16)',
+ type: 'dashed',
+ },
+ },
+ },
+ series: series.map((item) => ({
+ name: item.label,
+ type: 'line',
+ smooth: true,
+ showSymbol: false,
+ symbol: 'circle',
+ symbolSize: 8,
+ lineStyle: {
+ color: item.color,
+ width: 3,
+ },
+ itemStyle: {
+ color: item.color,
+ },
+ areaStyle:
+ item.variant === 'area'
+ ? {
+ color: item.fillColor ?? `${item.color}33`,
+ }
+ : undefined,
+ emphasis: {
+ focus: 'series',
+ scale: true,
+ },
+ data: item.values,
+ })),
+ };
+ }, [labels, series, yAxisValueFormatter]);
+
+ if (labels.length === 0 || series.length === 0) {
+ return (
+
+ 暂无趋势数据
+
+ );
+ }
+
+ return (
+
+
+ {series.map((item) => {
+ const latestValue = item.values[item.values.length - 1] ?? 0;
+ const formatter = item.valueFormatter ?? defaultFormatter;
+ return (
+
+
+
+ {formatter(latestValue)}
+
+
+ );
+ })}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/Wavelet/frontend/lib/services/openflare/index.ts b/Wavelet/frontend/lib/services/openflare/index.ts
index 11688b08..f54f2f86 100644
--- a/Wavelet/frontend/lib/services/openflare/index.ts
+++ b/Wavelet/frontend/lib/services/openflare/index.ts
@@ -60,6 +60,7 @@ export type {
DashboardTraffic,
DashboardCapacity,
DistributionItem,
+ TrafficDistributions,
TrafficTrendPoint,
CapacityTrendPoint,
NetworkTrendPoint,
diff --git a/Wavelet/frontend/lib/utils/metrics.ts b/Wavelet/frontend/lib/utils/metrics.ts
new file mode 100644
index 00000000..2844161c
--- /dev/null
+++ b/Wavelet/frontend/lib/utils/metrics.ts
@@ -0,0 +1,83 @@
+const compactNumberFormatter = new Intl.NumberFormat('zh-CN', {
+ maximumFractionDigits: 1,
+});
+
+type FormatBytesOptions = {
+ emptyText?: string;
+ zeroText?: string;
+};
+
+export function formatCompactNumber(value?: number | null) {
+ if (value === undefined || value === null || Number.isNaN(value)) {
+ return '—';
+ }
+ return compactNumberFormatter.format(value);
+}
+
+export function formatPercent(value?: number | null) {
+ if (value === undefined || value === null || Number.isNaN(value)) {
+ return '—';
+ }
+ return `${formatCompactNumber(value)}%`;
+}
+
+export function formatBytes(
+ value?: number | null,
+ options: FormatBytesOptions = {},
+) {
+ const {emptyText = '—', zeroText = '0 B'} = options;
+
+ if (value === undefined || value === null || Number.isNaN(value)) {
+ return emptyText;
+ }
+ if (value <= 0) {
+ return zeroText;
+ }
+
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ let current = value;
+ let index = 0;
+ while (current >= 1024 && index < units.length - 1) {
+ current /= 1024;
+ index += 1;
+ }
+
+ const digits = current >= 100 || index === 0 ? 0 : 1;
+ return `${new Intl.NumberFormat('zh-CN', {
+ maximumFractionDigits: digits,
+ minimumFractionDigits: 0,
+ }).format(current)} ${units[index]}`;
+}
+
+export function formatBytesPerSecond(
+ value?: number | null,
+ windowSeconds = 1,
+ options?: FormatBytesOptions,
+) {
+ if (value === undefined || value === null || Number.isNaN(value)) {
+ return options?.emptyText ?? '—';
+ }
+ if (windowSeconds <= 0) {
+ return options?.emptyText ?? '—';
+ }
+ return `${formatBytes(value / windowSeconds, options)}/s`;
+}
+
+export function calculateNiceAxisMax(values: number[]) {
+ const rawMax = Math.max(
+ 0,
+ ...values.map((value) => (Number.isFinite(value) && value > 0 ? value : 0)),
+ );
+
+ if (rawMax <= 0) {
+ return 1;
+ }
+
+ const paddedMax = rawMax * 1.1;
+ const magnitude = 10 ** Math.floor(Math.log10(paddedMax));
+ const normalized = paddedMax / magnitude;
+ const steps = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10];
+ const niceStep = steps.find((step) => normalized <= step) ?? 10;
+
+ return niceStep * magnitude;
+}
\ No newline at end of file
diff --git a/Wavelet/frontend/package.json b/Wavelet/frontend/package.json
index da6b3d10..0abcbc51 100644
--- a/Wavelet/frontend/package.json
+++ b/Wavelet/frontend/package.json
@@ -50,6 +50,8 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
+ "echarts": "^6.1.0",
+ "echarts-for-react": "^3.0.6",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"jspdf": "^3.0.4",
diff --git a/Wavelet/frontend/pnpm-lock.yaml b/Wavelet/frontend/pnpm-lock.yaml
index 124505ce..1b5ca5df 100644
--- a/Wavelet/frontend/pnpm-lock.yaml
+++ b/Wavelet/frontend/pnpm-lock.yaml
@@ -122,6 +122,12 @@ importers:
date-fns:
specifier: ^4.1.0
version: 4.1.0
+ echarts:
+ specifier: ^6.1.0
+ version: 6.1.0
+ echarts-for-react:
+ specifier: ^3.0.6
+ version: 3.0.6(echarts@6.1.0)(react@19.2.3)
embla-carousel-react:
specifier: ^8.6.0
version: 8.6.0(react@19.2.3)
@@ -2201,6 +2207,15 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
+ echarts-for-react@3.0.6:
+ resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==}
+ peerDependencies:
+ echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
+ react: ^15.0.0 || >=16.0.0
+
+ echarts@6.1.0:
+ resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
+
embla-carousel-react@8.6.0:
resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==}
peerDependencies:
@@ -3524,6 +3539,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
+ size-sensor@1.0.3:
+ resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==}
+
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
@@ -3655,6 +3673,9 @@ packages:
tsconfig-paths@3.15.0:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
+ tslib@2.3.0:
+ resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
+
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -3801,6 +3822,9 @@ packages:
zod@4.1.12:
resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==}
+ zrender@6.1.0:
+ resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
+
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -5758,6 +5782,18 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
+ echarts-for-react@3.0.6(echarts@6.1.0)(react@19.2.3):
+ dependencies:
+ echarts: 6.1.0
+ fast-deep-equal: 3.1.3
+ react: 19.2.3
+ size-sensor: 1.0.3
+
+ echarts@6.1.0:
+ dependencies:
+ tslib: 2.3.0
+ zrender: 6.1.0
+
embla-carousel-react@8.6.0(react@19.2.3):
dependencies:
embla-carousel: 8.6.0
@@ -7599,6 +7635,8 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
+ size-sensor@1.0.3: {}
+
sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
react: 19.2.3
@@ -7738,6 +7776,8 @@ snapshots:
minimist: 1.2.8
strip-bom: 3.0.0
+ tslib@2.3.0: {}
+
tslib@2.8.1: {}
tw-animate-css@1.4.0: {}
@@ -7971,4 +8011,8 @@ snapshots:
zod@4.1.12: {}
+ zrender@6.1.0:
+ dependencies:
+ tslib: 2.3.0
+
zwitch@2.0.4: {}
diff --git a/docs/changelog/index.md b/docs/changelog/index.md
index be84d509..07196bf5 100644
--- a/docs/changelog/index.md
+++ b/docs/changelog/index.md
@@ -18,6 +18,8 @@ sidebar: false
### 新增
+- Wavelet 总览仪表盘图表对齐旧前端 ECharts 样式:24 小时请求/容量趋势补充请求量与错误量展示,新增网络与磁盘趋势、Top 节点榜单、来源分布、状态码分布与 Top Domain 板块。
+
- 将 `openflare-server` 后端业务域迁移至 `Wavelet/internal/apps/openflare/`,通过 `/api/*` legacy 兼容层保持旧前端 API 路径不变。
- 新增 OpenFlare 业务表 goose 迁移(`of_options`、`of_origins`、`of_proxy_routes`、`of_nodes`、`of_waf_*`、`of_tls_*`、`of_config_versions`、`of_pages_*`、`of_apply_logs` 等)。
- 重叠职能复用 Wavelet 内置用户/OAuth/Cap/认证源能力;新增 `integration` 包覆盖认证、核心链路、安全、Agent 协议集成测试。