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

@@ -179,6 +179,10 @@ function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
const nameValue: unknown = raw["name"];
const name = isString(nameValue) ? nameValue : id;
if (isString(nameValue) && nameValue.trim() === "") {
issues.push(issue("invalid-value", `targets[${i}].name`, "name 不能为空白", name));
}
const type: unknown = raw["type"];
if (!isString(type)) {
issues.push(issue("required", `targets[${i}].type`, "缺少 type 字段", name));

View File

@@ -192,7 +192,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
group: target.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? t.id,
name: t.name ?? null,
timeoutMs: context.defaultTimeoutMs,
type: "cmd",
} satisfies ResolvedCommandTarget;

View File

@@ -33,7 +33,7 @@ export interface ResolvedCommandTarget extends ResolvedTargetBase {
expect?: CommandExpectConfig;
group: string;
intervalMs: number;
name: string;
name: null | string;
timeoutMs: number;
type: "cmd";
}

View File

@@ -185,7 +185,7 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
group: target.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? t.id,
name: t.name ?? null,
timeoutMs: context.defaultTimeoutMs,
type: "db",
} satisfies ResolvedDbTarget;

View File

@@ -21,7 +21,7 @@ export interface ResolvedDbTarget extends ResolvedTargetBase {
expect?: DbExpectConfig;
group: string;
intervalMs: number;
name: string;
name: null | string;
timeoutMs: number;
type: "db";
}

View File

@@ -126,7 +126,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
},
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? t.id,
name: t.name ?? null,
timeoutMs: context.defaultTimeoutMs,
type: "http",
} satisfies ResolvedHttpTarget;

View File

@@ -51,7 +51,7 @@ export interface ResolvedHttpTarget extends ResolvedTargetBase {
group: string;
http: ResolvedHttpConfig;
intervalMs: number;
name: string;
name: null | string;
timeoutMs: number;
type: "http";
}

View File

@@ -49,12 +49,12 @@ export function createProbeConfigSchema(checkers: CheckerDefinition[], external
export function createTargetSchema(checker: CheckerDefinition): TSchema {
const properties: Record<string, TSchema> = {
description: Type.Optional(Type.String({ maxLength: 500 })),
description: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 500 })])),
expect: Type.Optional(checker.schemas.expect),
group: Type.Optional(Type.String()),
id: Type.String({ maxLength: 30, minLength: 1 }),
interval: Type.Optional(durationSchema),
name: Type.Optional(Type.String({ maxLength: 30, minLength: 1 })),
name: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 30, minLength: 1 })])),
timeout: Type.Optional(durationSchema),
type: Type.Literal(checker.type),
};
@@ -69,11 +69,11 @@ function cloneSchema(schema: TSchema): Record<string, unknown> {
function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema {
return Type.Object(
{
description: Type.Optional(Type.String({ maxLength: 500 })),
description: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 500 })])),
group: Type.Optional(Type.String()),
id: Type.String({ maxLength: 30, minLength: 1 }),
interval: Type.Optional(durationSchema),
name: Type.Optional(Type.String({ maxLength: 30, minLength: 1 })),
name: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 30, minLength: 1 })])),
timeout: Type.Optional(durationSchema),
type: Type.Union(checkers.map((checker) => Type.Literal(checker.type)) as unknown as [TSchema, ...TSchema[]]),
},

View File

@@ -9,7 +9,7 @@ import { checkerRegistry } from "./runner";
const CREATE_TARGETS_TABLE = `
CREATE TABLE IF NOT EXISTS targets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
name TEXT,
description TEXT,
type TEXT NOT NULL,
target TEXT NOT NULL,
@@ -309,10 +309,7 @@ export class ProbeStore {
syncTargets(targets: ResolvedTargetBase[]): void {
if (this.closed) return;
const existingRows = this.db.query("SELECT id FROM targets").all() as Array<{
id: string;
name: string;
}>;
const existingRows = this.db.query("SELECT id FROM targets").all() as Array<{ id: string }>;
const existingIds = new Set(existingRows.map((r) => r.id));
const configIds = new Set(targets.map((t) => t.id));

View File

@@ -41,12 +41,12 @@ export interface ProbeConfig {
export interface RawTargetConfig {
[configKey: string]: unknown;
description?: string;
description?: null | string;
expect?: unknown;
group?: string;
id: string;
interval?: string;
name?: string;
name?: null | string;
timeout?: string;
type: string;
}
@@ -58,7 +58,7 @@ export interface ResolvedTargetBase {
group: string;
id: string;
intervalMs: number;
name: string;
name: null | string;
timeoutMs: number;
type: string;
}
@@ -86,7 +86,7 @@ export interface StoredTarget {
grp: string;
id: string;
interval_ms: number;
name: string;
name: null | string;
target: string;
timeout_ms: number;
type: string;

View File

@@ -104,7 +104,7 @@ export interface TargetStatus {
id: string;
interval: string;
latestCheck: CheckResult | null;
name: string;
name: null | string;
recentSamples: RecentSample[];
stats: TargetStats;
target: string;

View File

@@ -5,6 +5,7 @@ import { DateRangePicker, Drawer, RadioGroup, Space, Tabs, Tag, Typography } fro
import type { HistoryResponse, TargetMetricsResponse, TargetStatus } from "../../shared/api";
import { getTargetDisplayName } from "../utils/target";
import { subtractHours } from "../utils/time";
import { HistoryTab } from "./HistoryTab";
import { OverviewTab } from "./OverviewTab";
@@ -90,7 +91,7 @@ export function TargetDetailDrawer({
target ? (
<Space align="center" size={12}>
<StatusDot up={!!isUp} />
<Typography.Text strong>{target.name}</Typography.Text>
<Typography.Text strong>{getTargetDisplayName(target)}</Typography.Text>
<Tag size="small" theme="primary" variant="light-outline">
{target.type}
</Tag>

View File

@@ -6,6 +6,7 @@ import type { TargetStatus } from "../../shared/api";
import { StatusBar } from "../components/StatusBar";
import { StatusDot } from "../components/StatusDot";
import { getTargetDisplayName } from "../utils/target";
import { getAvailabilityProgressColor } from "./color-threshold";
import { statusFilter } from "./target-table-filters";
import { availabilitySorter, latencySorter } from "./target-table-sorters";
@@ -22,6 +23,7 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
width: 60,
},
{
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => getTargetDisplayName(row),
colKey: "name",
ellipsis: true,
title: "名称",

5
src/web/utils/target.ts Normal file
View File

@@ -0,0 +1,5 @@
import type { TargetStatus } from "../../shared/api";
export function getTargetDisplayName(target: TargetStatus): string {
return target.name ?? target.id;
}