From 1857fe4c9832d81823ba59ca78d2d89a6f234d80 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 18 Jun 2026 18:54:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(wavelet):=20=E6=80=BB=E8=A7=88=E4=BB=AA?= =?UTF-8?q?=E8=A1=A8=E7=9B=98=E5=AF=B9=E9=BD=90=E6=97=A7=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=20ECharts=20=E5=9B=BE=E8=A1=A8=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将请求/容量趋势图迁移至 ECharts,并补充网络磁盘趋势、Top 节点榜单、 来源分布、状态码分布与 Top Domain 板块。 --- .../dashboard/capacity-trend-chart.tsx | 131 +++-------- .../components/dashboard/dashboard-utils.ts | 22 +- .../dashboard/distribution-rank-charts.tsx | 93 ++++++++ .../dashboard/network-disk-trend-chart.tsx | 73 +++++++ .../components/dashboard/node-rank-panel.tsx | 73 +++++++ .../dashboard/traffic-trend-chart.tsx | 133 +++--------- Wavelet/frontend/app/(main)/page.tsx | 27 ++- .../frontend/components/data/rank-chart.tsx | 131 +++++++++++ .../frontend/components/data/trend-chart.tsx | 204 ++++++++++++++++++ .../frontend/lib/services/openflare/index.ts | 3 + Wavelet/frontend/lib/utils/metrics.ts | 83 +++++++ Wavelet/frontend/package.json | 2 + Wavelet/frontend/pnpm-lock.yaml | 44 ++++ docs/changelog/index.md | 2 + 14 files changed, 791 insertions(+), 230 deletions(-) create mode 100644 Wavelet/frontend/app/(main)/components/dashboard/distribution-rank-charts.tsx create mode 100644 Wavelet/frontend/app/(main)/components/dashboard/network-disk-trend-chart.tsx create mode 100644 Wavelet/frontend/app/(main)/components/dashboard/node-rank-panel.tsx create mode 100644 Wavelet/frontend/components/data/rank-chart.tsx create mode 100644 Wavelet/frontend/components/data/trend-chart.tsx create mode 100644 Wavelet/frontend/lib/utils/metrics.ts 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 7d050780..b9d1ad90 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,54 +8,11 @@ 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; - -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 ( - - - 24 小时容量趋势 - - 按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。 - - - -
- 暂无趋势数据 -
-
-
- ); - } +import {formatPercent, formatTrendHour} from './dashboard-utils'; +export function CapacityTrendChart({points}: {points: CapacityTrendPoint[]}) { return ( @@ -65,64 +21,27 @@ export function CapacityTrendChart({ points }: { points: CapacityTrendPoint[] }) 按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。 - -
- - - - - - - - - - - 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 b5d33105..c3dcbc4f 100644 --- a/Wavelet/frontend/app/(main)/components/dashboard/traffic-trend-chart.tsx +++ b/Wavelet/frontend/app/(main)/components/dashboard/traffic-trend-chart.tsx @@ -1,56 +1,18 @@ '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; - -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 ( - - - 24 小时请求趋势 - - 观察整体请求量和错误量是否出现异常抬升。 - - - -
- 暂无趋势数据 -
-
-
- ); - } +import {formatTrendHour} from './dashboard-utils'; +export function TrafficTrendChart({points}: {points: TrafficTrendPoint[]}) { return ( @@ -59,64 +21,25 @@ export function TrafficTrendChart({ points }: { points: TrafficTrendPoint[] }) { 观察整体请求量和错误量是否出现异常抬升。 - -
- - - - - - - - - - - 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 ( +
+
+ +

+ {item.label} +

+
+

+ {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 a11b63d7..55e935b1 100644 --- a/Wavelet/frontend/lib/services/openflare/index.ts +++ b/Wavelet/frontend/lib/services/openflare/index.ts @@ -55,8 +55,11 @@ export type { DashboardTraffic, DashboardCapacity, DistributionItem, + TrafficDistributions, TrafficTrendPoint, CapacityTrendPoint, + NetworkTrendPoint, + DiskIOTrendPoint, WAFIPGroup, WAFIPGroupAutoTestPayload, WAFIPGroupAutoTestResult, 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 9536b723..e4618769 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 协议集成测试。