feat(wavelet): 总览仪表盘对齐旧前端 ECharts 图表样式

将请求/容量趋势图迁移至 ECharts,并补充网络磁盘趋势、Top 节点榜单、
来源分布、状态码分布与 Top Domain 板块。
This commit is contained in:
ryan
2026-06-18 18:54:53 +08:00
parent 48421c12de
commit 1857fe4c98
14 changed files with 791 additions and 230 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,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 (
<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>
);
}
import {formatPercent, formatTrendHour} from './dashboard-utils';
export function CapacityTrendChart({points}: {points: CapacityTrendPoint[]}) {
return (
<Card className="border-dashed shadow-none">
<CardHeader>
@@ -65,64 +21,27 @@ export function CapacityTrendChart({ points }: { points: CapacityTrendPoint[] })
按小时聚合 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>
<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,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 (
<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>
);
}
import {formatTrendHour} from './dashboard-utils';
export function TrafficTrendChart({points}: {points: TrafficTrendPoint[]}) {
return (
<Card className="border-dashed shadow-none">
<CardHeader>
@@ -59,64 +21,25 @@ export function TrafficTrendChart({ points }: { points: TrafficTrendPoint[] }) {
观察整体请求量和错误量是否出现异常抬升。
</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>
);
}
}
@@ -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<EChartsOption>(
() => ({
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 ?? ''}<br/>${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 (
<div className="flex h-[220px] items-center justify-center rounded-3xl border border-dashed bg-muted/30 text-sm text-muted-foreground">
{emptyMessage}
</div>
);
}
return (
<ReactECharts
option={option}
notMerge
lazyUpdate
style={{height: Math.max(220, items.length * 44), width: '100%'}}
/>
);
}
@@ -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<number | string>;
};
const defaultFormatter = (value: number) => formatCompactNumber(value);
export function TrendChart({
labels,
series,
height = 220,
yAxisValueFormatter,
}: TrendChartProps) {
const option = useMemo<EChartsOption>(() => {
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 [
'<span style="display:inline-flex;align-items:center;gap:8px;">',
`<span style="display:inline-block;width:8px;height:8px;border-radius:9999px;background:${item.color ?? '#94a3b8'};"></span>`,
`<span>${item.seriesName ?? ''}</span>`,
`<strong style="margin-left:8px;">${formatter(numericValue)}</strong>`,
'</span>',
].join('');
});
return [header, ...rows].join('<br/>');
},
},
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 (
<div className="flex h-[220px] items-center justify-center rounded-3xl border border-dashed bg-muted/30 text-sm text-muted-foreground">
暂无趋势数据
</div>
);
}
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-3">
{series.map((item) => {
const latestValue = item.values[item.values.length - 1] ?? 0;
const formatter = item.valueFormatter ?? defaultFormatter;
return (
<div
key={item.label}
className="min-w-[140px] rounded-2xl border bg-card px-4 py-3"
>
<div className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 rounded-full"
style={{backgroundColor: item.color}}
/>
<p className="text-xs tracking-[0.18em] text-muted-foreground uppercase">
{item.label}
</p>
</div>
<p className="mt-2 text-lg font-semibold">
{formatter(latestValue)}
</p>
</div>
);
})}
</div>
<div className="overflow-hidden rounded-[28px] border bg-linear-to-b from-white/3 to-transparent px-4 py-4 dark:from-white/3">
<ReactECharts
option={option}
notMerge
lazyUpdate
style={{height, width: '100%'}}
/>
</div>
</div>
);
}
@@ -55,8 +55,11 @@ export type {
DashboardTraffic,
DashboardCapacity,
DistributionItem,
TrafficDistributions,
TrafficTrendPoint,
CapacityTrendPoint,
NetworkTrendPoint,
DiskIOTrendPoint,
WAFIPGroup,
WAFIPGroupAutoTestPayload,
WAFIPGroupAutoTestResult,
+83
View File
@@ -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;
}
+2
View File
@@ -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",
+44
View File
@@ -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: {}
+2
View File
@@ -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 协议集成测试。