[优化] 添加度量工具函数和测试用例,优化仪表盘和趋势图组件

This commit is contained in:
ryan
2026-03-15 14:11:16 +08:00
parent 072930d55b
commit 25004fefae
5 changed files with 1178 additions and 1044 deletions
@@ -4,6 +4,8 @@ import { useMemo } from 'react';
import type { EChartsOption } from 'echarts'; import type { EChartsOption } from 'echarts';
import ReactECharts from 'echarts-for-react'; import ReactECharts from 'echarts-for-react';
import { calculateNiceAxisMax, formatCompactNumber } from '@/lib/utils/metrics';
type TrendChartSeries = { type TrendChartSeries = {
label: string; label: string;
color: string; color: string;
@@ -17,17 +19,29 @@ type TrendChartProps = {
labels: string[]; labels: string[];
series: TrendChartSeries[]; series: TrendChartSeries[];
height?: number; height?: number;
yAxisValueFormatter?: (value: number) => string;
}; };
const defaultFormatter = (value: number) => value.toLocaleString('zh-CN'); type TooltipParam = {
axisValueLabel?: string;
color?: string;
seriesName?: string;
value?: number | string | Array<number | string>;
};
export function TrendChart({ labels, series, height = 220 }: TrendChartProps) { const defaultFormatter = (value: number) => formatCompactNumber(value);
export function TrendChart({
labels,
series,
height = 220,
yAxisValueFormatter,
}: TrendChartProps) {
const option = useMemo<EChartsOption>(() => { const option = useMemo<EChartsOption>(() => {
const maxValue = const axisFormatter = yAxisValueFormatter ?? defaultFormatter;
Math.max( const maxValue = calculateNiceAxisMax(
1, series.flatMap((item) => item.values),
...series.flatMap((item) => item.values.map((value) => value || 0)), );
) * 1.1;
return { return {
animationDuration: 500, animationDuration: 500,
@@ -47,6 +61,38 @@ export function TrendChart({ labels, series, height = 220 }: TrendChartProps) {
color: '#e2e8f0', color: '#e2e8f0',
fontSize: 12, 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: { legend: {
show: false, show: false,
@@ -75,6 +121,7 @@ export function TrendChart({ labels, series, height = 220 }: TrendChartProps) {
splitNumber: 4, splitNumber: 4,
axisLabel: { axisLabel: {
color: '#94a3b8', color: '#94a3b8',
formatter: (value: number) => axisFormatter(value),
}, },
splitLine: { splitLine: {
lineStyle: { lineStyle: {
@@ -110,7 +157,7 @@ export function TrendChart({ labels, series, height = 220 }: TrendChartProps) {
data: item.values, data: item.values,
})), })),
}; };
}, [labels, series]); }, [labels, series, yAxisValueFormatter]);
if (labels.length === 0 || series.length === 0) { if (labels.length === 0 || series.length === 0) {
return ( return (
@@ -20,35 +20,11 @@ import {
getOpenrestyStatusVariant, getOpenrestyStatusVariant,
} from '@/features/nodes/utils'; } from '@/features/nodes/utils';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
import {
function formatPercent(value?: number | null) { formatBytes,
if (value === undefined || value === null || Number.isNaN(value)) { formatBytesPerSecond,
return '—'; formatPercent,
} } from '@/lib/utils/metrics';
return `${value.toFixed(1)}%`;
}
function formatBytes(value?: number | null) {
if (!value || value <= 0) {
return '—';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let current = value;
let index = 0;
while (current >= 1024 && index < units.length - 1) {
current /= 1024;
index += 1;
}
return `${current.toFixed(current >= 100 || index === 0 ? 0 : 1)} ${units[index]}`;
}
function formatBytesPerSecond(value?: number | null, windowSeconds = 1) {
if (!value || value <= 0 || windowSeconds <= 0) {
return '—';
}
return `${formatBytes(value / windowSeconds)}/s`;
}
function getErrorMessage(error: unknown) { function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。'; return error instanceof Error ? error.message : '请求失败,请稍后重试。';
@@ -244,6 +220,7 @@ export function DashboardOverview() {
labels={overview.trends.capacity_24h.map((point) => labels={overview.trends.capacity_24h.map((point) =>
formatTrendHour(point.bucket_started_at), formatTrendHour(point.bucket_started_at),
)} )}
yAxisValueFormatter={formatPercent}
series={[ series={[
{ {
label: '平均 CPU', label: '平均 CPU',
@@ -279,6 +256,7 @@ export function DashboardOverview() {
formatTrendHour(point.bucket_started_at), formatTrendHour(point.bucket_started_at),
)} )}
height={180} height={180}
yAxisValueFormatter={(value) => formatBytesPerSecond(value, 3600)}
series={[ series={[
{ {
label: 'OpenResty 入站', label: 'OpenResty 入站',
@@ -306,6 +284,7 @@ export function DashboardOverview() {
formatTrendHour(point.bucket_started_at), formatTrendHour(point.bucket_started_at),
)} )}
height={180} height={180}
yAxisValueFormatter={formatBytes}
series={[ series={[
{ {
label: '磁盘读', label: '磁盘读',
@@ -366,9 +345,7 @@ export function DashboardOverview() {
容量压力节点 容量压力节点
</p> </p>
<RankChart <RankChart
items={buildNodeRankItems( items={buildNodeRankItems(overview.nodes, (node) =>
overview.nodes,
(node) =>
Math.round( Math.round(
Math.max( Math.max(
node.cpu_usage_percent, node.cpu_usage_percent,
@@ -43,6 +43,11 @@ import {
} from '@/features/shared/components/resource-primitives'; } from '@/features/shared/components/resource-primitives';
import type { ReleaseChannel } from '@/features/update/types'; import type { ReleaseChannel } from '@/features/update/types';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
import {
formatBytes,
formatBytesPerSecond,
formatPercent,
} from '@/lib/utils/metrics';
import { import {
buildNodeInstallCommand, buildNodeInstallCommand,
getApplyLabel, getApplyLabel,
@@ -74,35 +79,6 @@ async function copyToClipboard(value: string) {
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
} }
function formatBytes(value?: number | null) {
if (!value || value <= 0) {
return '—';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let current = value;
let index = 0;
while (current >= 1024 && index < units.length - 1) {
current /= 1024;
index += 1;
}
return `${current.toFixed(current >= 100 || index === 0 ? 0 : 1)} ${units[index]}`;
}
function formatBytesPerSecond(value?: number | null, windowSeconds = 1) {
if (!value || value <= 0 || windowSeconds <= 0) {
return '—';
}
return `${formatBytes(value / windowSeconds)}/s`;
}
function formatPercent(value?: number | null) {
if (value === undefined || value === null || Number.isNaN(value)) {
return '—';
}
return `${value.toFixed(1)}%`;
}
function formatUsageRatio(used?: number | null, total?: number | null) { function formatUsageRatio(used?: number | null, total?: number | null) {
if (!used || !total || total <= 0) { if (!used || !total || total <= 0) {
return null; return null;
@@ -445,8 +421,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
const latestMetricSnapshot = observability?.metric_snapshots?.[0] ?? null; const latestMetricSnapshot = observability?.metric_snapshots?.[0] ?? null;
const activeHealthEvents = useMemo( const activeHealthEvents = useMemo(
() => () =>
observability?.health_events.filter((event) => event.status === 'active') ?? observability?.health_events.filter(
[], (event) => event.status === 'active',
) ?? [],
[observability?.health_events], [observability?.health_events],
); );
const statusCodeDistribution = useMemo( const statusCodeDistribution = useMemo(
@@ -734,9 +711,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
</div> </div>
<div className="grid gap-6 xl:grid-cols-3"> <div className="grid gap-6 xl:grid-cols-3">
<AppCard <AppCard title="系统信息">
title="系统信息"
>
{observabilityQuery.isLoading ? ( {observabilityQuery.isLoading ? (
<LoadingState /> <LoadingState />
) : observabilityQuery.isError ? ( ) : observabilityQuery.isError ? (
@@ -802,7 +777,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
总内存 总内存
</p> </p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]"> <p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatBytes(observability.profile.total_memory_bytes)} {formatBytes(
observability.profile.total_memory_bytes,
)}
</p> </p>
</div> </div>
<div> <div>
@@ -915,7 +892,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
/> />
<StatusBadge <StatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)} label={getOpenrestyStatusLabel(node.openresty_status)}
variant={getOpenrestyStatusVariant(node.openresty_status)} variant={getOpenrestyStatusVariant(
node.openresty_status,
)}
/> />
<StatusBadge <StatusBadge
label={ label={
@@ -923,7 +902,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
? `${activeHealthEvents.length} 个活动异常` ? `${activeHealthEvents.length} 个活动异常`
: '无活动异常' : '无活动异常'
} }
variant={activeHealthEvents.length ? 'warning' : 'success'} variant={
activeHealthEvents.length ? 'warning' : 'success'
}
/> />
</div> </div>
@@ -973,7 +954,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
</p> </p>
<p className="mt-3 text-2xl font-semibold text-[var(--foreground-primary)]"> <p className="mt-3 text-2xl font-semibold text-[var(--foreground-primary)]">
{trafficSummary {trafficSummary
? trafficSummary.request_count.toLocaleString('zh-CN') ? trafficSummary.request_count.toLocaleString(
'zh-CN',
)
: '—'} : '—'}
</p> </p>
<p className="mt-2 text-sm text-[var(--foreground-secondary)]"> <p className="mt-2 text-sm text-[var(--foreground-secondary)]">
@@ -1052,6 +1035,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
formatTrendHour(point.bucket_started_at), formatTrendHour(point.bucket_started_at),
) ?? [] ) ?? []
} }
yAxisValueFormatter={formatPercent}
series={[ series={[
{ {
label: '平均 CPU', label: '平均 CPU',
@@ -1089,6 +1073,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
formatTrendHour(point.bucket_started_at), formatTrendHour(point.bucket_started_at),
) ?? [] ) ?? []
} }
yAxisValueFormatter={(value) =>
formatBytesPerSecond(value, 3600)
}
series={[ series={[
{ {
label: 'OpenResty 入站', label: 'OpenResty 入站',
@@ -1099,7 +1086,8 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
observability?.trends.network_24h.map( observability?.trends.network_24h.map(
(point) => point.openresty_rx_bytes, (point) => point.openresty_rx_bytes,
) ?? [], ) ?? [],
valueFormatter: (value) => formatBytesPerSecond(value, 3600), valueFormatter: (value) =>
formatBytesPerSecond(value, 3600),
}, },
{ {
label: 'OpenResty 出站', label: 'OpenResty 出站',
@@ -1108,7 +1096,8 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
observability?.trends.network_24h.map( observability?.trends.network_24h.map(
(point) => point.openresty_tx_bytes, (point) => point.openresty_tx_bytes,
) ?? [], ) ?? [],
valueFormatter: (value) => formatBytesPerSecond(value, 3600), valueFormatter: (value) =>
formatBytesPerSecond(value, 3600),
}, },
]} ]}
/> />
@@ -1124,6 +1113,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
formatTrendHour(point.bucket_started_at), formatTrendHour(point.bucket_started_at),
) ?? [] ) ?? []
} }
yAxisValueFormatter={formatBytes}
series={[ series={[
{ {
label: '磁盘读', label: '磁盘读',
@@ -1272,7 +1262,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
variant={getHealthEventVariant(event)} variant={getHealthEventVariant(event)}
/> />
<StatusBadge <StatusBadge
label={event.status === 'active' ? '活动中' : '已恢复'} label={
event.status === 'active' ? '活动中' : '已恢复'
}
variant={ variant={
event.status === 'active' ? 'warning' : 'success' event.status === 'active' ? 'warning' : 'success'
} }
@@ -1650,7 +1642,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
className="px-3 py-4 text-[var(--foreground-secondary)]" className="px-3 py-4 text-[var(--foreground-secondary)]"
title={log.checksum} title={log.checksum}
> >
{log.checksum ? `${log.checksum.slice(0, 12)}...` : '—'} {log.checksum
? `${log.checksum.slice(0, 12)}...`
: '—'}
</td> </td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]"> <td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatRelativeTime(log.created_at)} ·{' '} {formatRelativeTime(log.created_at)} ·{' '}
+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;
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import {
calculateNiceAxisMax,
formatBytes,
formatBytesPerSecond,
formatPercent,
} from '@/lib/utils/metrics';
describe('metrics utils', () => {
it('formats byte values with readable units', () => {
expect(formatBytes(0)).toBe('0 B');
expect(formatBytes(1536)).toBe('1.5 KB');
expect(formatBytes(183 * 1024 * 1024)).toBe('183 MB');
expect(formatBytes(14.7 * 1024 * 1024)).toBe('14.7 MB');
});
it('formats byte rates with per-second units', () => {
expect(formatBytesPerSecond(0, 3600)).toBe('0 B/s');
expect(formatBytesPerSecond(1630858.9, 3600)).toBe('453 B/s');
expect(formatBytesPerSecond(22620490956.8, 3600)).toBe('6 MB/s');
});
it('formats percent values without long decimals', () => {
expect(formatPercent(58.36809884639982)).toBe('58.4%');
});
it('rounds chart max values to friendly axis steps', () => {
expect(calculateNiceAxisMax([0, 58.36809884639982])).toBe(80);
expect(calculateNiceAxisMax([0, 1630858.9])).toBe(2000000);
expect(calculateNiceAxisMax([0, 22620490956.8])).toBe(25000000000);
});
});