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

@@ -0,0 +1,126 @@
import { describe, expect, test } from "bun:test";
import {
analyzeIncidentSequence,
buildHourlyTrend,
calculateAvailability,
calculateCurrentStreak,
calculatePercentile,
type MetricCheckpoint,
} from "../../src/server/metrics";
describe("后端指标计算", () => {
test("可用率无数据返回 0并保留两位精度", () => {
expect(calculateAvailability(0, 0)).toBe(0);
expect(calculateAvailability(2, 3)).toBe(66.67);
});
test("百分位按 ceil(count * N / 100) - 1 取值", () => {
const durations = Array.from({ length: 100 }, (_, index) => index + 1);
expect(calculatePercentile([], 95)).toBeNull();
expect(calculatePercentile([40, 10, 30, 20], 95)).toBe(40);
expect(calculatePercentile(durations, 95)).toBe(95);
expect(calculatePercentile(durations, 99)).toBe(99);
});
test("无检查数据时故障分析返回空口径", () => {
const result = analyzeIncidentSequence([], "2025-01-01T00:00:00.000Z", "2025-01-01T01:00:00.000Z");
expect(result).toEqual({ incidentCount: 0, longestOutage: null, mttr: null });
expect(calculateCurrentStreak([])).toBeNull();
});
test("窗口起始即故障计入 incident 和最长故障,但不计入 MTTR", () => {
const result = analyzeIncidentSequence(
[
checkpoint("2025-01-01T00:05:00.000Z", false),
checkpoint("2025-01-01T00:10:00.000Z", false),
checkpoint("2025-01-01T00:20:00.000Z", true),
],
"2025-01-01T00:00:00.000Z",
"2025-01-01T01:00:00.000Z",
);
expect(result.incidentCount).toBe(1);
expect(result.longestOutage).toBe(20 * 60 * 1000);
expect(result.mttr).toBeNull();
});
test("未恢复故障计算到窗口结束且不计入 MTTR", () => {
const result = analyzeIncidentSequence(
[checkpoint("2025-01-01T00:05:00.000Z", true), checkpoint("2025-01-01T00:20:00.000Z", false)],
"2025-01-01T00:00:00.000Z",
"2025-01-01T01:00:00.000Z",
);
expect(result.incidentCount).toBe(1);
expect(result.longestOutage).toBe(40 * 60 * 1000);
expect(result.mttr).toBeNull();
});
test("连续异常只计一次 incident恢复后纳入 MTTR", () => {
const result = analyzeIncidentSequence(
[
checkpoint("2025-01-01T00:00:00.000Z", true),
checkpoint("2025-01-01T00:05:00.000Z", false),
checkpoint("2025-01-01T00:10:00.000Z", false),
checkpoint("2025-01-01T00:20:00.000Z", true),
],
"2025-01-01T00:00:00.000Z",
"2025-01-01T01:00:00.000Z",
);
expect(result.incidentCount).toBe(1);
expect(result.longestOutage).toBe(15 * 60 * 1000);
expect(result.mttr).toBe(15 * 60 * 1000);
});
test("连续状态支持 capped 标记", () => {
expect(
calculateCurrentStreak(
[
checkpoint("2025-01-01T00:00:00.000Z", true),
checkpoint("2025-01-01T00:01:00.000Z", false),
checkpoint("2025-01-01T00:02:00.000Z", false),
],
2,
),
).toEqual({ capped: true, count: 2, up: false });
});
test("UTC 小时趋势分桶返回 up/down 和延迟范围", () => {
const trend = buildHourlyTrend([
checkpoint("2025-01-01T00:10:00.000Z", true, 100),
checkpoint("2025-01-01T00:40:00.000Z", false, null),
checkpoint("2025-01-01T01:05:00.000Z", true, 300),
]);
expect(trend).toEqual([
{
availability: 50,
avgDurationMs: 100,
bucketStart: "2025-01-01T00:00:00.000Z",
downChecks: 1,
maxDurationMs: 100,
minDurationMs: 100,
totalChecks: 2,
upChecks: 1,
},
{
availability: 100,
avgDurationMs: 300,
bucketStart: "2025-01-01T01:00:00.000Z",
downChecks: 0,
maxDurationMs: 300,
minDurationMs: 300,
totalChecks: 1,
upChecks: 1,
},
]);
});
});
function checkpoint(timestamp: string, matched: boolean, durationMs: null | number = null): MetricCheckpoint {
return { durationMs, matched, timestamp };
}