1
0

feat: 前端指标体系增强 — Dashboard/Metrics API、2×4 统计区、趋势图面积+异常标记、连续状态列

- 新增 GET /api/dashboard 合并原 summary+targets 首屏接口
- 新增 GET /api/targets/:id/metrics 合并原 stats+trend 概览接口
- 后端指标纯函数:可用率、百分位、故障段分析、连续状态、UTC 小时分桶
- ProbeStore 窗口取数方法替代全量历史查询
- SummaryCards 扩展为 4 卡片(新增异常事件数)+ 数据新鲜度展示
- 表格新增「连续」列(Tag 渲染 capped 状态)
- OverviewTab 重构为 2×4 Statistic 多维度布局
- TrendChart 改为延迟范围面积图 + 红色异常标记点
- 删除旧路由(summary/targets/trend)和 computeTrendStats
- 同步 delta specs 到主 specs 并归档变更
This commit is contained in:
2026-05-14 12:32:41 +08:00
parent e983e5d75d
commit 1c5cfafda6
47 changed files with 1768 additions and 1231 deletions

View File

@@ -19,13 +19,14 @@ function getColumn(columns: Array<PrimaryTableCol<TargetStatus>>, colKey: string
function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
return {
currentStreak: null,
group: "default",
id: 1,
interval: "5s",
latestCheck: null,
name: "test",
recentSamples: [],
stats: { availability: 100, totalChecks: 0 },
stats: { availability: 100, downChecks: 0, totalChecks: 0, upChecks: 0 },
target: "https://example.com",
type: "http",
...overrides,
@@ -33,7 +34,7 @@ function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
}
describe("createTargetTableColumns", () => {
test("生成 7 个目标表格列", () => {
test("生成 8 个目标表格列", () => {
const columns = createTargetTableColumns(["http", "cmd"]);
expect(columns.map((column) => column.colKey)).toEqual([
@@ -42,6 +43,7 @@ describe("createTargetTableColumns", () => {
"type",
"stats.availability",
"recentSamples",
"currentStreak",
"latestCheck.durationMs",
"interval",
]);
@@ -81,4 +83,19 @@ describe("createTargetTableColumns", () => {
expect(element.props.children).toBe("tcp");
});
test("连续状态列渲染 capped 标记", () => {
const streakColumn = getColumn(createTargetTableColumns(["http"]), "currentStreak");
const renderCell = streakColumn.cell as (params: PrimaryTableCellParams<TargetStatus>) => {
props: { children: unknown[] };
};
const element = renderCell({
col: streakColumn,
colIndex: 5,
row: makeTarget({ currentStreak: { capped: true, count: 30, up: false } }),
rowIndex: 0,
});
expect(element.props.children.join("")).toBe("▼ 30+");
});
});

View File

@@ -11,13 +11,14 @@ import {
function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
return {
currentStreak: null,
group: "default",
id: 1,
interval: "5s",
latestCheck: null,
name: "test",
recentSamples: [],
stats: { availability: 100, totalChecks: 0 },
stats: { availability: 100, downChecks: 0, totalChecks: 0, upChecks: 0 },
target: "https://example.com",
type: "http",
...overrides,
@@ -57,20 +58,20 @@ describe("statusSorter", () => {
describe("availabilitySorter", () => {
test("低可用率排前面", () => {
const low = makeTarget({ stats: { availability: 95, totalChecks: 100 } });
const high = makeTarget({ stats: { availability: 99.9, totalChecks: 100 } });
const low = makeTarget({ stats: { availability: 95, downChecks: 5, totalChecks: 100, upChecks: 95 } });
const high = makeTarget({ stats: { availability: 99.9, downChecks: 1, totalChecks: 100, upChecks: 99 } });
expect(availabilitySorter(low, high)).toBeLessThan(0);
});
test("相同可用率返回 0", () => {
const a = makeTarget({ stats: { availability: 99.9, totalChecks: 100 } });
const b = makeTarget({ stats: { availability: 99.9, totalChecks: 50 } });
const a = makeTarget({ stats: { availability: 99.9, downChecks: 1, totalChecks: 100, upChecks: 99 } });
const b = makeTarget({ stats: { availability: 99.9, downChecks: 1, totalChecks: 50, upChecks: 49 } });
expect(availabilitySorter(a, b)).toBe(0);
});
test("无 stats 按 0 处理", () => {
const noStats = makeTarget({ stats: undefined as unknown as TargetStatus["stats"] });
const high = makeTarget({ stats: { availability: 99.9, totalChecks: 100 } });
const high = makeTarget({ stats: { availability: 99.9, downChecks: 1, totalChecks: 100, upChecks: 99 } });
expect(availabilitySorter(noStats, high)).toBeLessThan(0);
});
});

View File

@@ -1,29 +0,0 @@
import { describe, expect, test } from "bun:test";
import type { TrendPoint } from "../../../src/shared/api";
import { computeTrendStats } from "../../../src/web/utils/stats";
describe("computeTrendStats", () => {
test("空趋势返回 0 统计", () => {
expect(computeTrendStats([])).toEqual({ downChecks: 0, totalChecks: 0, upChecks: 0 });
});
test("汇总总检查、正常和异常数量", () => {
const points: TrendPoint[] = [
{ availability: 80, avgDurationMs: 100, hour: "2025-01-01T00:00:00.000Z", totalChecks: 10 },
{ availability: 40, avgDurationMs: 200, hour: "2025-01-01T01:00:00.000Z", totalChecks: 5 },
];
expect(computeTrendStats(points)).toEqual({ downChecks: 5, totalChecks: 15, upChecks: 10 });
});
test("按每个趋势点四舍五入正常数量", () => {
const points: TrendPoint[] = [
{ availability: 33.3, avgDurationMs: null, hour: "2025-01-01T00:00:00.000Z", totalChecks: 3 },
{ availability: 66.7, avgDurationMs: null, hour: "2025-01-01T01:00:00.000Z", totalChecks: 3 },
];
expect(computeTrendStats(points)).toEqual({ downChecks: 3, totalChecks: 6, upChecks: 3 });
});
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { subtractHours } from "../../../src/web/utils/time";
import { formatDurationUnit, formatRelativeTime, isOlderThan, subtractHours } from "../../../src/web/utils/time";
describe("subtractHours", () => {
test("正常扣减小时", () => {
@@ -27,3 +27,38 @@ describe("subtractHours", () => {
expect(result.toISOString()).toBe("2025-01-15T12:00:00.000Z");
});
});
describe("formatRelativeTime", () => {
const now = new Date("2025-01-01T00:02:00.000Z");
test("格式化秒和分钟", () => {
expect(formatRelativeTime("2025-01-01T00:01:45.000Z", now)).toBe("15秒前");
expect(formatRelativeTime("2025-01-01T00:00:00.000Z", now)).toBe("2分钟前");
});
test("无时间返回占位", () => {
expect(formatRelativeTime(null, now)).toBe("尚无检查数据");
expect(formatRelativeTime("invalid", now)).toBe("尚无检查数据");
});
});
describe("formatDurationUnit", () => {
test("按秒、分钟、小时动态格式化", () => {
expect(formatDurationUnit(1500)).toEqual({ suffix: "秒", value: 1.5 });
expect(formatDurationUnit(120000)).toEqual({ suffix: "分钟", value: 2 });
expect(formatDurationUnit(5400000)).toEqual({ suffix: "小时", value: 1.5 });
});
test("空时长返回占位", () => {
expect(formatDurationUnit(null)).toEqual({ suffix: "", value: 0 });
});
});
describe("isOlderThan", () => {
test("判断时间是否超过阈值", () => {
const now = new Date("2025-01-01T00:02:00.000Z");
expect(isOlderThan("2025-01-01T00:00:59.000Z", 60000, now)).toBe(true);
expect(isOlderThan("2025-01-01T00:01:30.000Z", 60000, now)).toBe(false);
});
});