# DiAL 开发文档 本文档面向 DiAL 项目的开发者,介绍项目结构、构建流程、测试、代码规范等内容。 用户使用说明请参阅 [README.md](README.md)。 ## 目录 - [项目结构](#项目结构) - [一、后端开发指引](#一后端开发指引) - [二、前端开发指引](#二前端开发指引) - [三、项目运行、集成与打包](#三项目运行集成与打包) - [代码质量](#代码质量) - [已知限制](#已知限制) --- ## 项目结构 ```text src/ server/ app.ts Bun HTTP 路由入口(路由分发 + API 汇聚) config.ts CLI 参数解析 dev.ts 生产/开发启动入口 server.ts HTTP server 启动工厂 helpers.ts 共享响应格式化工具(jsonResponse、createHeaders 等) middleware.ts API 参数校验中间件(guardGetHead、validateTargetId 等) static.ts 静态资源服务与 SPA fallback routes/ API 路由 handler(按端点拆分) health.ts GET /health summary.ts GET /api/summary targets.ts GET /api/targets history.ts GET /api/targets/:id/history trend.ts GET /api/targets/:id/trend checker/ types.ts 类型定义 config-loader.ts YAML 配置解析、契约校验、语义校验与运行期解析 config-contract/ TypeBox + Ajv 配置契约、schema fragments、issue 渲染和 schema 导出入口 store.ts SQLite 数据存储 engine.ts 调度引擎(按 interval 分组的 es-toolkit groupBy + Semaphore 并发控制) size.ts 大小单位解析 runner/ Checker 统一抽象与注册机制 types.ts CheckerDefinition、CheckerContext、ResolveContext registry.ts CheckerRegistry 注册中心 index.ts 注册入口(registerCheckers) shared/ 共享 expect 断言和启动期 validator(跨 checker 复用) failure.ts 失败信息类型 operator.ts 操作符系统(applyOperator、evaluateJsonPath) duration.ts 耗时断言 text.ts 文本规则断言 body.ts Body 规则断言(JSONPath/XPath/CSS/contains/regex) validate.ts 共享 operator/text/body 语义校验 http/ HTTP Checker 子包 contract.ts HTTP defaults、target.http、expect TypeBox 契约 runner.ts HttpChecker(resolve/execute/serialize) expect.ts HTTP 专用断言(status/headers) validate.ts HTTP 专属启动期语义校验 command/ Command Checker 子包 contract.ts Command defaults、target.command、expect TypeBox 契约 runner.ts CommandChecker(resolve/execute/serialize) expect.ts Command 专用断言(exitCode) validate.ts Command 专属启动期语义校验 shared/ api.ts 前后端共享 TypeScript 类型 web/ Vite + React 前端 Dashboard components/ UI 组件(表格、分组、Drawer、状态条等) constants/ 常量定义(列配置、类型映射、排序/筛选/颜色阈值函数) hooks/ TanStack Query 数据层(useTargetDetail 集成轮询/条件查询) utils/ 前端工具函数 scripts/ 开发、构建、schema 生成和 smoke test 脚本 tests/ Bun test 测试 openspec/ OpenSpec 变更与规格文档 probe-config.schema.json 用户配置 JSON Schema 导出物 ``` ## 前后端边界 前端只通过 HTTP 调用后端,API 路径为 `/api/*`。共享类型放在 `src/shared`,前端不得 import `src/server` 的运行时实现。 --- ## 一、后端开发指引 ### 1.1 架构概览 ``` 启动流程: dev.ts → readRuntimeConfig(cli args) → loadConfig(yaml) → ProbeStore(db) → ProbeEngine(store, targets) → startServer(store) 运行时: 定时器(tick) → ProbeEngine.probeGroup() → HTTP: fetcher.ts / Command: command-runner.ts → runner/*/expect.ts 校验 → store.insertCheckResult() HTTP 请求: Request → app.ts(路由分发) → routes/*.ts(handler) → middleware.ts(参数校验) → helpers.ts(响应格式化) → Response ``` ### 1.2 库使用优先级 后端代码开发遵循严格的库选择顺序: | 优先级 | 来源 | 典型用途 | | ------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | Bun 内置 API | `Bun.serve`、`bun:sqlite`、`Bun.spawn`、`Bun.file`、`Bun.YAML` | | 2 | es-toolkit | 类型判断(`isPlainObject`/`isNil`/`isEmptyObject`)、深度比较(`isEqual`)、错误判断(`isError`)、并发控制(`Semaphore`)、集合操作(`groupBy`) | | 3 | 标准 Web API | `Object.fromEntries`、`Headers`、`fetch`、`AbortController` | | 4 | 主流三方库 | cheerio(HTML 解析)、xpath + @xmldom/xmldom(XML 解析) | | 5 | 自行实现 | 仅在以上都无法满足时(如 `parseDuration`、`parseSize`、`evaluateJsonPath` 等专项逻辑) | **原则**:新增依赖前先检查上述每一层级是否已有可用方案。禁止随意引入新依赖。 ### 1.3 API 路由开发 路由文件位于 `src/server/routes/`,每个端点一个文件。handler 函数签名统一为: ```typescript export function handleXxx(params, store: ProbeStore, method: string, mode: RuntimeMode): Response; ``` **请求处理流程**: 1. `app.ts` 的 `createFetchHandler` 作为总入口,根据 URL pattern 匹配路由 2. API 路由统一经过 `guardGetHead` 做方法检查(仅允许 GET/HEAD) 3. 各 handler 内部通过 `middleware.ts` 提供的 `validateTargetId`、`validateTimeRange`、`validatePagination` 做参数校验 4. 校验函数返回 `Response` 表示校验失败(直接返回),返回数据对象表示通过 5. 业务逻辑通过 `store` 查询数据,用 `helpers.ts` 的 `jsonResponse`、`mapCheckResult`、`formatDuration` 等格式化输出 **新增路由步骤**: 1. 在 `src/server/routes/` 下创建 `.ts` 2. 实现 handler 函数并 export 3. 在 `app.ts` 的 `createFetchHandler` 中注册路径匹配和调用 4. 在 `tests/server/app.test.ts` 中添加对应测试 ### 1.4 共享工具 - **`helpers.ts`**:跨路由共用的响应工具函数(`jsonResponse`、`createHeaders`、`createApiError`、`mapCheckResult`、`formatDuration`、`createHealthResponse`) - **`middleware.ts`**:API 参数校验函数(`guardGetHead`、`validateTargetId`、`validateTimeRange`、`validatePagination`) - **`static.ts`**:生产模式下的静态资源服务与 SPA fallback ### 1.5 类型定义规范 - **共享类型**以 `src/shared/api.ts` 为唯一源头,前后端共同引用 - 前端不得 `import src/server/` 下的任何文件 - **严格联合类型**优先于宽类型:如 `phase: "status" | "duration" | ...` 而非 `phase: string` - **后端内部扩展**:`checker/types.ts` 中 `CheckResult` 通过 `extends` 共享版本的 `ApiCheckResult` 增加 `targetName` 等内部字段 - 存储层类型(`StoredTarget`、`StoredCheckResult`)独立定义,与 API 类型分离 - 配置类型按生命周期区分:YAML 解析后的 `RawProbeConfig`、已通过契约与语义校验的 `ValidatedProbeConfig`、运行期使用的 `ResolvedConfig`/`ResolvedTarget` ### 1.6 配置契约与校验 配置加载流程固定为:`unknown -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig`。 `config-loader.ts` 只负责 YAML 解析、契约调度、公共语义校验和最终运行期解析;checker 专属规则必须下沉到对应 checker 的 `contract.ts` 和 `validate.ts`。 契约层使用 `src/server/checker/config-contract/` 中的 TypeBox fragments 生成 JSON Schema,并用 Ajv 执行启动期校验。Ajv 必须保持严格拒绝模式:`allErrors: true`、不启用类型强制转换、不注入默认值、不自动删除未知字段。 默认对象策略是 `additionalProperties: false`。只有明确声明的动态键值表可以开放任意键名,例如 `http.headers`、`defaults.http.headers`、`expect.headers`、`command.env`。 契约校验和语义 validator 都必须返回 `ConfigValidationIssue[]`,不要在 validator 内直接拼接最终用户错误字符串。最终错误由 `formatConfigIssues()` 统一渲染,错误路径需要尽量包含 `targetName` 或 `defaults`/root 路径。 新增或修改配置字段时必须同步更新:TypeBox schema fragments、`probe-config.schema.json` 导出、对应语义 validator、单元测试和 README/DEVELOPMENT 用户文档。提交前运行 `bun run schema:check` 确认导出 schema 与 fragments 一致。 ### 1.7 开发新 Checker Checker 是本项目的核心扩展单元。得益于插件式注册架构,完成一个新 checker 并注册后,**配置契约组装、引擎调度、数据存储、API 层会自动走 registry 委托链路**,无需在这些中间层添加新的 type 分支。 当前 checker 执行链路已经注册化,但新增 checker 仍需更新中央类型定义、默认注册入口、前端展示常量、配置示例、用户/开发文档和测试。下文清单以这些必要更新为准。 以下以新增 `tcp` 类型 checker 为例,说明完整的开发步骤。 #### 1.7.1 架构总览 ``` checkerRegistry(单例) │ ├── registerCheckers() ← 注册入口,所有 checker 在此集中注册 │ ├── HttpChecker │ ├── CommandChecker │ └── TcpChecker ← 新增 │ ├── config-contract/schema.ts ← 自动遍历 registry 生成全量 JSON Schema ├── config-loader.ts ← 自动遍历 registry 调用 validate() + resolve() ├── engine.ts ← 自动按 target.type 分发到 execute() └── store.ts ← 自动按 target.type 分发到 serialize() ``` 每个 checker 是 `src/server/checker/runner//` 下的自包含模块,包含四个文件: | 文件 | 职责 | | ------------- | ------------------------------------------------------------------------------------- | | `contract.ts` | TypeBox 契约 schema(config / defaults / expect 三部分) | | `validate.ts` | 启动期语义校验(JSON Schema 无法表达的规则) | | `runner.ts` | Checker 类:resolve(默认值合并 + 解析)、execute(执行检查)、serialize(DB 持久化) | | `expect.ts` | Checker 专用断言函数 | #### 1.7.2 步骤一:定义类型 在 `src/server/checker/types.ts` 中添加 checker 专属类型接口,并更新联合类型: ```typescript // 1. 添加 TargetConfig(YAML 中 target.tcp 字段的原始类型) export interface TcpTargetConfig { host: string; port: number; timeout?: number; } // 2. 添加 ExpectConfig 扩展(如果 checker 有专属 expect 字段) export interface TcpExpectConfig { connected?: boolean; } // 3. 添加 DefaultsConfig(defaults.tcp 字段) export interface TcpDefaultsConfig { timeout?: number; } // 4. 添加 Resolved 变体(运行期已合并默认值、已解析路径) export interface ResolvedTcpTarget { type: "tcp"; name: string; group: string; intervalMs: number; timeoutMs: number; tcp: { host: string; port: number; connectTimeout: number; }; expect?: TcpExpectConfig; } ``` 然后更新以下联合类型: ```typescript // TargetConfig 联合 — 新增一个分支 export type TargetConfig = BaseTargetConfig & ( | { http: HttpTargetConfig; type: "http" } | { command: CommandTargetConfig; type: "command" } | { tcp: TcpTargetConfig; type: "tcp" } // ← 新增 ); // ResolvedTarget 联合 export type ResolvedTarget = ResolvedHttpTarget | ResolvedCommandTarget | ResolvedTcpTarget; // ← 新增 // DefaultsConfig — 新增可选字段 export interface DefaultsConfig { interval?: string; timeout?: string; http?: HttpDefaultsConfig; command?: CommandDefaultsConfig; tcp?: TcpDefaultsConfig; // ← 新增 } // TargetType 联合 export type TargetType = "command" | "http" | "tcp"; // ← 新增 // ExpectConfig — 如有专属字段则扩展 export interface ExpectConfig { // ... 现有字段 connected?: boolean; // ← TcpChecker 专属(如果复用公共字段则不需要) } ``` #### 1.7.3 步骤二:创建 TypeBox 契约 Schema 在 `src/server/checker/runner/tcp/contract.ts` 中定义三部分 schema: ```typescript import { Type } from "@sinclair/typebox"; import type { CheckerSchemas } from "../types"; import { sizeSchema } from "../../config-contract/fragments"; // 复用共享 fragments export const tcpCheckerSchemas: CheckerSchemas = { // target.tcp 字段的 schema config: Type.Object( { host: Type.String(), port: Type.Integer({ maximum: 65535, minimum: 0 }), connectTimeout: Type.Optional(Type.Integer({ minimum: 100 })), }, { additionalProperties: false }, ), // defaults.tcp 字段的 schema defaults: Type.Object( { connectTimeout: Type.Optional(Type.Integer({ minimum: 100 })), }, { additionalProperties: false }, ), // target.expect 中 tcp 专属字段的 schema(如果无专属字段则用 Type.Object({})) expect: Type.Object( { connected: Type.Optional(Type.Boolean()), }, { additionalProperties: false }, ), }; ``` **可复用的共享 fragments**(来自 `config-contract/fragments.ts`): | Fragment | 用途 | | ---------------------------- | ---------------------------------------------- | | `durationSchema` | 时长字符串(`"30s"`、`"5m"`、`"500ms"`) | | `httpMethodSchema` | HTTP 方法枚举 | | `sizeSchema` | 大小单位(字符串如 `"10MB"` 或整数) | | `statusCodePatternSchema` | 状态码(`100`-`599` 或 `"2xx"`) | | `stringMapSchema` | `Record`(用于 headers / env) | | `createBodyRulesSchema()` | body 规则数组(json/css/xpath/contains/regex) | | `createTextRulesSchema()` | 文本规则数组(stdout/stderr) | | `createPureOperatorSchema()` | 操作符对象 | | `operatorProperties()` | 所有操作符字段的 Record | **注意**:默认对象策略为 `additionalProperties: false`。只有明确的动态键值表(如 `http.headers`、`command.env`)可以开放任意键名。 #### 1.7.4 步骤三:实现语义校验 在 `src/server/checker/runner/tcp/validate.ts` 中实现 JSON Schema 无法表达的语义规则: ```typescript import type { ConfigValidationIssue } from "../../config-contract/issues"; import type { CheckerValidationInput } from "../types"; import { issue } from "../../config-contract/issues"; export function validateTcpConfig(input: CheckerValidationInput): ConfigValidationIssue[] { const issues: ConfigValidationIssue[] = []; // 1. 校验 defaults.tcp(如需要) const defaults = input.defaults.tcp; if (defaults) { // 语义校验示例:connectTimeout 不能超过某个上限 } // 2. 遍历所有 tcp 类型的 target for (const target of input.targets) { if (target.type !== "tcp") continue; const name = target.name; // 校验 target.tcp 中的语义规则 const tcp = (target as any).tcp; if (tcp) { // 示例:host 不能为空字符串 if (typeof tcp.host === "string" && tcp.host.trim() === "") { issues.push(issue("invalid-value", "tcp.host", "host 不能为空字符串", name)); } } // 校验 expect(如有公共部分可使用 shared/validate.ts 的工具函数) // validateBodyRules、validateTextRules、validateOperatorObject 等 } return issues; } ``` **共享校验工具**(`runner/shared/validate.ts`): | 函数 | 用途 | | --------------------------------------------------------- | --------------------------------- | | `validateBodyRules(body, path, targetName)` | 校验 body 规则数组 | | `validateTextRules(rules, path, targetName)` | 校验文本规则数组(stdout/stderr) | | `validateOperatorObject(ops, path, targetName, options?)` | 校验操作符对象 | | `validateJsonPath(path, rulePath, targetName)` | 校验 JSONPath 格式 | #### 1.7.5 步骤四:实现 Checker 类 在 `src/server/checker/runner/tcp/runner.ts` 中实现 `CheckerDefinition` 接口的全部成员: ```typescript import type { CheckResult, ResolvedTarget, TargetConfig } from "../../types"; import type { Checker, CheckerContext, CheckerValidationInput, ResolveContext } from "../types"; import { tcpCheckerSchemas } from "./contract"; import { validateTcpConfig } from "./validate"; export class TcpChecker implements Checker { readonly configKey = "tcp"; // YAML 中 target.tcp / defaults.tcp 的键名 readonly type = "tcp"; // target.type 的判别值 readonly schemas = tcpCheckerSchemas; // 启动期语义校验入口 validate(input: CheckerValidationInput): ConfigValidationIssue[] { return validateTcpConfig(input); } // 将原始配置解析为运行期配置(合并默认值、解析路径和单位) resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget { const t = target as TargetConfig & { tcp: TcpTargetConfig; type: "tcp" }; const defaults = context.defaults.tcp; return { expect: target.expect, group: target.group ?? "default", intervalMs: context.defaultIntervalMs, name: t.name, tcp: { connectTimeout: t.tcp.connectTimeout ?? defaults?.connectTimeout ?? 3000, host: t.tcp.host, port: t.tcp.port, }, timeoutMs: context.defaultTimeoutMs, type: "tcp", } satisfies ResolvedTcpTarget; } // 执行实际检查,评估 expect,返回 CheckResult async execute(target: ResolvedTarget, ctx: CheckerContext): Promise { const t = target as ResolvedTcpTarget; const timestamp = new Date().toISOString(); const start = performance.now(); try { // 执行检查逻辑(如 TCP 连接) // ... // 评估 expect 规则 // 首个失败即停止,返回 failure const durationMs = Math.round(performance.now() - start); return { durationMs, failure: null, matched: true, statusDetail: "TCP connected", targetName: t.name, timestamp, }; } catch (error) { const durationMs = Math.round(performance.now() - start); return { durationMs, failure: errorFailure("connection", "connection", isError(error) ? error.message : String(error)), matched: false, statusDetail: null, targetName: t.name, timestamp, }; } } // 序列化为 DB 存储格式 serialize(target: ResolvedTarget): { config: string; target: string } { const t = target as ResolvedTcpTarget; return { config: JSON.stringify({ host: t.tcp.host, port: t.tcp.port, connectTimeout: t.tcp.connectTimeout }), target: `${t.tcp.host}:${t.tcp.port}`, }; } } ``` **`resolve()` 规范**: - 只做默认值合并、路径解析、单位转换,**不执行校验** - 返回 `satisfies ResolvedXxxTarget` 确保类型正确 - 通过 `context.defaults[this.configKey]` 访问 checker 专属默认值 **`execute()` 规范**: - 始终记录 `timestamp`(ISO 字符串)和 `start = performance.now()` - 通过 `ctx.signal`(`AbortSignal`)支持超时取消 - 首个 expect 失败即停止,返回带 `failure` 的结果 - 成功时 `failure: null, matched: true` - 异常时使用 `errorFailure(phase, path, message)` 构造 failure - 不匹配时使用 `mismatchFailure(phase, path, expected, actual, message)` 构造 failure **可用的共享断言工具**(`runner/shared/`): | 模块 | 函数 | 用途 | | ------------- | ----------------------------------------------------- | ---------------------- | | `failure.ts` | `errorFailure(phase, path, msg)` | 构造错误类型 failure | | `failure.ts` | `mismatchFailure(phase, path, expected, actual, msg)` | 构造不匹配类型 failure | | `duration.ts` | `checkDuration(ms, maxMs?)` | 耗时断言 | | `body.ts` | `checkBodyExpect(body, rules)` | Body 规则断言 | | `text.ts` | `checkTextRules(text, rules, phase)` | 文本规则断言 | | `operator.ts` | `applyOperator(actual, operator)` | 执行单个操作符比较 | | `operator.ts` | `evaluateJsonPath(json, path)` | JSONPath 提取 | #### 1.7.6 步骤五:注册 Checker 在 `src/server/checker/runner/index.ts` 中注册: ```typescript import { TcpChecker } from "./tcp/runner"; // ← 新增导入 export function registerCheckers(registry = checkerRegistry): void { registry.register(new HttpChecker()); registry.register(new CommandChecker()); registry.register(new TcpChecker()); // ← 新增注册 } ``` 注册后,以下管线会通过 registry 自动委托,**无需新增 type 分支**: | 模块 | 自动行为 | | ----------------------------- | ------------------------------------------------------------------------ | | `config-contract/schema.ts` | 遍历 registry 生成全量 JSON Schema(defaults.tcp + target.tcp + expect) | | `config-contract/validate.ts` | 按注册 checker 构建 Ajv 校验,自动识别 `type: tcp` | | `config-loader.ts` | 遍历 registry 调用每个 checker 的 `validate()` + `resolve()` | | `engine.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `execute()` | | `store.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `serialize()` | 注意:自动适配指上述中间层不需要新增 `switch/case` 或类型分支;开发者仍需按后续步骤更新类型、注册、前端展示、示例、文档和测试。 #### 1.7.7 步骤六:更新前端展示 | 文件 | 修改内容 | | ------------------------------------------- | ------------------------------------------------------------ | | `src/web/constants/target-type-display.ts` | 在 `TARGET_TYPE_DISPLAY` 中添加 `"tcp": "TCP"` | | `src/web/constants/target-table-filters.ts` | 在 `typeFilter.list` 中添加 `{ label: "TCP", value: "tcp" }` | #### 1.7.8 步骤七:编写测试 测试文件放在 `tests/server/checker/runner/tcp/` 下,镜像源文件结构。必须覆盖: | 测试类别 | 覆盖内容 | 参考 | | ---------------- | ------------------------------------------ | ---------------------------------------------------------- | | **契约测试** | TypeBox schema 与 JSON Schema 导出一致性 | `config-contract/validate.test.ts` | | **语义校验测试** | `validateTcpConfig()` 各种合法/非法输入 | `http/validate.test.ts`(通过 `runner.test.ts` 间接测试) | | **resolve 测试** | 默认值合并、路径解析、单位转换 | `http/runner.test.ts` 的 `HttpChecker.resolve` describe 块 | | **execute 测试** | 成功/失败/超时/expect 各种规则组合 | `http/runner.test.ts` 的集成测试 | | **注册测试** | fresh registry 不污染全局、多 checker 注册 | `registry.test.ts` | | **配置加载测试** | 含新 checker 的 YAML 完整加载流程 | `config-loader.test.ts` | #### 1.7.9 步骤八:更新文档和 Schema | 操作 | 命令/文件 | | --------------------------------- | -------------------------------------------- | | 重新生成 JSON Schema 导出 | `bun run schema` | | 检查导出 schema 与 fragments 一致 | `bun run schema:check` | | 更新配置示例 | `probes.example.yaml` 中添加新类型示例 | | 更新用户文档 | `README.md` 中的配置格式说明 | | 更新项目结构 | `DEVELOPMENT.md` 项目结构中的 runner/ 目录树 | #### 1.7.10 完整检查清单 ``` □ src/server/checker/types.ts — 新增类型接口 + 更新联合类型 □ src/server/checker/runner/tcp/contract.ts — TypeBox schemas □ src/server/checker/runner/tcp/validate.ts — 语义校验 □ src/server/checker/runner/tcp/runner.ts — Checker 类 □ src/server/checker/runner/tcp/expect.ts — 专用断言(如需要) □ src/server/checker/runner/index.ts — 注册 □ src/web/constants/target-type-display.ts — 前端类型标签 □ src/web/constants/target-table-filters.ts — 前端类型筛选 □ tests/ — 契约 + 校验 + resolve + execute + 注册 测试 □ probes.example.yaml — 配置示例 □ bun run schema + bun run schema:check — Schema 导出同步 □ bun run check — 全量质量检查通过 □ bun run verify — 完整验证(含 build + smoke test) □ README.md — 用户文档 □ DEVELOPMENT.md — 项目结构目录树 ``` ### 1.8 数据存储规范 基于 `bun:sqlite`,WAL 模式运行,数据库文件位于配置的 `dataDir` 下。 **Statement 使用规范**: | 场景 | 方式 | 原因 | | -------------- | -------------------------------------- | ---------------------------------------- | | 单次读/写 | `this.db.query(sql).get()/all()/run()` | bun:sqlite 内置 statement 缓存,自动复用 | | 事务内多次复用 | `this.db.prepare(sql)` 缓存为局部变量 | 事务闭包中需要持有引用 | **查询优化**: - 避免 N+1 查询:批量场景优先用单次 SQL 聚合(GROUP BY、子查询 JOIN)+ 内存组装 - 新增批量查询方法时必须编写对应单元测试 - `getSummary()` 和 `GET /api/targets` 的响应组装已通过 `getLatestChecksMap` + `getAllTargetStats` 实现批量查询 **Schema**: - `targets` 表:name(UNIQUE)、type、target(展示摘要)、config(JSON)、interval_ms、timeout_ms、expect(JSON)、grp - `check_results` 表:target_id(FK CASCADE)、timestamp、matched(0/1)、duration_ms、status_detail、failure(JSON) - 复合索引:`(target_id, timestamp)` ### 1.9 拨测引擎 - **调度**:`ProbeEngine` 用 `es-toolkit/groupBy` 按 interval 分组,每组独立 `setInterval` 定时触发 - **并发控制**:`es-toolkit/Semaphore` 限制全局最大并发数(`maxConcurrentChecks`),`acquire()` 阻塞等待 - **Runner 选择**:`engine.runCheck()` 通过 `checkerRegistry.get(target.type)` 获取 checker,并调用 `checker.execute(target, { signal })` - **超时控制**:`ProbeEngine` 为每次检查创建 `AbortController` 并按 `target.timeoutMs` 触发 abort;checker 必须使用 `CheckerContext.signal` 感知超时,HTTP 将 signal 传给 `fetch()`,Command 在 signal abort 时 `proc.kill()` - **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLite,engine 通过 `targetNameToId` 缓存 name→id 映射 - **生命周期**:`start()`/`stop()` 管理定时器,`stop()` 清理所有 `setInterval` ### 1.10 expect 断言系统 两层模型:**观测值收集** → **规则校验**。 **HTTP 校验流程**: ``` HttpChecker.execute → 收集观测(statusCode/headers) → status → headers → (early duration) → body(按需) → (final duration) → 首个失败即停止,返回 CheckFailure ``` HTTP checker 的 `durationMs` 覆盖完整执行(含重定向、响应体读取、解码和 expect 校验)。status 或 headers 失败时不读取 body;进入 body 前若已超过 `maxDurationMs`,直接返回 duration failure。 **Command 校验流程**: ``` CommandChecker.execute → 收集观测(exitCode/stdout/stderr/durationMs) → exitCode → duration → stdout → stderr → 首个失败即停止 ``` **Body 规则类型**: - `contains`:文本包含匹配 - `regex`:正则表达式匹配(注意:body 正则字段为 `regex`,不是 `match`) - `json`:JSONPath 提取 + 操作符比较(使用 `es-toolkit/isPlainObject` 区分纯值和操作符) - `css`:cheerio CSS 选择器 + 操作符比较 - `xpath`:XPath 节点提取 + 操作符比较 **操作符**:`equals`(深度比较,`es-toolkit/isEqual`)、`contains`、`match`(正则)、`empty`(`isNil`+`isEmptyObject`)、`exists`、`gte`/`lte`/`gt`/`lt` ### 1.11 错误模式 - **API 错误**:`{ error: "描述", status: }`,状态码 400/404/405/503 - **CheckFailure**:`{ kind: "error"|"mismatch", phase, path, expected?, actual?, message }` - **错误处理**:expect 校验失败记录首个失败原因;网络/超时/进程崩溃统一为 `kind:"error"`,请求/TLS/timeout 错误归属 `phase:"request"`,body 超限/解码/解析错误归属 `phase:"body"` - **日志**:解析失败等非致命异常用 `console.warn`,启动失败用 `console.error` + `process.exit(1)` ### 1.12 测试规范 - 测试文件与源文件对应:`tests/server/checker/runner/shared/body.test.ts` ↔ `src/server/checker/runner/shared/body.ts` - 使用 `bun:test` 框架(`describe`/`test`/`expect`),测试数据库用临时目录 + `tmpdir()` - 新增 store 方法必须编写单元测试;新增 API 端点必须在 `app.test.ts` 中添加集成测试 - 测试后清理:`afterAll` 中 `store.close()` + `rm(tempDir, { recursive: true })` --- ## 二、前端开发指引 ### 2.1 技术栈概览 | 层面 | 技术 | 用途 | | ------ | ----------------------------------- | ---------------------------- | | 框架 | React 19 | UI 组件开发 | | 构建 | Vite 8 | 开发服务与生产构建 | | 语言 | TypeScript 6 | 类型安全 | | UI 库 | TDesign React + tdesign-icons-react | UI 组件与图标 | | 数据层 | TanStack Query (React Query) | 服务端状态管理与自动轮询 | | 图表 | Recharts | 拨测趋势折线图与状态环状图 | | 路由 | 无(单页面 Dashboard) | 仅需 Drawer/Tab 做页面内导航 | **不引入的依赖**:React Router(单页面场景不需要)、状态管理库(TanStack Query 即服务端状态层,组件内用 `useState` 足够) ### 2.2 组件树与数据流 ``` main.tsx └── QueryClientProvider(TanStack Query 全局挂载) └── App(根组件) ├── SummaryCards(总览统计卡片) │ └── useSummary() ─── GET /api/summary(8s 轮询) └── TargetBoard(目标列表) ├── useTargets() ─── GET /api/targets(8s 轮询) └── TargetGroup[](按 group 字段分组) └── PrimaryTable ← TARGET_TABLE_COLUMNS(列定义:排序/筛选/渲染) └── TargetDetailDrawer(目标详情抽屉) └── useTargetDetail() ── 按需发起 trend + history 查询 ├── Tab: 概览 → Statistic + TrendChart + StatusDonut + Descriptions └── Tab: 记录 → PrimaryTable(分页历史记录) ``` **数据层架构**: ``` hooks/useTargetDetail.ts(唯一的数据层入口) ├── queryKeys(结构化 query key,确保缓存粒度精确) ├── useSummary() → /api/summary(8s 自动轮询) ├── useTargets() → /api/targets(8s 自动轮询) └── useTargetDetail()(组合 hook,管理 Drawer 全部状态) ├── 内部复用 useTargets() 的缓存来查找 selectedTarget ├── useQuery(/api/targets/:id/trend)(条件查询:enabled 仅当 Drawer 打开且时间范围有效) └── useQuery(/api/targets/:id/history)(条件查询:含分页) ``` ### 2.3 TanStack Query 数据层 #### Query Key 规范 ```typescript const queryKeys = { summary: () => ["summary"] as const, targets: () => ["targets"] as const, trend: (targetId: number, from: string, to: string) => ["trend", targetId, from, to] as const, history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const, }; ``` - Key 使用 **structured array**(非字符串),以便精确匹配和按 prefix 失效 - 使用 `as const` 保持字面量类型 - 排序:scope → id → 参数(粒度从粗到细) #### 查询配置规范 ```typescript // 全局面板级查询(需要持续刷新) useQuery({ queryKey: queryKeys.summary(), queryFn: () => fetchJson("/api/summary"), refetchInterval: 8000, // 自动轮询间隔 refetchIntervalInBackground: false, // 切后台不轮询 }); // 详情级查询(按需加载) useQuery({ queryKey: selectedTargetId ? queryKeys.trend(id, from, to) : ["trend", "disabled"], queryFn: () => fetchJson(`/api/targets/${id}/trend?...`), enabled: selectedTargetId !== null && !!timeFrom && !!timeTo, // 条件查询 }); ``` #### fetch 封装 ```typescript async function fetchJson(url: string): Promise { const response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json() as Promise; } ``` - 统一使用 `fetch`(不引入 axios),与后端共享 Web API 生态 - 错误抛异常,由 TanStack Query 的 `error` 状态承接 #### QueryClient 全局配置 ```typescript new QueryClient({ defaultOptions: { queries: { retry: 1, // 失败重试 1 次 refetchOnWindowFocus: true, // 窗口聚焦时刷新 staleTime: 5000, // 5s 内视为 fresh,避免重复请求 }, }, }); ``` ### 2.4 组件开发规范 #### 文件命名与导入 - 每个 React 组件一个 `.tsx` 文件,文件名使用 PascalCase(如 `StatusDot.tsx`) - 组件 props 定义为 `interface XxxProps`,紧邻组件函数声明 - 类型从 `../../shared/api` 导入,使用 `type` 导入(`import type { ... }`) ```typescript import type { TargetStatus } from "../../shared/api"; import { StatusDot } from "./StatusDot"; interface TargetGroupProps { name: string; targets: TargetStatus[]; onTargetClick: (target: TargetStatus) => void; } export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps) { // ... } ``` #### 组件拆分原则 - **展示组件**(`components/`):纯渲染逻辑,通过 props 接收数据,通过回调返回事件 - **容器逻辑**放在 hooks 中,组件只做数据消费 - **常量数据**(列定义、排序器、筛选器)放在 `constants/`,不放在组件内部 - **工具函数**(时间处理等)放在 `utils/`,保持纯函数无副作用 #### 现有组件清单 | 组件 | 文件 | 用途 | | -------------------- | ----------------------------------- | ---------------------------------- | | `App` | `app.tsx` | 根组件,编排全局状态与布局 | | `SummaryCards` | `components/SummaryCards.tsx` | 总览统计卡片(全部/正常/异常) | | `TargetBoard` | `components/TargetBoard.tsx` | 按分组渲染目标表格列表 | | `TargetGroup` | `components/TargetGroup.tsx` | 单个分组标题 + PrimaryTable | | `TargetDetailDrawer` | `components/TargetDetailDrawer.tsx` | 目标详情抽屉(概览/记录 Tab) | | `TrendChart` | `components/TrendChart.tsx` | Recharts 双轴折线图(耗时/可用率) | | `StatusDonut` | `components/StatusDonut.tsx` | Recharts 环状图(UP/DOWN 分布) | | `StatusDot` | `components/StatusDot.tsx` | 圆形状态指示点(绿/红) | | `StatusBar` | `components/StatusBar.tsx` | 最近采样状态条(多色块) | | `GroupHeader` | `components/GroupHeader.tsx` | 分组标题(名称 + 统计) | ### 2.5 新增功能开发步骤 以"新增一个详情页面 Tab"为例: 1. **确认数据需求**:是已有 API 数据还是需要新端点? - 如有新端点,先在 `src/server/routes/` 添加,参考 [1.3 新增路由步骤](#13-api-路由开发) - 如有新字段,更新 `src/shared/api.ts` 类型定义 2. **实现 hooks**:在 `src/web/hooks/useTargetDetail.ts` 中新增 `useQuery`(写好 `queryKey` 和 `enabled` 条件) 3. **编写组件**:在 `src/web/components/` 创建组件文件 - 在 `TargetDetailDrawer.tsx` 中新增 `` 引用 4. **编写常量**:如有列定义/排序器/筛选器,放在 `src/web/constants/` 5. **编写测试**:在 `tests/web/` 下添加对应的单元测试 ### 2.6 样式开发规范 前端基于 TDesign React 构建 UI,样式开发遵循以下优先级(从高到低): 1. **使用 TDesign 组件**:布局、间距、排版优先使用 TDesign 组件(如 Space、Divider、Typography) 2. **使用 TDesign 组件 props**:通过组件的 props 参数控制外观(如 `theme`、`variant`、`size`) 3. **使用 TDesign CSS tokens**:颜色、间距、字体等使用 `--td-*` CSS 变量(如 `--td-success-color`、`--td-comp-margin-xxl`) 4. **在 styles.css 中定义 CSS 类**:无法通过上述方式满足的样式需求,集中定义在 `styles.css` 中 5. **自行开发组件**:仅在 TDesign 无法满足需求时自行开发 **红线**: - **严禁在组件中使用 `style` 属性内联调整样式** - **严禁通过 CSS 覆盖 TDesign 组件内部类名**(如 `.t-tab-panel`),如需定制使用组件的 `className` prop - **严禁使用 `!important`** - 颜色统一使用 TDesign CSS tokens(`--td-success-color`、`--td-error-color`、`--td-warning-color` 等),不使用硬编码色值 **styles.css 组织**: - 自定义 CSS 变量(如可用率渐变色 `--avail-0` ~ `--avail-9`)定义在 `:root` 中 - 布局类(`.dashboard`、`.dashboard-header`)定义全局页面结构 - 组件修饰类(`.status-dot--up`、`.latency-ok`)为自定义视觉组件提供样式变体 - TDesign 表格行高亮(`.row-down`)通过 `rowClassName` prop 应用 ### 2.7 前端测试规范 - 测试目录:`tests/web/`,结构对应 `src/web/` - 重点测试 **constants/** 中的纯函数(排序器、筛选器、颜色阈值等) - 使用 `bun:test` 框架 --- ## 三、项目运行、集成与打包 ### 3.1 开发期运行 #### 同时启动前后端 ```bash bun run dev probes.yaml ``` `scripts/dev.ts` 通过 `Bun.spawn` 同时启动两个子进程: ``` bun run dev probes.yaml ├── bun run dev:server probes.yaml → Bun HTTP 后端(默认 3000 端口) └── bun run dev:web → Vite 前端开发服务器(5173 端口) ``` - 任一子进程退出会导致整体退出 - `SIGINT`/`SIGTERM` 信号会同时终止两个子进程 - `BACKEND_PORT` 环境变量可覆盖后端端口 #### 分别启动 ```bash # 启动后端(含 watch 模式自动重启) bun run dev:server probes.yaml # 另开终端启动前端 bun run dev:web ``` ### 3.2 前后端集成方式 #### 开发期代理 Vite 配置了开发代理(`vite.config.ts`): ```typescript server: { proxy: { "/api": { target: `http://127.0.0.1:${backendPort}`, changeOrigin: true, }, }, } ``` 前端访问 `/api/*` 时,Vite 开发服务器自动转发到后端 `http://127.0.0.1:${backendPort}`,无需 CORS 配置。 前端开发地址为 `http://127.0.0.1:5173`(严格端口 `strictPort: true`)。 后端在开发模式下不提供静态资源服务,访问 `http://127.0.0.1:3000` 会提示"请通过 Vite 前端地址访问"。 #### 生产期集成 生产可执行文件是单体应用:前端静态资源嵌入 binary,后端同时提供 API 和静态文件服务。 ``` ./dist/dial-server probes.yaml 启动后: 访问 http://127.0.0.1:3000/ → 返回前端 SPA(index.html) 访问 http://127.0.0.1:3000/api/* → 返回后端 API 访问 /assets/* → 返回带不可变缓存的静态资源 ``` SPA fallback 逻辑(`src/server/static.ts`): - `/` → index.html - 匹配 `/assets/*` → 返回对应文件(未匹配则 404) - 其他路径(如 `/dashboard`)→ fallback 到 index.html(SPA 路由) ### 3.3 构建打包 #### 构建命令 ```bash bun run build ``` #### 构建流程详解 `scripts/build.ts` 执行以下步骤: ``` 1. vite build ├── 入口:src/web/index.html └── 输出:dist/web/(index.html + assets/) 2. 生成 .build/static-assets.ts(临时文件) ├── import Vite 产物为 Bun.file └── 导出 staticAssets: StaticAssets 对象 3. 生成 .build/server-entry.ts(临时文件) └── import 后端入口模块 + staticAssets,作为 Bun.build 入口 4. Bun.build({ compile, minify, sourcemap: "linked" }) └── 输出:dist/dial-server(单文件可执行 binary) ``` #### 产物 | 产物 | 用途 | | ------------------ | -------------------------- | | `dist/dial-server` | 生产可执行文件 | | `dist/web/` | Vite 构建产物(中间产物) | | `.build/` | 临时生成文件(构建后清理) | #### 构建参数 | 环境变量 | 说明 | | --------------------------- | -------------------------------------- | | `BUN_TARGET`/`BUILD_TARGET` | 交叉编译目标平台(如 `bun-linux-x64`) | #### 运行可执行文件 ```bash ./dist/dial-server probes.yaml ``` #### 清理 ```bash bun run clean # 清理 dist/ 构建产物、.build/ 缓存和 *.bun-build 临时文件 ``` ### 3.4 开发工作流 #### 日常开发循环 ```bash bun run dev probes.yaml # 启动开发环境 # 修改代码 → Vite HMR(前端)/ bun --watch(后端自动重启) bun run check # 提交前运行完整质量检查 ``` #### 完整验证流程 ```bash bun run verify # = bun run check + bun run build + bun run test:smoke ``` `verify` 适合 CI 或正式提交前,会完整验证类型检查、lint、格式、单元测试、构建、smoke test。 ### 3.5 Smoke Test ```bash bun run test:smoke ``` `scripts/smoke.ts` 构建后验证流程: 1. 动态分配空闲端口 2. 用临时配置文件启动 `dist/dial-server` 3. 等待健康检查通过 4. 验证所有 API 端点返回正确数据 5. 验证静态资源服务(含 SPA fallback 和 404 处理) 6. 验证安全 headers 7. 测试结束清理临时目录和进程 ### 3.6 脚本说明 | 脚本 | 文件 | 说明 | | ---------------------- | ----------------------------------- | ------------------------------- | | `bun run dev` | `scripts/dev.ts` | 同时启动前后端开发服务 | | `bun run build` | `scripts/build.ts` | Vite 构建 + Bun 编译可执行文件 | | `bun run schema` | `scripts/generate-config-schema.ts` | 生成 `probe-config.schema.json` | | `bun run schema:check` | `scripts/generate-config-schema.ts` | 检查配置 schema 导出物是否同步 | | `bun run test:smoke` | `scripts/smoke.ts` | 构建后的端到端验证 | | `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 | ### 3.7 环境变量 | 变量 | 用途 | 默认值 | | --------------------------- | ---------------------------------------------------- | -------- | | `PORT`/`BACKEND_PORT` | 后端监听端口(开发期 Vite 代理目标、生产期监听端口) | `3000` | | `BUN_TARGET`/`BUILD_TARGET` | 交叉编译目标平台(仅在 `bun run build` 时有效) | 当前平台 | ### 3.8 项目配置文件 | 文件 | 用途 | | --------------------- | ---------------------------------------------- | | `package.json` | 项目信息、脚本、依赖声明 | | `tsconfig.json` | TypeScript 配置(ESNext 模块、严格模式) | | `vite.config.ts` | Vite 开发代理与构建配置 | | `eslint.config.js` | ESLint 规则(含前端不得 import server 的检查) | | `.prettierrc.json` | Prettier 格式化规则(`printWidth: 120`) | | `.prettierignore` | Prettier 排除路径 | | `probes.example.yaml` | 配置文件示例 | | `opencode.json` | OpenCode 工具配置(TDesign MCP server) | ### 3.9 依赖管理 - **包管理器**:仅使用 `bun`,禁止使用 npm、pnpm、yarn - **安装依赖**:`bun install` - **运行工具**:使用 `bunx`,禁止使用 `npx`、`pnpx` - **锁文件**:`bun.lock` ### 3.10 目录约定 | 目录 | 约定 | | ------------- | -------------------------------------------- | | `src/server/` | 后端代码,不能 import `src/web/` | | `src/web/` | 前端代码,不能 import `src/server/` | | `src/shared/` | 前后端共享类型,双向可引用 | | `scripts/` | 独立运行脚本,可 import 项目源码 | | `tests/` | 测试目录,结构镜像 src 目录 | | `dist/` | 构建产物(gitignore) | | `.build/` | 构建临时文件(gitignore) | | `openspec/` | OpenSpec 变更管理与规格文档 | | `data/` | 默认数据目录(gitignore,运行期生成 SQLite) | --- ## 代码质量 项目使用多层代码质量保障体系:ESLint 类型感知规则 + Perfectionist 导入排序 + Prettier 格式化(通过 eslint-plugin-prettier 集成至 ESLint)+ TypeScript 严格模式 + Git hooks 自动化。 ```bash bun run lint # ESLint 检查(含类型感知规则、导入排序、导入验证、Prettier 格式) bun run format # Prettier 自动格式化 bun run schema:check # 检查 probe-config.schema.json 是否与 TypeBox fragments 同步 bun run typecheck # TypeScript 类型检查(含 noUnusedLocals、noPropertyAccessFromIndexSignature) bun test # 运行所有测试 bun run check # 一键运行 schema:check + typecheck + lint + test ``` `check` 是日常开发推荐的质量检查命令。 ### ESLint 规则 配置文件:`eslint.config.js` | 配置来源 | 用途 | | --------------------------------------------------------------- | -------------------------------------------------- | | `@eslint/js` recommended | JavaScript 基础规则 | | `typescript-eslint` recommended-type-checked | TypeScript 类型感知规则(no-floating-promises 等) | | `typescript-eslint` stylistic-type-checked | TypeScript 风格规则(命名规范、语法选择等) | | `eslint-plugin-perfectionist` recommended-natural | 导入语句和命名导出自动排序 | | `eslint-plugin-import` | 导入路径验证、循环依赖检测、重复导入合并 | | `eslint-plugin-prettier` recommended + `eslint-config-prettier` | 将 Prettier 格式集成为 ESLint 规则,禁用冲突规则 | ### Prettier 配置 配置文件:`.prettierrc.json`,通过 `eslint-plugin-prettier` 集成为 ESLint 规则(`lint` 命令同时检查格式),也可通过 `format` 命令独立运行。 显式声明所有格式化参数(`printWidth: 120`、`semi: true`、`singleQuote: false`、`trailingComma: "all"`、`endOfLine: "lf"` 等),确保不同开发环境产出完全一致的格式化结果。 ### TypeScript 严格标志 | 标志 | 值 | 说明 | | ------------------------------------ | ----- | -------------------------------------------------------------------------- | | `strict` | true | 全局严格模式 | | `noUnusedLocals` | true | 未使用局部变量视为错误 | | `noUnusedParameters` | false | 保留关闭(路由 handler 统一签名需要,如 `handleXxx(store, method, mode)`) | | `noPropertyAccessFromIndexSignature` | true | 禁止通过点号访问索引签名属性,强制使用括号语法 | | `noUncheckedIndexedAccess` | true | 数组/Map 访问必须运行时真值检查 | | `verbatimModuleSyntax` | true | 强制 `import type` 纯类型导入 | ### Git Hooks 通过 husky 在 commit 阶段自动执行检查: | Hook | 行为 | | ------------ | -------------------------------------------------------------------------------------------------------------- | | `pre-commit` | lint-staged 对变更文件运行 `eslint --fix`(TS/TSX,含 Prettier 格式修复)或 `prettier --write`(MD/JSON/YAML) | | `commit-msg` | commitlint 校验提交信息格式 `类型: 简短描述` | 提交类型限定:`feat`、`fix`、`refactor`、`docs`、`style`、`test`、`chore`。 `bun install` 时自动初始化 husky hooks,无需手动配置。 ## 测试 ```bash bun run check # 日常开发(类型检查 + lint(含格式) + 单元测试) bun run verify # 完整验证(check + 构建 + smoke test) ``` ## 已知限制 当前不做告警通知、数据自动清理、拨测目标动态增删、认证鉴权和分布式部署。Command 类型拨测不支持 Windows 环境。