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 {
|
||||
|
||||
Reference in New Issue
Block a user