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

View File

@@ -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+");
});
});

View File

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

View File

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

View File

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