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

@@ -227,44 +227,55 @@ describe("ProbeStore", () => {
expect(history.items).toHaveLength(20);
});
test("getTargetStats 计算可用率和 duration", () => {
test("getTargetWindowStats 按时间窗口计算基础计数", () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
const stats = store.getTargetStats(t1Id);
const stats = store.getTargetWindowStats(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(stats.totalChecks).toBeGreaterThan(0);
expect(stats.upChecks + stats.downChecks).toBe(stats.totalChecks);
expect(stats.availability).toBeGreaterThanOrEqual(0);
expect(stats.availability).toBeLessThanOrEqual(100);
});
test("无记录目标的 stats", () => {
test("无记录目标的窗口 stats", () => {
const targets = store.getTargets();
const t2Id = targets.find((t) => t.name === "test-cmd")!.id;
const stats = store.getTargetStats(t2Id);
const stats = store.getTargetWindowStats(t2Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(stats.totalChecks).toBe(0);
expect(stats.upChecks).toBe(0);
expect(stats.downChecks).toBe(0);
expect(stats.availability).toBe(0);
});
test("getSummary 返回总览统计", () => {
const summary = store.getSummary();
expect(summary.total).toBe(2);
expect(summary.up + summary.down).toBe(2);
expect(summary.lastCheckTime).not.toBeNull();
test("getLatestChecksMap 支持 Dashboard 组装当前状态", () => {
const latestChecksMap = store.getLatestChecksMap();
const targets = store.getTargets();
const latest = latestChecksMap.get(targets[0]!.id);
expect(latest).toBeDefined();
expect(latest!.timestamp).toBe("2025-01-01T01:24:00.000Z");
});
test("getTrend 返回趋势数据", () => {
test("getTargetCheckpoints 返回窗口内升序检查点", () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
const trend = store.getTrend(t1Id, "2024-01-01T00:00:00.000Z", "2026-12-31T23:59:59.999Z");
expect(Array.isArray(trend)).toBe(true);
if (trend.length > 0) {
expect(trend[0]!.hour).toBeDefined();
expect(trend[0]!.avgDurationMs).toBeDefined();
expect(trend[0]!.availability).toBeGreaterThanOrEqual(0);
expect(trend[0]!.totalChecks).toBeGreaterThan(0);
}
const checkpoints = store.getTargetCheckpoints(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(checkpoints).toEqual([
{ duration_ms: 150.5, matched: 1, timestamp: "2025-01-01T00:00:00.000Z" },
{ duration_ms: 300, matched: 1, timestamp: "2025-01-01T00:00:30.000Z" },
{ duration_ms: null, matched: 0, timestamp: "2025-01-01T00:01:00.000Z" },
]);
});
test("getTargetDurations 返回成功检查耗时升序数组", () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
const durations = store.getTargetDurations(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(durations).toEqual([150.5, 300]);
});
test("getRecentSamples 返回最近采样数据", () => {
@@ -439,17 +450,18 @@ describe("ProbeStore", () => {
freshStore.close();
});
test("getAllTargetStats 返回所有 target 的聚合统计", () => {
test("getAllTargetWindowStats 返回所有 target 的窗口聚合统计", () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
const t2Id = targets[1]!.id;
const stats = store.getAllTargetStats();
const stats = store.getAllTargetWindowStats("2024-01-01T00:00:00.000Z", "2026-12-31T23:59:59.999Z");
expect(stats).toBeInstanceOf(Map);
const stats1 = stats.get(t1Id);
expect(stats1).toBeDefined();
expect(stats1!.totalChecks).toBeGreaterThan(0);
expect(stats1!.upChecks + stats1!.downChecks).toBe(stats1!.totalChecks);
expect(stats1!.availability).toBeGreaterThanOrEqual(0);
const stats2 = stats.get(t2Id);
@@ -459,7 +471,7 @@ describe("ProbeStore", () => {
}
});
test("getAllTargetStats 对无记录的 target 不包含 key", () => {
test("getAllTargetWindowStats 对无记录的 target 不包含 key", () => {
const freshStore = new ProbeStore(join(tempDir, "fresh-stats.db"));
freshStore.syncTargets([
{
@@ -479,13 +491,13 @@ describe("ProbeStore", () => {
},
]);
const stats = freshStore.getAllTargetStats();
const stats = freshStore.getAllTargetWindowStats("2025-01-01T00:00:00.000Z", "2025-01-02T00:00:00.000Z");
expect(stats.size).toBe(0);
freshStore.close();
});
test("getAllTargetStats 与 getTargetStats 的 availability 精度一致", () => {
test("getAllTargetWindowStats 与 getTargetWindowStats 的 availability 精度一致", () => {
const statsStore = new ProbeStore(join(tempDir, "stats-precision.db"));
const target: ResolvedHttpTarget = { ...httpTarget, name: "stats-precision" };
statsStore.syncTargets([target]);
@@ -502,16 +514,71 @@ describe("ProbeStore", () => {
});
}
const targetStats = statsStore.getTargetStats(targetId);
const allStats = statsStore.getAllTargetStats().get(targetId)!;
const targetStats = statsStore.getTargetWindowStats(
targetId,
"2025-01-01T00:00:00.000Z",
"2025-01-01T00:02:00.000Z",
);
const allStats = statsStore
.getAllTargetWindowStats("2025-01-01T00:00:00.000Z", "2025-01-01T00:02:00.000Z")
.get(targetId)!;
expect(targetStats.availability).toBe(66.67);
expect(targetStats.upChecks).toBe(2);
expect(targetStats.downChecks).toBe(1);
expect(allStats.availability).toBe(66.67);
expect(allStats.availability).toBe(targetStats.availability);
statsStore.close();
});
test("getDashboardIncidentStates 返回按 target 和 timestamp 升序排列的状态序列", () => {
const incidentStore = new ProbeStore(join(tempDir, "dashboard-incidents.db"));
const httpA: ResolvedHttpTarget = { ...httpTarget, name: "incident-http-a" };
const httpB: ResolvedHttpTarget = {
...httpTarget,
http: { ...httpTarget.http, url: "https://example.com/incident-b" },
name: "incident-http-b",
};
incidentStore.syncTargets([httpA, httpB]);
const targets = incidentStore.getTargets();
const targetAId = targets.find((target) => target.name === "incident-http-a")!.id;
const targetBId = targets.find((target) => target.name === "incident-http-b")!.id;
incidentStore.insertCheckResult({
durationMs: 100,
failure: null,
matched: false,
statusDetail: null,
targetId: targetBId,
timestamp: "2025-01-01T00:03:00.000Z",
});
incidentStore.insertCheckResult({
durationMs: 100,
failure: null,
matched: true,
statusDetail: "200 OK",
targetId: targetAId,
timestamp: "2025-01-01T00:02:00.000Z",
});
incidentStore.insertCheckResult({
durationMs: 100,
failure: null,
matched: false,
statusDetail: null,
targetId: targetAId,
timestamp: "2025-01-01T00:01:00.000Z",
});
expect(incidentStore.getDashboardIncidentStates("2025-01-01T00:00:00.000Z", "2025-01-01T00:03:00.000Z")).toEqual([
{ matched: 0, target_id: targetAId, timestamp: "2025-01-01T00:01:00.000Z" },
{ matched: 1, target_id: targetAId, timestamp: "2025-01-01T00:02:00.000Z" },
{ matched: 0, target_id: targetBId, timestamp: "2025-01-01T00:03:00.000Z" },
]);
incidentStore.close();
});
test("prune 删除过期数据", () => {
const pruneStore = new ProbeStore(join(tempDir, "prune.db"));
pruneStore.syncTargets([httpTarget]);