1
0

refactor: 统一 target name/description 可空语义,前端展示 fallback 到 id

- schema: name/description 允许省略或显式 null,TypeBox Union([Null, String])
- 类型: RawTargetConfig/ResolvedTargetBase/子类型/StoredTarget/TargetStatus name 改为 string | null
- checker resolve: name: t.name ?? null,不再 fallback 到 id
- 语义校验: 拒绝空字符串和纯空白 name
- SQLite: targets.name 列改为可空 TEXT
- 前端: 新增 getTargetDisplayName(target) 展示 name ?? id
- 测试: 覆盖 name/description null 全场景,查找改为按 id
- 文档: 更新 README/DEVELOPMENT 和 6 个 openspec specs
This commit is contained in:
2026-05-17 20:12:39 +08:00
parent f7193e98ff
commit 31fd3a2a43
29 changed files with 382 additions and 119 deletions

View File

@@ -234,7 +234,7 @@ targets:
expect(cmd.cmd.maxOutputBytes).toBe(10485760);
});
test("name 缺省时 fallback 到 id", async () => {
test("name 缺省时保留为 null", async () => {
const configPath = join(tempDir, "name-fallback.yaml");
await writeFile(
configPath,
@@ -249,7 +249,105 @@ targets:
const config = await loadConfig(configPath);
const target = config.targets[0]!;
expect(target.id).toBe("api-health");
expect(target.name).toBe("api-health");
expect(target.name).toBeNull();
});
test("name 显式 null 保留为 null", async () => {
const configPath = join(tempDir, "name-explicit-null.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
name: null
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.name).toBeNull();
});
test("name YAML 空值保留为 null", async () => {
const configPath = join(tempDir, "name-yaml-null.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
name:
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.name).toBeNull();
});
test("name 为空字符串抛出错误", async () => {
const configPath = join(tempDir, "empty-name.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
name: ""
type: http
http:
url: "http://example.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("name 不能为空白");
});
test("name 仅包含空白字符抛出错误", async () => {
const configPath = join(tempDir, "whitespace-name.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
name: " "
type: http
http:
url: "http://example.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("name 不能为空白");
});
test("description 显式 null 保留为 null", async () => {
const configPath = join(tempDir, "description-null.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
description: null
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.description).toBeNull();
});
test("description YAML 空值保留为 null", async () => {
const configPath = join(tempDir, "description-yaml-null.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
description:
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.description).toBeNull();
});
test("name 支持变量替换且不要求唯一", async () => {

View File

@@ -209,7 +209,7 @@ describe("ProbeEngine", () => {
}),
);
const mockStore = createMockStore(targets.map((t) => t.name)) as unknown as ProbeStore;
const mockStore = createMockStore(targets.map((t) => t.name ?? t.id)) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, targets, 2);
const probeGroup = (

View File

@@ -24,10 +24,6 @@ beforeAll(() => {
ensureRegistered();
});
function targetId(store: ProbeStore, name: string): string {
return store.getTargets().find((target) => target.name === name)!.id;
}
const httpTarget: ResolvedHttpTarget = {
description: null,
expect: { maxDurationMs: 3000, status: [200] },
@@ -87,11 +83,11 @@ describe("ProbeStore", () => {
store.syncTargets([httpTarget, commandTarget]);
const targets = store.getTargets();
expect(targets).toHaveLength(2);
expect(targets.map((target) => target.name).sort()).toEqual(["test-cmd", "test-http"]);
expect(targets.map((target) => target.id).sort()).toEqual(["test-cmd", "test-http"]);
});
test("http target 字段正确", () => {
const t = store.getTargets().find((t) => t.name === "test-http")!;
const t = store.getTargets().find((t) => t.id === "test-http")!;
expect(t.type).toBe("http");
expect(t.target).toBe("https://example.com/health");
const config = JSON.parse(t.config) as {
@@ -114,7 +110,7 @@ describe("ProbeStore", () => {
});
test("cmd target 字段正确", () => {
const t = store.getTargets().find((t) => t.name === "test-cmd")!;
const t = store.getTargets().find((t) => t.id === "test-cmd")!;
expect(t.type).toBe("cmd");
expect(t.target).toBe("exec ping -c 1 localhost");
const config = JSON.parse(t.config) as { args: string[]; cwd: string; exec: string; maxOutputBytes: number };
@@ -133,7 +129,7 @@ describe("ProbeStore", () => {
http: { ...httpTarget.http, url: "https://example.com/v2" },
};
store.syncTargets([updated, commandTarget]);
const t = store.getTargets().find((t) => t.name === "test-http")!;
const t = store.getTargets().find((t) => t.id === "test-http")!;
expect(t.target).toBe("https://example.com/v2");
expect(store.getTargets()).toHaveLength(2);
});
@@ -151,7 +147,7 @@ describe("ProbeStore", () => {
});
test("getTargetById", () => {
const found = store.getTargetById(targetId(store, "test-http"));
const found = store.getTargetById("test-http");
expect(found).toBeDefined();
expect(found!.name).toBe("test-http");
});
@@ -162,14 +158,13 @@ describe("ProbeStore", () => {
test("写入 check result 并查询", () => {
store.syncTargets([httpTarget, commandTarget]);
const t1Id = targetId(store, "test-http");
store.insertCheckResult({
durationMs: 150.5,
failure: null,
matched: true,
statusDetail: "200 OK",
targetId: t1Id,
targetId: "test-http",
timestamp: "2025-01-01T00:00:00.000Z",
});
@@ -178,7 +173,7 @@ describe("ProbeStore", () => {
failure: null,
matched: true,
statusDetail: "200 OK",
targetId: t1Id,
targetId: "test-http",
timestamp: "2025-01-01T00:00:30.000Z",
});
@@ -196,15 +191,15 @@ describe("ProbeStore", () => {
failure,
matched: false,
statusDetail: null,
targetId: t1Id,
targetId: "test-http",
timestamp: "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);
const history = store.getHistory("test-http", "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)!;
const latest = store.getLatestCheck("test-http")!;
expect(latest.matched).toBe(0);
expect(latest.failure).not.toBeNull();
const parsedFailure = JSON.parse(latest.failure!) as CheckFailure;
@@ -214,27 +209,23 @@ describe("ProbeStore", () => {
});
test("getHistory 默认 limit=20", () => {
const t1Id = targetId(store, "test-http");
for (let i = 0; i < 25; i++) {
store.insertCheckResult({
durationMs: 100 + i,
failure: null,
matched: true,
statusDetail: "200 OK",
targetId: t1Id,
targetId: "test-http",
timestamp: `2025-01-01T01:${String(i).padStart(2, "0")}:00.000Z`,
});
}
const history = store.getHistory(t1Id, "2025-01-01T00:00:00.000Z", "2025-12-31T23:59:59.999Z");
const history = store.getHistory("test-http", "2025-01-01T00:00:00.000Z", "2025-12-31T23:59:59.999Z");
expect(history.items).toHaveLength(20);
});
test("getTargetWindowStats 按时间窗口计算基础计数", () => {
const t1Id = targetId(store, "test-http");
const stats = store.getTargetWindowStats(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
const stats = store.getTargetWindowStats("test-http", "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);
@@ -242,9 +233,7 @@ describe("ProbeStore", () => {
});
test("无记录目标的窗口 stats", () => {
const t2Id = targetId(store, "test-cmd");
const stats = store.getTargetWindowStats(t2Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
const stats = store.getTargetWindowStats("test-cmd", "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);
@@ -253,16 +242,14 @@ describe("ProbeStore", () => {
test("getLatestChecksMap 支持 Dashboard 组装当前状态", () => {
const latestChecksMap = store.getLatestChecksMap();
const latest = latestChecksMap.get(targetId(store, "test-http"));
const latest = latestChecksMap.get("test-http");
expect(latest).toBeDefined();
expect(latest!.timestamp).toBe("2025-01-01T01:24:00.000Z");
});
test("getTargetCheckpoints 返回窗口内升序检查点", () => {
const t1Id = targetId(store, "test-http");
const checkpoints = store.getTargetCheckpoints(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
const checkpoints = store.getTargetCheckpoints("test-http", "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" },
@@ -271,16 +258,12 @@ describe("ProbeStore", () => {
});
test("getTargetDurations 返回成功检查耗时升序数组", () => {
const t1Id = targetId(store, "test-http");
const durations = store.getTargetDurations(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
const durations = store.getTargetDurations("test-http", "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(durations).toEqual([150.5, 300]);
});
test("getRecentSamples 返回最近采样数据", () => {
const t1Id = targetId(store, "test-http");
const samples = store.getRecentSamples(t1Id, 10);
const samples = store.getRecentSamples("test-http", 10);
expect(Array.isArray(samples)).toBe(true);
expect(samples.length).toBeGreaterThan(0);
for (const sample of samples) {
@@ -306,9 +289,9 @@ describe("ProbeStore", () => {
};
sampleStore.syncTargets([httpA, httpB, httpEmpty]);
const targets = sampleStore.getTargets();
const targetAId = targets.find((t) => t.name === "sample-http-a")!.id;
const targetBId = targets.find((t) => t.name === "sample-http-b")!.id;
const emptyTargetId = targets.find((t) => t.name === "sample-http-empty")!.id;
const targetAId = targets.find((t) => t.id === "sample-http-a")!.id;
const targetBId = targets.find((t) => t.id === "sample-http-b")!.id;
const emptyTargetId = targets.find((t) => t.id === "sample-http-empty")!.id;
for (const [index, timestamp] of [
"2025-01-01T00:00:00.000Z",
@@ -455,19 +438,16 @@ describe("ProbeStore", () => {
});
test("getAllTargetWindowStats 返回所有 target 的窗口聚合统计", () => {
const t1Id = targetId(store, "test-http");
const t2Id = targetId(store, "test-cmd");
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);
const stats1 = stats.get("test-http");
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);
const stats2 = stats.get("test-cmd");
if (stats2) {
expect(stats2.totalChecks).toBe(0);
expect(stats2.availability).toBe(0);
@@ -548,8 +528,8 @@ describe("ProbeStore", () => {
};
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;
const targetAId = targets.find((target) => target.id === "incident-http-a")!.id;
const targetBId = targets.find((target) => target.id === "incident-http-b")!.id;
incidentStore.insertCheckResult({
durationMs: 100,
@@ -698,4 +678,12 @@ describe("ProbeStore", () => {
expect(t.description).toBe("新描述");
updateDescStore.close();
});
test("name 为 null 时持久化为 null", () => {
const nullNameStore = new ProbeStore(join(tempDir, "null-name.db"));
nullNameStore.syncTargets([{ ...httpTarget, id: "null-name", name: null }]);
const t = nullNameStore.getTargets()[0]!;
expect(t.name).toBeNull();
nullNameStore.close();
});
});

View File

@@ -121,4 +121,10 @@ describe("TargetDetailDrawer", () => {
const dragLine = wrapper.querySelector('[style*="col-resize"]');
expect(dragLine).not.toBeNull();
});
test("name 为 null 时标题显示 id", () => {
const nullNameTarget = { ...target, name: null };
render(<TargetDetailDrawer {...defaultProps} target={nullNameTarget} />);
expect(document.body.textContent).toContain("1");
});
});

View File

@@ -156,4 +156,28 @@ describe("createTargetTableColumns", () => {
expect(nameColumn.sorter).toBeUndefined();
expect(nameColumn.sortType).toBeUndefined();
});
test("名称列 name 为 null 时显示 id", () => {
const nameColumn = getColumn(createTargetTableColumns(["http"]), "name");
const renderCell = nameColumn.cell as (params: PrimaryTableCellParams<TargetStatus>) => string;
const result = renderCell({
col: nameColumn,
colIndex: 1,
row: makeTarget({ id: "my-api", name: null }),
rowIndex: 0,
});
expect(result).toBe("my-api");
});
test("名称列 name 有值时显示 name", () => {
const nameColumn = getColumn(createTargetTableColumns(["http"]), "name");
const renderCell = nameColumn.cell as (params: PrimaryTableCellParams<TargetStatus>) => string;
const result = renderCell({
col: nameColumn,
colIndex: 1,
row: makeTarget({ id: "my-api", name: "我的 API" }),
rowIndex: 0,
});
expect(result).toBe("我的 API");
});
});

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import type { TargetStatus } from "../../../src/shared/api";
import { getTargetDisplayName } from "../../../src/web/utils/target";
function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
return {
currentStreak: null,
description: null,
group: "default",
id: "api-health",
interval: "30s",
latestCheck: null,
name: null,
recentSamples: [],
stats: { availability: 100, downChecks: 0, totalChecks: 0, upChecks: 0 },
target: "https://example.com",
type: "http",
...overrides,
};
}
describe("getTargetDisplayName", () => {
test("name 为 null 时返回 id", () => {
expect(getTargetDisplayName(makeTarget())).toBe("api-health");
});
test("name 有值时返回 name", () => {
expect(getTargetDisplayName(makeTarget({ name: "我的 API" }))).toBe("我的 API");
});
});