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:
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user