mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 18:36:37 +08:00
[优化] 添加度量工具函数和测试用例,优化仪表盘和趋势图组件
This commit is contained in:
@@ -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)} ·{' '}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user