feat: 重构 Dashboard 为卡片式分组布局
表格布局替换为按分组展示的卡片式布局,新增 group 字段配置和 TargetBoard/TargetCard 等组件。模态框详情页支持时间范围筛选和分页,SummaryCards 减为 3 个。API 端点变更:trend/history 改用 from/to 参数,history 支持分页。recentSampleCount 硬编码为 30。
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { createFetchHandler, type StaticAssets } from "../../src/server/app";
|
||||
import { ProbeStore } from "../../src/server/checker/store";
|
||||
import type { SummaryResponse, TargetStatus, HealthResponse } from "../../src/shared/api";
|
||||
import type { HistoryResponse, SummaryResponse, TargetStatus, HealthResponse } from "../../src/shared/api";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -28,6 +28,7 @@ describe("API 路由", () => {
|
||||
{
|
||||
type: "http",
|
||||
name: "test-a",
|
||||
group: "default",
|
||||
http: {
|
||||
url: "http://a.com",
|
||||
method: "GET",
|
||||
@@ -40,6 +41,7 @@ describe("API 路由", () => {
|
||||
{
|
||||
type: "command",
|
||||
name: "test-b",
|
||||
group: "default",
|
||||
command: {
|
||||
exec: "echo",
|
||||
args: ["hello"],
|
||||
@@ -104,7 +106,7 @@ describe("API 路由", () => {
|
||||
expect(body.up).toBeGreaterThanOrEqual(0);
|
||||
expect(body.down).toBeGreaterThanOrEqual(0);
|
||||
expect(body.up + body.down).toBe(2);
|
||||
expect(body.avgDurationMs).toBeDefined();
|
||||
expect(body.lastCheckTime).not.toBeNull();
|
||||
});
|
||||
|
||||
test("/api/targets 返回目标列表", async () => {
|
||||
@@ -117,14 +119,15 @@ describe("API 路由", () => {
|
||||
const tA = body.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!.success).toBe(false);
|
||||
expect(tA.latestCheck!.matched).toBe(false);
|
||||
expect(tA.latestCheck!.failure).not.toBeNull();
|
||||
expect(tA.sparkline).toBeDefined();
|
||||
expect(Array.isArray(tA.sparkline)).toBe(true);
|
||||
expect(tA.stats.avgDurationMs).toBeDefined();
|
||||
expect(tA.stats.p99DurationMs).toBeDefined();
|
||||
expect(tA.recentSamples).toBeDefined();
|
||||
expect(Array.isArray(tA.recentSamples)).toBe(true);
|
||||
expect(tA.stats.totalChecks).toBeDefined();
|
||||
expect(tA.stats.availability).toBeDefined();
|
||||
|
||||
const tB = body.find((t) => t.name === "test-b")!;
|
||||
expect(tB.type).toBe("command");
|
||||
@@ -134,27 +137,43 @@ describe("API 路由", () => {
|
||||
|
||||
test("/api/targets/:id/history 返回历史记录", async () => {
|
||||
const targets = store.getTargets();
|
||||
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history`));
|
||||
const body = await response.json();
|
||||
const from = "2024-01-01T00:00:00.000Z";
|
||||
const to = "2026-12-31T23:59:59.999Z";
|
||||
const response = await fetchHandler(
|
||||
new Request(`http://localhost/api/targets/${targets[0]!.id}/history?from=${from}&to=${to}`),
|
||||
);
|
||||
const body = (await response.json()) as HistoryResponse;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toHaveLength(2);
|
||||
expect(body[0].failure).not.toBeNull();
|
||||
expect(body[0].failure.kind).toBe("error");
|
||||
expect(body.items).toHaveLength(2);
|
||||
expect(body.total).toBe(2);
|
||||
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");
|
||||
});
|
||||
|
||||
test("/api/targets/:id/history 支持 limit 参数", async () => {
|
||||
test("/api/targets/:id/history 支持 page 参数", async () => {
|
||||
const targets = store.getTargets();
|
||||
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history?limit=1`));
|
||||
const body = await response.json();
|
||||
const from = "2024-01-01T00:00:00.000Z";
|
||||
const to = "2026-12-31T23:59:59.999Z";
|
||||
const response = await fetchHandler(
|
||||
new Request(`http://localhost/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).toHaveLength(1);
|
||||
expect(body.items).toHaveLength(1);
|
||||
expect(body.total).toBe(2);
|
||||
});
|
||||
|
||||
test("/api/targets/:id/trend 返回趋势数据", async () => {
|
||||
const targets = store.getTargets();
|
||||
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/trend`));
|
||||
const from = "2024-01-01T00:00:00.000Z";
|
||||
const to = "2026-12-31T23:59:59.999Z";
|
||||
const response = await fetchHandler(
|
||||
new Request(`http://localhost/api/targets/${targets[0]!.id}/trend?from=${from}&to=${to}`),
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -162,22 +181,33 @@ describe("API 路由", () => {
|
||||
});
|
||||
|
||||
test("查询不存在的目标返回 404", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/targets/99999/history"));
|
||||
const response = await fetchHandler(
|
||||
new Request(
|
||||
"http://localhost/api/targets/99999/history?from=2024-01-01T00:00:00.000Z&to=2026-12-31T23:59:59.999Z",
|
||||
),
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(body.error).toBe("Target not found");
|
||||
});
|
||||
|
||||
test("无效 limit 参数返回 400", async () => {
|
||||
test("history 缺少 from/to 参数返回 400", async () => {
|
||||
const targets = store.getTargets();
|
||||
const response = await fetchHandler(
|
||||
new Request(`http://localhost/api/targets/${targets[0]!.id}/history?limit=abc`),
|
||||
);
|
||||
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history`));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe("Invalid limit parameter");
|
||||
expect(body.error).toContain("from and to");
|
||||
});
|
||||
|
||||
test("trend 缺少 from/to 参数返回 400", async () => {
|
||||
const targets = store.getTargets();
|
||||
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/trend`));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toContain("from and to");
|
||||
});
|
||||
|
||||
test("无效目标 ID 返回 400", async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ function makeTarget(
|
||||
return {
|
||||
type: "command",
|
||||
name: "test-cmd",
|
||||
group: "default",
|
||||
command: {
|
||||
exec: "echo",
|
||||
args: ["hello"],
|
||||
|
||||
@@ -479,4 +479,53 @@ targets:
|
||||
expect(t.command.env.PATH).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("解析 group 字段", async () => {
|
||||
const configPath = join(tempDir, "group.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "grouped"
|
||||
type: http
|
||||
group: "搜索引擎"
|
||||
http:
|
||||
url: "http://example.com"
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.targets[0]!.group).toBe("搜索引擎");
|
||||
});
|
||||
|
||||
test("group 字段默认为 default", async () => {
|
||||
const configPath = join(tempDir, "no-group.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "no-group"
|
||||
type: http
|
||||
http:
|
||||
url: "http://example.com"
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.targets[0]!.group).toBe("default");
|
||||
});
|
||||
|
||||
test("非法 group 类型抛出错误", async () => {
|
||||
const configPath = join(tempDir, "bad-group.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "test"
|
||||
type: http
|
||||
group: 123
|
||||
http:
|
||||
url: "http://example.com"
|
||||
`,
|
||||
);
|
||||
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("group 字段必须为字符串");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ function createMockStore(targetNames: string[]) {
|
||||
interval_ms: 60000,
|
||||
timeout_ms: 5000,
|
||||
expect: null,
|
||||
grp: "default",
|
||||
}));
|
||||
},
|
||||
insertCheckResult(result: Record<string, unknown>) {
|
||||
@@ -32,6 +33,7 @@ function makeCommandTarget(name: string, overrides?: Partial<ResolvedCommandTarg
|
||||
return {
|
||||
type: "command",
|
||||
name,
|
||||
group: "default",
|
||||
command: {
|
||||
exec: "echo",
|
||||
args: ["hello"],
|
||||
@@ -175,6 +177,7 @@ describe("ProbeEngine", () => {
|
||||
const httpTarget: ResolvedHttpTarget = {
|
||||
type: "http",
|
||||
name: "http-test",
|
||||
group: "default",
|
||||
http: {
|
||||
url: `http://localhost:${httpServer.port}/`,
|
||||
method: "GET",
|
||||
|
||||
@@ -61,6 +61,7 @@ describe("runHttpCheck 集成", () => {
|
||||
return {
|
||||
type: "http" as const,
|
||||
name: "test-http",
|
||||
group: "default",
|
||||
http: {
|
||||
url: overrides.url ?? `${baseUrl}/ok`,
|
||||
method: overrides.method ?? "GET",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { tmpdir } from "node:os";
|
||||
const httpTarget: ResolvedTarget = {
|
||||
type: "http",
|
||||
name: "test-http",
|
||||
group: "default",
|
||||
http: {
|
||||
url: "https://example.com/health",
|
||||
method: "GET",
|
||||
@@ -22,6 +23,7 @@ const httpTarget: ResolvedTarget = {
|
||||
const commandTarget: ResolvedTarget = {
|
||||
type: "command",
|
||||
name: "test-cmd",
|
||||
group: "default",
|
||||
command: {
|
||||
exec: "ping",
|
||||
args: ["-c", "1", "localhost"],
|
||||
@@ -166,9 +168,9 @@ describe("ProbeStore", () => {
|
||||
failure,
|
||||
});
|
||||
|
||||
const history = store.getHistory(t1Id, 10);
|
||||
expect(history).toHaveLength(3);
|
||||
expect(history[0]!.timestamp).toBe("2025-01-01T00:01:00.000Z");
|
||||
const history = store.getHistory(t1Id, "2025-01-01T00:00:00.000Z", "2025-12-31T23:59:59.999Z", 1, 10);
|
||||
expect(history.items).toHaveLength(3);
|
||||
expect(history.items[0]!.timestamp).toBe("2025-01-01T00:01:00.000Z");
|
||||
|
||||
const latest = store.getLatestCheck(t1Id)!;
|
||||
expect(latest.success).toBe(0);
|
||||
@@ -195,8 +197,8 @@ describe("ProbeStore", () => {
|
||||
});
|
||||
}
|
||||
|
||||
const history = store.getHistory(t1Id);
|
||||
expect(history).toHaveLength(20);
|
||||
const history = store.getHistory(t1Id, "2025-01-01T00:00:00.000Z", "2025-12-31T23:59:59.999Z");
|
||||
expect(history.items).toHaveLength(20);
|
||||
});
|
||||
|
||||
test("getTargetStats 计算可用率和 duration", () => {
|
||||
@@ -207,8 +209,6 @@ describe("ProbeStore", () => {
|
||||
expect(stats.totalChecks).toBeGreaterThan(0);
|
||||
expect(stats.availability).toBeGreaterThanOrEqual(0);
|
||||
expect(stats.availability).toBeLessThanOrEqual(100);
|
||||
expect(stats.avgDurationMs).not.toBeNull();
|
||||
expect(typeof stats.avgDurationMs).toBe("number");
|
||||
});
|
||||
|
||||
test("无记录目标的 stats", () => {
|
||||
@@ -218,8 +218,6 @@ describe("ProbeStore", () => {
|
||||
const stats = store.getTargetStats(t2Id);
|
||||
expect(stats.totalChecks).toBe(0);
|
||||
expect(stats.availability).toBe(0);
|
||||
expect(stats.avgDurationMs).toBeNull();
|
||||
expect(stats.p99DurationMs).toBeNull();
|
||||
});
|
||||
|
||||
test("getSummary 返回总览统计", () => {
|
||||
@@ -227,14 +225,13 @@ describe("ProbeStore", () => {
|
||||
expect(summary.total).toBe(2);
|
||||
expect(summary.up + summary.down).toBe(2);
|
||||
expect(summary.lastCheckTime).not.toBeNull();
|
||||
expect(summary.avgDurationMs).not.toBeNull();
|
||||
});
|
||||
|
||||
test("getTrend 返回趋势数据", () => {
|
||||
const targets = store.getTargets();
|
||||
const t1Id = targets[0]!.id;
|
||||
|
||||
const trend = store.getTrend(t1Id, 24);
|
||||
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();
|
||||
@@ -244,15 +241,17 @@ describe("ProbeStore", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("getSparkline 返回 duration 数组", () => {
|
||||
test("getRecentSamples 返回最近采样数据", () => {
|
||||
const targets = store.getTargets();
|
||||
const t1Id = targets[0]!.id;
|
||||
|
||||
const sparkline = store.getSparkline(t1Id);
|
||||
expect(Array.isArray(sparkline)).toBe(true);
|
||||
expect(sparkline.length).toBeGreaterThan(0);
|
||||
for (const val of sparkline) {
|
||||
expect(typeof val).toBe("number");
|
||||
const samples = store.getRecentSamples(t1Id, 10);
|
||||
expect(Array.isArray(samples)).toBe(true);
|
||||
expect(samples.length).toBeGreaterThan(0);
|
||||
for (const sample of samples) {
|
||||
expect(typeof sample.timestamp).toBe("string");
|
||||
expect(typeof sample.success).toBe("number");
|
||||
expect(typeof sample.matched).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user