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

@@ -4,11 +4,11 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
DashboardResponse,
HealthResponse,
HistoryResponse,
MetaResponse,
SummaryResponse,
TargetStatus,
TargetMetricsResponse,
} from "../../src/shared/api";
import { checkerRegistry } from "../../src/server/checker/runner";
@@ -73,7 +73,7 @@ describe("API 路由", () => {
const targets = store.getTargets();
store.insertCheckResult({
durationMs: 150,
durationMs: 100,
failure: null,
matched: true,
statusDetail: "200 OK",
@@ -93,7 +93,78 @@ describe("API 路由", () => {
matched: false,
statusDetail: null,
targetId: targets[0]!.id,
timestamp: "2025-01-01T00:00:30.000Z",
timestamp: "2025-01-01T00:10:00.000Z",
});
store.insertCheckResult({
durationMs: null,
failure: {
actual: 500,
expected: 200,
kind: "error",
message: "状态码不匹配",
path: "$.status",
phase: "status",
},
matched: false,
statusDetail: null,
targetId: targets[0]!.id,
timestamp: "2025-01-01T00:20:00.000Z",
});
store.insertCheckResult({
durationMs: 200,
failure: null,
matched: true,
statusDetail: "200 OK",
targetId: targets[0]!.id,
timestamp: "2025-01-01T00:40:00.000Z",
});
store.insertCheckResult({
durationMs: 400,
failure: null,
matched: true,
statusDetail: "200 OK",
targetId: targets[0]!.id,
timestamp: "2025-01-01T01:10:00.000Z",
});
const now = Date.now();
store.insertCheckResult({
durationMs: 120,
failure: null,
matched: true,
statusDetail: "200 OK",
targetId: targets[0]!.id,
timestamp: new Date(now - 90 * 60 * 1000).toISOString(),
});
store.insertCheckResult({
durationMs: null,
failure: {
actual: 500,
expected: 200,
kind: "error",
message: "状态码不匹配",
path: "$.status",
phase: "status",
},
matched: false,
statusDetail: null,
targetId: targets[0]!.id,
timestamp: new Date(now - 60 * 60 * 1000).toISOString(),
});
store.insertCheckResult({
durationMs: null,
failure: {
actual: 500,
expected: 200,
kind: "error",
message: "状态码不匹配",
path: "$.status",
phase: "status",
},
matched: false,
statusDetail: null,
targetId: targets[0]!.id,
timestamp: new Date(now - 30 * 60 * 1000).toISOString(),
});
server = startServer({
@@ -119,40 +190,44 @@ describe("API 路由", () => {
expect(body.service).toBe("dial-server");
});
test("/api/summary 返回总览统计", async () => {
const response = await fetch(`${baseUrl}/api/summary`);
const body = (await response.json()) as SummaryResponse;
expect(response.status).toBe(200);
expect(body.total).toBe(2);
expect(body.up).toBeGreaterThanOrEqual(0);
expect(body.down).toBeGreaterThanOrEqual(0);
expect(body.up + body.down).toBe(2);
expect(body.lastCheckTime).not.toBeNull();
});
test("/api/targets 返回目标列表", async () => {
const response = await fetch(`${baseUrl}/api/targets`);
const body = (await response.json()) as TargetStatus[];
test("/api/dashboard 返回总览和目标列表", async () => {
const response = await fetch(`${baseUrl}/api/dashboard?window=24h&recentLimit=2`);
const body = (await response.json()) as DashboardResponse;
expect(response.status).toBe(200);
expect(body).toHaveLength(2);
expect(body.summary.total).toBe(2);
expect(body.summary.up).toBe(0);
expect(body.summary.down).toBe(2);
expect(body.summary.incidents).toBe(1);
expect(body.summary.lastCheckTime).not.toBeNull();
expect(body.summary.window.label).toBe("24h");
expect(body.targets).toHaveLength(2);
const tA = body.find((t) => t.name === "test-a")!;
const tA = body.targets.find((t) => t.name === "test-a")!;
expect(tA.type).toBe("http");
expect(tA.target).toBe("http://a.com");
expect(tA.group).toBe("default");
expect(tA.latestCheck).not.toBeNull();
expect(tA.latestCheck!.matched).toBe(false);
expect(tA.latestCheck!.failure).not.toBeNull();
expect(tA.recentSamples).toBeDefined();
expect(Array.isArray(tA.recentSamples)).toBe(true);
expect(tA.stats.totalChecks).toBeDefined();
expect(tA.stats.availability).toBeDefined();
expect(tA.recentSamples).toHaveLength(2);
expect(tA.stats).toMatchObject({ availability: 33.33, downChecks: 2, totalChecks: 3, upChecks: 1 });
expect(tA.currentStreak).toEqual({ capped: true, count: 2, up: false });
const tB = body.find((t) => t.name === "test-b")!;
const tB = body.targets.find((t) => t.name === "test-b")!;
expect(tB.type).toBe("cmd");
expect(tB.target).toBe("exec echo hello");
expect(tB.latestCheck).toBeNull();
expect(tB.stats).toMatchObject({ availability: 0, downChecks: 0, totalChecks: 0, upChecks: 0 });
expect(tB.currentStreak).toBeNull();
});
test("dashboard 无效参数返回 400", async () => {
const invalidWindow = await fetch(`${baseUrl}/api/dashboard?window=7d`);
const invalidLimit = await fetch(`${baseUrl}/api/dashboard?recentLimit=0`);
expect(invalidWindow.status).toBe(400);
expect(invalidLimit.status).toBe(400);
});
test("/api/meta 返回 checker 类型列表", async () => {
@@ -166,36 +241,36 @@ describe("API 路由", () => {
});
test("不支持的 method 在有 API 通配符时返回 404", async () => {
const response = await fetch(`${baseUrl}/api/summary`, { method: "POST" });
const response = await fetch(`${baseUrl}/api/dashboard`, { method: "POST" });
expect(response.status).toBe(404);
});
test("/api/targets/:id/history 返回历史记录", async () => {
const targets = store.getTargets();
const from = "2024-01-01T00:00:00.000Z";
const to = "2026-12-31T23:59:59.999Z";
const to = "2025-01-02T00:00:00.000Z";
const response = await fetch(`${baseUrl}/api/targets/${targets[0]!.id}/history?from=${from}&to=${to}`);
const body = (await response.json()) as HistoryResponse;
expect(response.status).toBe(200);
expect(body.items).toHaveLength(2);
expect(body.total).toBe(2);
expect(body.items).toHaveLength(5);
expect(body.total).toBe(5);
expect(body.page).toBe(1);
expect(body.pageSize).toBe(20);
expect(body.items[0]!.failure).not.toBeNull();
expect(body.items[0]!.failure!.kind).toBe("error");
const failedItem = body.items.find((item) => item.failure);
expect(failedItem?.failure?.kind).toBe("error");
});
test("/api/targets/:id/history 支持 page 参数", async () => {
const targets = store.getTargets();
const from = "2024-01-01T00:00:00.000Z";
const to = "2026-12-31T23:59:59.999Z";
const to = "2025-01-02T00:00:00.000Z";
const response = await fetch(`${baseUrl}/api/targets/${targets[0]!.id}/history?from=${from}&to=${to}&pageSize=1`);
const body = (await response.json()) as HistoryResponse;
expect(response.status).toBe(200);
expect(body.items).toHaveLength(1);
expect(body.total).toBe(2);
expect(body.total).toBe(5);
});
test("history pageSize 超过上限返回 400", async () => {
@@ -209,15 +284,64 @@ describe("API 路由", () => {
expect(body["error"]).toBe("pageSize must not exceed 200");
});
test("/api/targets/:id/trend 返回趋势数据", async () => {
test("/api/targets/:id/metrics 返回单目标统计和趋势", async () => {
const targets = store.getTargets();
const from = "2024-01-01T00:00:00.000Z";
const to = "2026-12-31T23:59:59.999Z";
const response = await fetch(`${baseUrl}/api/targets/${targets[0]!.id}/trend?from=${from}&to=${to}`);
const body = (await response.json()) as unknown[];
const from = "2025-01-01T00:00:00.000Z";
const to = "2025-01-01T01:59:59.999Z";
const response = await fetch(`${baseUrl}/api/targets/${targets[0]!.id}/metrics?from=${from}&to=${to}&bucket=1h`);
const body = (await response.json()) as TargetMetricsResponse;
expect(response.status).toBe(200);
expect(Array.isArray(body)).toBe(true);
expect(body.targetId).toBe(targets[0]!.id);
expect(body.window.bucket).toBe("1h");
expect(body.stats).toMatchObject({
availability: 60,
avgDurationMs: 233.33,
downChecks: 2,
incidentCount: 1,
longestOutage: 30 * 60 * 1000,
mttr: 30 * 60 * 1000,
p95DurationMs: 400,
p99DurationMs: 400,
totalChecks: 5,
upChecks: 3,
});
expect(body.stats.currentStreak).toEqual({ count: 2, up: true });
expect(body.trend[0]).toMatchObject({
availability: 50,
avgDurationMs: 150,
bucketStart: "2025-01-01T00:00:00.000Z",
downChecks: 2,
maxDurationMs: 200,
minDurationMs: 100,
totalChecks: 4,
upChecks: 2,
});
});
test("/api/targets/:id/metrics 无数据返回空指标", async () => {
const targets = store.getTargets();
const target = targets.find((item) => item.name === "test-b")!;
const response = await fetch(
`${baseUrl}/api/targets/${target.id}/metrics?from=2025-01-01T00:00:00.000Z&to=2025-01-01T01:00:00.000Z`,
);
const body = (await response.json()) as TargetMetricsResponse;
expect(response.status).toBe(200);
expect(body.stats).toEqual({
availability: 0,
avgDurationMs: null,
currentStreak: null,
downChecks: 0,
incidentCount: 0,
longestOutage: null,
mttr: null,
p95DurationMs: null,
p99DurationMs: null,
totalChecks: 0,
upChecks: 0,
});
expect(body.trend).toEqual([]);
});
test("查询不存在的目标返回 404", async () => {
@@ -239,18 +363,18 @@ describe("API 路由", () => {
expect(body["error"]).toContain("from and to");
});
test("trend 缺少 from/to 参数返回 400", async () => {
test("metrics 缺少 from/to 参数返回 400", async () => {
const targets = store.getTargets();
const response = await fetch(`${baseUrl}/api/targets/${targets[0]!.id}/trend`);
const response = await fetch(`${baseUrl}/api/targets/${targets[0]!.id}/metrics`);
const body = (await response.json()) as Record<string, unknown>;
expect(response.status).toBe(400);
expect(body["error"]).toContain("from and to");
});
test("trend 无效 targetId 返回 400", async () => {
test("metrics 无效 targetId 返回 400", async () => {
const response = await fetch(
`${baseUrl}/api/targets/invalid/trend?from=2024-01-01T00:00:00Z&to=2024-01-02T00:00:00Z`,
`${baseUrl}/api/targets/invalid/metrics?from=2024-01-01T00:00:00Z&to=2024-01-02T00:00:00Z`,
);
const body = (await response.json()) as Record<string, unknown>;
@@ -258,6 +382,19 @@ describe("API 路由", () => {
expect(body["error"]).toBe("Invalid target ID");
});
test("metrics 无效 bucket 和不存在目标返回错误", async () => {
const targets = store.getTargets();
const invalidBucket = await fetch(
`${baseUrl}/api/targets/${targets[0]!.id}/metrics?from=2025-01-01T00:00:00.000Z&to=2025-01-01T01:00:00.000Z&bucket=5m`,
);
const missingTarget = await fetch(
`${baseUrl}/api/targets/99999/metrics?from=2025-01-01T00:00:00.000Z&to=2025-01-01T01:00:00.000Z`,
);
expect(invalidBucket.status).toBe(400);
expect(missingTarget.status).toBe(404);
});
test("未知 /api/* 返回 404", async () => {
const response = await fetch(`${baseUrl}/api/missing`);
expect(response.status).toBe(404);
@@ -270,7 +407,7 @@ describe("API 路由", () => {
store,
});
try {
const response = await fetch(`http://127.0.0.1:${prodServer.port}/api/summary`);
const response = await fetch(`http://127.0.0.1:${prodServer.port}/api/dashboard`);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
} finally {

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]);

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 };
}