mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
feat(wavelet): 总览仪表盘对齐旧前端 ECharts 图表样式
将请求/容量趋势图迁移至 ECharts,并补充网络磁盘趋势、Top 节点榜单、 来源分布、状态码分布与 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,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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Generated
+44
@@ -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: {}
|
||||
|
||||
@@ -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 协议集成测试。
|
||||
|
||||
Reference in New Issue
Block a user