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:
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
126
tests/server/metrics.test.ts
Normal file
126
tests/server/metrics.test.ts
Normal 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 };
|
||||
}
|
||||
@@ -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+");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user