mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-04 07:06:36 +08:00
feat: add 24-hour traffic and capacity trend charts to dashboard and node detail pages
- Implemented TrendChart component for visualizing traffic and capacity trends. - Enhanced DashboardOverview and NodeDetailPage components to display 24-hour request and capacity trends. - Updated types to include traffic and capacity trend data structures. - Created observability trends service to aggregate traffic and capacity data. - Added tests for traffic report generation and trend data handling. - Introduced new dependencies for charting (echarts and echarts-for-react).
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const defaultFormatter = (value: number) => value.toLocaleString('zh-CN');
|
||||
|
||||
export function TrendChart({ labels, series, height = 220 }: TrendChartProps) {
|
||||
if (labels.length === 0 || series.length === 0) {
|
||||
return (
|
||||
<div className="flex h-[220px] items-center justify-center rounded-3xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] text-sm text-[var(--foreground-secondary)]">
|
||||
暂无趋势数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const option = useMemo<EChartsOption>(() => {
|
||||
const maxValue =
|
||||
Math.max(
|
||||
1,
|
||||
...series.flatMap((item) => item.values.map((value) => value || 0)),
|
||||
) * 1.1;
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
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',
|
||||
},
|
||||
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]);
|
||||
|
||||
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 border-[var(--border-default)] bg-[var(--surface-elevated)] 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-[var(--foreground-muted)] uppercase">
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
{formatter(latestValue)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[28px] border border-[var(--border-default)] bg-[linear-gradient(180deg,rgba(255,255,255,0.03),rgba(255,255,255,0))] px-4 py-4">
|
||||
<ReactECharts
|
||||
option={option}
|
||||
notMerge
|
||||
lazyUpdate
|
||||
style={{ height, width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from 'next/link';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { TrendChart } from '@/components/data/trend-chart';
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
@@ -32,6 +33,14 @@ function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
function formatTrendHour(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return `${date.getHours().toString().padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
function getAlertVariant(
|
||||
alert: DashboardAlert,
|
||||
): 'success' | 'warning' | 'danger' | 'info' {
|
||||
@@ -215,6 +224,68 @@ export function DashboardOverview() {
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<AppCard
|
||||
title="24 小时请求趋势"
|
||||
description="观察整体请求量和错误量是否出现异常抬升。"
|
||||
>
|
||||
<TrendChart
|
||||
labels={overview.trends.traffic_24h.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
series={[
|
||||
{
|
||||
label: '请求量',
|
||||
color: '#f59e0b',
|
||||
fillColor: 'rgba(245, 158, 11, 0.18)',
|
||||
variant: 'area',
|
||||
values: overview.trends.traffic_24h.map(
|
||||
(point) => point.request_count,
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '错误量',
|
||||
color: '#ef4444',
|
||||
values: overview.trends.traffic_24h.map(
|
||||
(point) => point.error_count,
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="24 小时容量趋势"
|
||||
description="按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。"
|
||||
>
|
||||
<TrendChart
|
||||
labels={overview.trends.capacity_24h.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
series={[
|
||||
{
|
||||
label: '平均 CPU',
|
||||
color: '#0f766e',
|
||||
fillColor: 'rgba(15, 118, 110, 0.15)',
|
||||
variant: 'area',
|
||||
values: overview.trends.capacity_24h.map(
|
||||
(point) => point.average_cpu_usage_percent,
|
||||
),
|
||||
valueFormatter: formatPercent,
|
||||
},
|
||||
{
|
||||
label: '平均内存',
|
||||
color: '#2563eb',
|
||||
values: overview.trends.capacity_24h.map(
|
||||
(point) => point.average_memory_usage_percent,
|
||||
),
|
||||
valueFormatter: formatPercent,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<AppCard
|
||||
title="节点健康列表"
|
||||
|
||||
@@ -30,6 +30,25 @@ export interface DashboardConfig {
|
||||
pending_nodes: number;
|
||||
}
|
||||
|
||||
export interface TrafficTrendPoint {
|
||||
bucket_started_at: string;
|
||||
request_count: number;
|
||||
error_count: number;
|
||||
unique_visitor_count: number;
|
||||
}
|
||||
|
||||
export interface CapacityTrendPoint {
|
||||
bucket_started_at: string;
|
||||
average_cpu_usage_percent: number;
|
||||
average_memory_usage_percent: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface DashboardTrends {
|
||||
traffic_24h: TrafficTrendPoint[];
|
||||
capacity_24h: CapacityTrendPoint[];
|
||||
}
|
||||
|
||||
export interface DashboardNodeHealth {
|
||||
id: number;
|
||||
node_id: string;
|
||||
@@ -63,6 +82,7 @@ export interface DashboardOverview {
|
||||
traffic: DashboardTraffic;
|
||||
capacity: DashboardCapacity;
|
||||
config: DashboardConfig;
|
||||
trends: DashboardTrends;
|
||||
nodes: DashboardNodeHealth[];
|
||||
active_alerts: DashboardAlert[];
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { TrendChart } from '@/components/data/trend-chart';
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
@@ -143,6 +144,14 @@ function formatUptime(seconds?: number | null) {
|
||||
return `${minutes} 分钟`;
|
||||
}
|
||||
|
||||
function formatTrendHour(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return `${date.getHours().toString().padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
function getHealthEventVariant(
|
||||
event: NodeObservability['health_events'][number],
|
||||
): 'success' | 'warning' | 'danger' | 'info' {
|
||||
@@ -829,6 +838,76 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<AppCard
|
||||
title="24 小时请求趋势"
|
||||
description="按小时聚合该节点的请求量和错误量,判断是否存在突发流量或持续异常。"
|
||||
>
|
||||
<TrendChart
|
||||
labels={
|
||||
observability?.trends.traffic_24h.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
) ?? []
|
||||
}
|
||||
series={[
|
||||
{
|
||||
label: '请求量',
|
||||
color: '#f59e0b',
|
||||
fillColor: 'rgba(245, 158, 11, 0.18)',
|
||||
variant: 'area',
|
||||
values:
|
||||
observability?.trends.traffic_24h.map(
|
||||
(point) => point.request_count,
|
||||
) ?? [],
|
||||
},
|
||||
{
|
||||
label: '错误量',
|
||||
color: '#ef4444',
|
||||
values:
|
||||
observability?.trends.traffic_24h.map(
|
||||
(point) => point.error_count,
|
||||
) ?? [],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="24 小时容量趋势"
|
||||
description="观察该节点 CPU 与内存使用率在 24 小时内的变化,辅助扩容和排障。"
|
||||
>
|
||||
<TrendChart
|
||||
labels={
|
||||
observability?.trends.capacity_24h.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
) ?? []
|
||||
}
|
||||
series={[
|
||||
{
|
||||
label: '平均 CPU',
|
||||
color: '#0f766e',
|
||||
fillColor: 'rgba(15, 118, 110, 0.15)',
|
||||
variant: 'area',
|
||||
values:
|
||||
observability?.trends.capacity_24h.map(
|
||||
(point) => point.average_cpu_usage_percent,
|
||||
) ?? [],
|
||||
valueFormatter: formatPercent,
|
||||
},
|
||||
{
|
||||
label: '平均内存',
|
||||
color: '#2563eb',
|
||||
values:
|
||||
observability?.trends.capacity_24h.map(
|
||||
(point) => point.average_memory_usage_percent,
|
||||
) ?? [],
|
||||
valueFormatter: formatPercent,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<AppCard title="更新模式">
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -99,6 +99,25 @@ export interface NodeTrafficReport {
|
||||
source_countries_json: string;
|
||||
}
|
||||
|
||||
export interface NodeTrafficTrendPoint {
|
||||
bucket_started_at: string;
|
||||
request_count: number;
|
||||
error_count: number;
|
||||
unique_visitor_count: number;
|
||||
}
|
||||
|
||||
export interface NodeCapacityTrendPoint {
|
||||
bucket_started_at: string;
|
||||
average_cpu_usage_percent: number;
|
||||
average_memory_usage_percent: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface NodeObservabilityTrends {
|
||||
traffic_24h: NodeTrafficTrendPoint[];
|
||||
capacity_24h: NodeCapacityTrendPoint[];
|
||||
}
|
||||
|
||||
export interface NodeHealthEvent {
|
||||
event_type: string;
|
||||
severity: string;
|
||||
@@ -116,4 +135,5 @@ export interface NodeObservability {
|
||||
metric_snapshots: NodeMetricSnapshot[];
|
||||
traffic_reports: NodeTrafficReport[];
|
||||
health_events: NodeHealthEvent[];
|
||||
trends: NodeObservabilityTrends;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@tanstack/react-query": "^5.66.8",
|
||||
"clsx": "^2.1.1",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"marked": "^17.0.4",
|
||||
"next": "15.2.2",
|
||||
"react": "19.0.0",
|
||||
|
||||
Generated
+44
@@ -17,6 +17,12 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
echarts:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
echarts-for-react:
|
||||
specifier: ^3.0.6
|
||||
version: 3.0.6(echarts@6.0.0)(react@19.0.0)
|
||||
marked:
|
||||
specifier: ^17.0.4
|
||||
version: 17.0.4
|
||||
@@ -1421,6 +1427,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.0.0:
|
||||
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
|
||||
|
||||
electron-to-chromium@1.5.307:
|
||||
resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==}
|
||||
|
||||
@@ -2528,6 +2543,9 @@ packages:
|
||||
simple-swizzle@0.2.4:
|
||||
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
||||
|
||||
size-sensor@1.0.3:
|
||||
resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==}
|
||||
|
||||
slash@3.0.0:
|
||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2701,6 +2719,9 @@ packages:
|
||||
tslib@1.14.1:
|
||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||
|
||||
tslib@2.3.0:
|
||||
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -2922,6 +2943,9 @@ packages:
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zrender@6.0.0:
|
||||
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
|
||||
|
||||
zustand@5.0.11:
|
||||
resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
@@ -4131,6 +4155,18 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
echarts-for-react@3.0.6(echarts@6.0.0)(react@19.0.0):
|
||||
dependencies:
|
||||
echarts: 6.0.0
|
||||
fast-deep-equal: 3.1.3
|
||||
react: 19.0.0
|
||||
size-sensor: 1.0.3
|
||||
|
||||
echarts@6.0.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
zrender: 6.0.0
|
||||
|
||||
electron-to-chromium@1.5.307: {}
|
||||
|
||||
emoji-regex@9.2.2: {}
|
||||
@@ -5421,6 +5457,8 @@ snapshots:
|
||||
is-arrayish: 0.3.4
|
||||
optional: true
|
||||
|
||||
size-sensor@1.0.3: {}
|
||||
|
||||
slash@3.0.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
@@ -5590,6 +5628,8 @@ snapshots:
|
||||
|
||||
tslib@1.14.1: {}
|
||||
|
||||
tslib@2.3.0: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsutils@3.21.0(typescript@5.9.3):
|
||||
@@ -5853,6 +5893,10 @@ snapshots:
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zrender@6.0.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
|
||||
zustand@5.0.11(@types/react@19.2.14)(immer@9.0.21)(react@19.0.0):
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
@@ -4,6 +4,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DashboardOverview } from '@/features/dashboard/components/dashboard-overview';
|
||||
|
||||
vi.mock('echarts-for-react', () => ({
|
||||
default: () => <div data-testid="echarts-mock" />,
|
||||
}));
|
||||
|
||||
describe('DashboardOverview', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
@@ -15,80 +19,86 @@ describe('DashboardOverview', () => {
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
||||
if (url.includes('/nodes/')) {
|
||||
if (url.includes('/dashboard/overview')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: [{ id: 1, status: 'online', auto_update_enabled: true }],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/config-versions/')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: [{ id: 1, version: '20260311-001', is_active: true, created_at: '2026-03-11T10:00:00Z' }],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/managed-domains/')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: [{ id: 1, domain: '*.example.com', enabled: true, cert_id: 1 }],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/tls-certificates/')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'example',
|
||||
not_after: '2026-04-01T00:00:00Z',
|
||||
updated_at: '2026-03-10T10:00:00Z',
|
||||
data: {
|
||||
generated_at: '2026-03-14T08:00:00Z',
|
||||
summary: {
|
||||
total_nodes: 2,
|
||||
online_nodes: 2,
|
||||
offline_nodes: 0,
|
||||
pending_nodes: 0,
|
||||
unhealthy_nodes: 1,
|
||||
active_alerts: 1,
|
||||
lagging_nodes: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/proxy-routes/')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: [{ id: 1, enabled: true, enable_https: true, custom_headers: '[{"key":"X-Test","value":"1"}]' }],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/user/search')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: [{ id: 1, role: 100, status: 1, username: 'root' }],
|
||||
traffic: {
|
||||
request_count: 900,
|
||||
unique_visitors: 200,
|
||||
error_count: 36,
|
||||
estimated_qps: 15,
|
||||
reported_nodes: 2,
|
||||
},
|
||||
capacity: {
|
||||
average_cpu_usage_percent: 68.5,
|
||||
average_memory_usage_percent: 71.8,
|
||||
high_cpu_nodes: 1,
|
||||
high_memory_nodes: 1,
|
||||
high_storage_nodes: 1,
|
||||
},
|
||||
config: {
|
||||
active_version: '20260314-001',
|
||||
lagging_nodes: 1,
|
||||
pending_nodes: 0,
|
||||
},
|
||||
trends: {
|
||||
traffic_24h: Array.from({ length: 24 }, (_, index) => ({
|
||||
bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
|
||||
request_count: index * 10,
|
||||
error_count: index,
|
||||
unique_visitor_count: index * 3,
|
||||
})),
|
||||
capacity_24h: Array.from({ length: 24 }, (_, index) => ({
|
||||
bucket_started_at: `2026-03-13T${String(index).padStart(2, '0')}:00:00Z`,
|
||||
average_cpu_usage_percent: index,
|
||||
average_memory_usage_percent: index + 10,
|
||||
reported_nodes: 2,
|
||||
})),
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
node_id: 'node-a',
|
||||
name: 'edge-a',
|
||||
status: 'online',
|
||||
openresty_status: 'healthy',
|
||||
current_version: '20260314-001',
|
||||
last_seen_at: '2026-03-14T08:00:00Z',
|
||||
active_event_count: 0,
|
||||
cpu_usage_percent: 45,
|
||||
memory_usage_percent: 50,
|
||||
storage_usage_percent: 60,
|
||||
request_count: 600,
|
||||
error_count: 6,
|
||||
unique_visitor_count: 120,
|
||||
},
|
||||
],
|
||||
active_alerts: [
|
||||
{
|
||||
node_id: 'node-b',
|
||||
node_name: 'edge-b',
|
||||
event_type: 'openresty_unhealthy',
|
||||
severity: 'critical',
|
||||
message: 'reload failed',
|
||||
last_triggered_at: '2026-03-14T07:59:00Z',
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -112,8 +122,8 @@ describe('DashboardOverview', () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('控制台仪表盘')).toBeInTheDocument();
|
||||
expect(await screen.findByText('节点摘要')).toBeInTheDocument();
|
||||
expect(await screen.findByText('用户概览')).toBeInTheDocument();
|
||||
expect(await screen.findByText('系统运行总览')).toBeInTheDocument();
|
||||
expect(await screen.findByText('24 小时请求趋势')).toBeInTheDocument();
|
||||
expect(await screen.findByText('节点健康列表')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user