From 147a2559ae17c76e043f404c502866dad3138fc1 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 13 May 2026 18:15:46 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=90=8E=E7=AB=AF=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E5=8A=A0=E5=9B=BA=20=E2=80=94=20=E6=B3=9B=E5=9E=8B?= =?UTF-8?q?=E5=8C=96=E3=80=81=E6=89=B9=E9=87=8F=E6=9F=A5=E8=AF=A2=E3=80=81?= =?UTF-8?q?bootstrap=20=E7=BB=9F=E4=B8=80=E3=80=81=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=8E=20pageSize=20=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CheckerDefinition 泛型化,HTTP/Command checker 移除 resolved target 断言 - 新增 ProbeStore.getAllRecentSamples 消除 targets 路由 N+1 查询 - 统一 getAllTargetStats 与 getTargetStats 的 availability 精度 - Engine rejected 结果写入 internal error 记录,提升可观测性 - 新增 bootstrap.ts 统一 dev/production 启动序列 - dataDir 相对路径改为基于配置文件目录解析 - validatePagination 增加 pageSize 上限 200 校验 - 修复 ErrorBoundary override 标记 - 更新 README/DEVELOPMENT 文档,新增完整测试覆盖 --- DEVELOPMENT.md | 65 ++++---- README.md | 26 ++-- .../.openspec.yaml | 2 + .../backend-architecture-hardening/design.md | 121 +++++++++++++++ .../proposal.md | 34 +++++ .../specs/batch-data-queries/spec.md | 44 ++++++ .../specs/checker-runner-abstraction/spec.md | 47 ++++++ .../specs/probe-api/spec.md | 32 ++++ .../specs/probe-config/spec.md | 16 ++ .../specs/probe-engine/spec.md | 24 +++ .../specs/server-bootstrap/spec.md | 38 +++++ .../backend-architecture-hardening/tasks.md | 42 ++++++ scripts/build.ts | 28 +--- src/server/bootstrap.ts | 83 +++++++++++ src/server/checker/config-loader.ts | 2 +- src/server/checker/engine.ts | 19 ++- src/server/checker/runner/command/execute.ts | 14 +- src/server/checker/runner/http/execute.ts | 14 +- src/server/checker/runner/types.ts | 10 +- src/server/checker/store.ts | 36 ++++- src/server/dev.ts | 27 +--- src/server/middleware.ts | 5 + src/server/routes/targets.ts | 3 +- src/web/components/ErrorBoundary.tsx | 6 +- tests/server/app.test.ts | 13 ++ tests/server/bootstrap.test.ts | 141 ++++++++++++++++++ tests/server/checker/config-loader.test.ts | 23 ++- tests/server/checker/engine.test.ts | 42 ++++++ .../server/checker/runner/http/runner.test.ts | 10 +- tests/server/checker/store.test.ts | 92 ++++++++++++ 30 files changed, 930 insertions(+), 129 deletions(-) create mode 100644 openspec/changes/backend-architecture-hardening/.openspec.yaml create mode 100644 openspec/changes/backend-architecture-hardening/design.md create mode 100644 openspec/changes/backend-architecture-hardening/proposal.md create mode 100644 openspec/changes/backend-architecture-hardening/specs/batch-data-queries/spec.md create mode 100644 openspec/changes/backend-architecture-hardening/specs/checker-runner-abstraction/spec.md create mode 100644 openspec/changes/backend-architecture-hardening/specs/probe-api/spec.md create mode 100644 openspec/changes/backend-architecture-hardening/specs/probe-config/spec.md create mode 100644 openspec/changes/backend-architecture-hardening/specs/probe-engine/spec.md create mode 100644 openspec/changes/backend-architecture-hardening/specs/server-bootstrap/spec.md create mode 100644 openspec/changes/backend-architecture-hardening/tasks.md create mode 100644 src/server/bootstrap.ts create mode 100644 tests/server/bootstrap.test.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ae78ab2..fae3174 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -21,6 +21,7 @@ src/ server/ app.ts Bun HTTP 路由入口(路由分发 + API 汇聚、StaticAssets 接口定义) + bootstrap.ts 后端统一启动引导(loadConfig → store → engine → startServer → shutdown) config.ts CLI 参数解析(仅提取配置文件路径) dev.ts 开发模式启动入口 server.ts HTTP server 启动工厂(接收 StartServerOptions) @@ -95,11 +96,13 @@ probe-config.schema.json 用户配置 JSON Schema 导出物(用于 IDE 自动 ``` 启动流程: - dev.ts → readRuntimeConfig(cli args, 仅提取 configPath) + dev.ts / build entry → readRuntimeConfig(cli args, 仅提取 configPath) + → bootstrap({ configPath, mode, staticAssets? }) → loadConfig(yaml) → ResolvedConfig{ host, port, dataDir, maxConcurrentChecks, retentionMs, targets } → ProbeStore(db) → store.syncTargets(targets) - → ProbeEngine(store, targets, maxConcurrentChecks, retentionMs) - → startServer({ config, mode: "development", store }) + → ProbeEngine(store, targets, maxConcurrentChecks, retentionMs) → engine.start() + → startServer({ config, mode, store, staticAssets? }) + → 注册 SIGINT/SIGTERM shutdown(engine.stop + store.close) 运行时: 定时器(tick) → ProbeEngine.probeGroup() @@ -148,7 +151,7 @@ export function handleTrend(idStr: string, url: URL, method: string, store: Prob 1. `app.ts` 的 `createFetchHandler` 作为总入口,根据 URL pattern 匹配路由 2. `/health` 路由独立处理,不经过 `guardGetHead`(使用 `helpers.ts` 的 `allowsGetHead` 自行校验方法) 3. `/api/*` 路由统一经过 `guardGetHead` 做方法检查(仅允许 GET/HEAD),返回 `null` 表示通过 -4. 各 handler 内部通过 `middleware.ts` 提供的 `validateTargetId`、`validateTimeRange`、`validatePagination` 做参数校验 +4. 各 handler 内部通过 `middleware.ts` 提供的 `validateTargetId`、`validateTimeRange`、`validatePagination` 做参数校验,`pageSize` 最大值为 `200` 5. 校验函数返回 `Response` 实例表示校验失败(直接返回),返回数据对象表示通过 6. 业务逻辑通过 `store` 查询数据,用 `helpers.ts` 的 `jsonResponse`、`mapCheckResult`、`formatDuration` 等格式化输出 @@ -170,7 +173,7 @@ export function handleTrend(idStr: string, url: URL, method: string, store: Prob - `jsonResponse(body, options)` — JSON 响应构造(自动处理 HEAD 空体) - `mapCheckResult(row)` — 数据库行转 API CheckResult - `methodNotAllowedResponse(allow, mode)` — 构造 405 响应 -- **`middleware.ts`**:API 参数校验函数(`guardGetHead`、`validateTargetId`、`validateTimeRange`、`validatePagination`) +- **`middleware.ts`**:API 参数校验函数(`guardGetHead`、`validateTargetId`、`validateTimeRange`、`validatePagination`,其中 `pageSize` 上限为 `200`) - **`static.ts`**:生产模式下的静态资源服务与 SPA fallback ### 1.5 类型定义规范 @@ -184,7 +187,9 @@ export function handleTrend(idStr: string, url: URL, method: string, store: Prob - `checker/types.ts` 定义 base interface(`ResolvedTargetBase`、`RawTargetConfig`、`DefaultsConfig`),使用 index signature 支持扩展 - 各 checker 在自己的 `types.ts` 中定义具体类型(如 `ResolvedHttpTarget`、`ResolvedCommandTarget`),满足 base interface 约束 - 中间层(engine、store、config-loader)只依赖 base interface,不感知具体 checker 类型 - - Checker 内部通过 `as` 类型断言将 base 窄化为具体类型 + - `CheckerDefinition` 使用泛型约束 `resolve` 返回值以及 `execute`、`serialize` 的 target 参数 + - checker 实现指定具体 `ResolvedXxxTarget` 类型,中间层(registry、engine、config-loader、store)使用默认泛型参数完成类型擦除 + - Checker 内部 `execute` 和 `serialize` 直接接收具体类型;`resolve` 输入仍是 `RawTargetConfig`,可在读取 checker 专属原始配置时做必要窄化 ### 1.6 配置契约与校验 @@ -194,15 +199,15 @@ export function handleTrend(idStr: string, url: URL, method: string, store: Prob `ResolvedConfig` 包含以下字段: -| 字段 | 来源 | 默认值 | -| --------------------- | ----------------------------- | ----------- | -| `configDir` | 配置文件所在目录 | — | -| `dataDir` | `server.dataDir` | `./data` | -| `host` | `server.host` | `127.0.0.1` | -| `port` | `server.port` | `3000` | -| `maxConcurrentChecks` | `runtime.maxConcurrentChecks` | `20` | -| `retentionMs` | `runtime.retention` | `7d` | -| `targets` | `targets[]` 经 resolve 后 | — | +| 字段 | 来源 | 默认值 | +| --------------------- | -------------------------------------------------- | ---------------- | +| `configDir` | 配置文件所在目录 | — | +| `dataDir` | `server.dataDir`(基于配置文件目录解析为绝对路径) | `configDir/data` | +| `host` | `server.host` | `127.0.0.1` | +| `port` | `server.port` | `3000` | +| `maxConcurrentChecks` | `runtime.maxConcurrentChecks` | `20` | +| `retentionMs` | `runtime.retention` | `7d` | +| `targets` | `targets[]` 经 resolve 后 | — | 契约层使用 `src/server/checker/schema/` 中的 TypeBox fragments 生成 JSON Schema,并用 Ajv 执行启动期校验。Ajv 必须保持严格拒绝模式:`allErrors: true`、不启用类型强制转换、不注入默认值、不自动删除未知字段。 @@ -412,18 +417,19 @@ TcpChecker implements Checker **核心方法**: -| 方法 | 用途 | -| ---------------------- | ---------------------------------------------------------------- | -| `syncTargets(targets)` | 启动期同步 targets(基于 name 做 upsert + delete 事务) | -| `insertCheckResult()` | 写入单条检查结果 | -| `getTargets()` | 查询全部 targets(default 分组优先排序) | -| `getLatestChecksMap()` | 批量获取每个 target 的最新检查结果(单次 SQL 聚合) | -| `getAllTargetStats()` | 批量获取每个 target 的可用率统计(GROUP BY 聚合) | -| `getSummary()` | 获取总览统计(基于 `getLatestChecksMap` 内存计算 up/down/total) | -| `getTrend()` | 获取按小时聚合的趋势数据 | -| `getHistory()` | 分页查询历史记录 | -| `getRecentSamples()` | 获取最近 N 条采样数据(用于状态条渲染) | -| `prune(retentionMs)` | 按 retention 策略清理过期数据(由 engine 定时调用) | +| 方法 | 用途 | +| ----------------------- | ---------------------------------------------------------------- | +| `syncTargets(targets)` | 启动期同步 targets(基于 name 做 upsert + delete 事务) | +| `insertCheckResult()` | 写入单条检查结果 | +| `getTargets()` | 查询全部 targets(default 分组优先排序) | +| `getLatestChecksMap()` | 批量获取每个 target 的最新检查结果(单次 SQL 聚合) | +| `getAllTargetStats()` | 批量获取每个 target 的可用率统计(GROUP BY 聚合) | +| `getAllRecentSamples()` | 批量获取每个 target 的最近 N 条采样(window function) | +| `getSummary()` | 获取总览统计(基于 `getLatestChecksMap` 内存计算 up/down/total) | +| `getTrend()` | 获取按小时聚合的趋势数据 | +| `getHistory()` | 分页查询历史记录 | +| `getRecentSamples()` | 获取最近 N 条采样数据(用于状态条渲染) | +| `prune(retentionMs)` | 按 retention 策略清理过期数据(由 engine 定时调用) | **Statement 使用规范**: @@ -436,7 +442,7 @@ TcpChecker implements Checker - 避免 N+1 查询:批量场景优先用单次 SQL 聚合(GROUP BY、子查询 JOIN)+ 内存组装 - 新增批量查询方法时必须编写对应单元测试 -- `getSummary()` 和 `GET /api/targets` 的响应组装已通过 `getLatestChecksMap` + `getAllTargetStats` 实现批量查询 +- `getSummary()` 和 `GET /api/targets` 的响应组装已通过 `getLatestChecksMap` + `getAllTargetStats` + `getAllRecentSamples` 实现批量查询 **Schema**: @@ -451,6 +457,7 @@ TcpChecker implements Checker - **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 映射 +- **异常可观测**:`probeGroup()` 对 `Promise.allSettled` 的 rejected 结果通过索引关联 target,并写入 `phase:"internal"` 的失败记录 - **数据清理**:当 `retentionMs > 0` 时,engine 启动时立即执行一次 `store.prune()`,之后每小时定时执行,按 `timestamp` 清理过期数据 - **生命周期**:`start()`/`stop()` 管理定时器(含调度定时器和清理定时器),`stop()` 清理所有 `setInterval` @@ -815,7 +822,7 @@ bun run build └── 导出 staticAssets: StaticAssets 对象 3. 生成 .build/server-entry.ts(临时文件) - └── import 后端入口模块 + staticAssets,作为 Bun.build 入口 + └── import bootstrap + staticAssets,调用 production bootstrap,作为 Bun.build 入口 4. Bun.build({ compile, minify, sourcemap: "linked" }) └── 输出:dist/dial-server(单文件可执行 binary) diff --git a/README.md b/README.md index 92573e4..203bbab 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ targets: - **server**: 服务配置(均可省略,使用默认值) - `host`: 监听地址,默认 `127.0.0.1` - `port`: 监听端口,默认 `3000` - - `dataDir`: 数据目录,默认 `./data` + - `dataDir`: 数据目录,默认 `./data`,相对路径基于配置文件所在目录解析 - **runtime**: 运行时配置 - `maxConcurrentChecks`: 最大并发拨测数,默认 `20` - `retention`: 历史数据保留时长,默认 `7d`,支持 `ms`/`s`/`m`/`h`/`d` 单位 @@ -160,13 +160,13 @@ JSON Schema:仓库根目录导出 `probe-config.schema.json`,可在 YAML 文 ## API 端点 -| 端点 | 说明 | -| ----------------------------------------------------------------- | --------------------------------------- | -| `GET /health` | 健康检查 | -| `GET /api/summary` | 总览统计(total/up/down/lastCheckTime) | -| `GET /api/targets` | 目标列表及最新状态、分组和采样数据 | -| `GET /api/targets/:id/history?from=ISO&to=ISO&page=1&pageSize=20` | 指定目标的拨测记录(时间范围 + 分页) | -| `GET /api/targets/:id/trend?from=ISO&to=ISO` | 指定目标的按小时聚合趋势 | +| 端点 | 说明 | +| ----------------------------------------------------------------- | ------------------------------------------------------------ | +| `GET /health` | 健康检查 | +| `GET /api/summary` | 总览统计(total/up/down/lastCheckTime) | +| `GET /api/targets` | 目标列表及最新状态、分组和采样数据 | +| `GET /api/targets/:id/history?from=ISO&to=ISO&page=1&pageSize=20` | 指定目标的拨测记录(时间范围 + 分页,`pageSize` 最大 `200`) | +| `GET /api/targets/:id/trend?from=ISO&to=ISO` | 指定目标的按小时聚合趋势 | ### 响应字段 @@ -194,11 +194,11 @@ API 错误返回 `ApiErrorResponse` 格式: { "error": "描述信息", "status": 400 } ``` -| 状态码 | 触发场景 | -| ------ | ----------------------------------------------------------------------- | -| 400 | 参数格式错误(无效 ID、from/to 缺失或格式错误、page/pageSize 非正整数) | -| 404 | 目标不存在、API 路由未匹配 | -| 405 | 非 GET 方法请求 API 路由 | +| 状态码 | 触发场景 | +| ------ | ------------------------------------------------------------------------------------------ | +| 400 | 参数格式错误(无效 ID、from/to 缺失或格式错误、page/pageSize 非正整数、pageSize 超过 200) | +| 404 | 目标不存在、API 路由未匹配 | +| 405 | 非 GET 方法请求 API 路由 | ## 运行参数 diff --git a/openspec/changes/backend-architecture-hardening/.openspec.yaml b/openspec/changes/backend-architecture-hardening/.openspec.yaml new file mode 100644 index 0000000..93831bd --- /dev/null +++ b/openspec/changes/backend-architecture-hardening/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-13 diff --git a/openspec/changes/backend-architecture-hardening/design.md b/openspec/changes/backend-architecture-hardening/design.md new file mode 100644 index 0000000..0e2a976 --- /dev/null +++ b/openspec/changes/backend-architecture-hardening/design.md @@ -0,0 +1,121 @@ +## Context + +DiAL 后端是基于 Bun 的拨测服务,当前有 2 个 checker 类型(http、command),target 规模预计增长到 100,checker 类型预计超过 5 种。 + +现状问题: +1. `GET /api/targets` 对每个 target 单独查询 `getRecentSamples`,产生 N+3 次 SQL 查询 +2. `ProbeEngine.probeGroup` 中 rejected 结果仅 `console.warn`,前端无法感知异常 +3. `dev.ts` 和 `scripts/build.ts` 生成的 entry 各自维护相同的启动序列 +4. `config-loader.ts` 中 `dataDir` 未基于 `configDir` 解析,相对路径依赖进程 cwd +5. `validatePagination` 无 pageSize 上限,可被滥用 +6. `CheckerDefinition` 接口方法参数为 `ResolvedTargetBase`,checker 内部需手动 `as` 断言 + +## Goals / Non-Goals + +**Goals:** +- 消除 targets 路由的 N+1 查询,支撑 100 target 规模 +- Engine 异常可观测:rejected 结果写入数据库,前端可见 +- 启动逻辑单一来源,降低维护成本 +- 修复 dataDir 路径解析 bug +- API 防御性:pageSize 上限 +- CheckerDefinition 泛型化,checker 开发者获得编译期类型安全 + +**Non-Goals:** +- 不做配置热更新 +- 不做 API 认证/鉴权 +- 不做通知/告警系统 +- 不改变 `ResolvedTargetBase` 的 index signature(registry 层仍用类型擦除) +- 不改变前端行为 + +## Decisions + +### Decision 1: 批量查询 recentSamples 使用 window function + +**选择**:在 `ProbeStore` 中新增 `getAllRecentSamples(limit: number)` 方法,使用 SQLite window function `ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY timestamp DESC)` 一次查询所有 target 的最近 N 条采样。 + +**替代方案**: +- UNION ALL 拼接每个 target 的子查询 → SQL 长度随 target 数线性增长,不可控 +- 应用层批量(一次查全部再内存分组)→ 数据量大时内存开销高 + +**理由**:window function 是 SQLite 3.25+ 原生支持的特性,Bun 内置的 SQLite 版本满足要求。单次查询,SQL 固定长度,性能最优。 + +### Decision 2: Engine rejected 写入 internal error 记录 + +**选择**:在 `probeGroup` 中,对 `rejected` 的结果构造一条 `matched: false`、`failure: { kind: "error", phase: "internal", path: "engine", message: reason }` 的 check_result 写入 store。 + +**替代方案**: +- 单独的错误日志表 → 增加 schema 复杂度,前端需要额外查询 +- 仅保留 console.warn → 现状,不可观测 + +**理由**:复用现有 check_results 表和 failure 结构,前端无需改动即可展示异常状态。`phase: "internal"` 区分于正常的 checker 执行失败。通过 `Promise.allSettled` 的索引关联回 target 数组,确保能获取 targetName。 + +### Decision 3: 抽取 bootstrap.ts + +**选择**:新增 `src/server/bootstrap.ts`,导出 `bootstrap(options: BootstrapOptions)` 函数,封装完整启动序列:loadConfig → ProbeStore → syncTargets → ProbeEngine → startServer → 注册 shutdown handler。 + +**接口设计**: +```typescript +interface BootstrapOptions { + configPath: string; + mode: RuntimeMode; + staticAssets?: StaticAssets; +} +``` + +`dev.ts` 调用 `bootstrap({ configPath, mode: "development" })`。 +`build.ts` 生成的 entry 调用 `bootstrap({ configPath, mode: "production", staticAssets })`。 + +**替代方案**: +- 保持两处重复 → 维护负担随启动逻辑复杂化线性增长 + +### Decision 4: dataDir 基于 configDir 解析 + +**选择**:在 `config-loader.ts` 的 `loadConfig` 中,对 `dataDir` 使用 `resolve(configDir, dataDir)` 处理。如果 `dataDir` 是绝对路径,`resolve` 会直接返回绝对路径,不影响绝对路径用户。 + +**影响**:行为变更——之前相对路径基于 cwd,现在基于配置文件目录。由于项目未上线,无需向前兼容。 + +### Decision 5: pageSize 上限 200 + +**选择**:在 `middleware.ts` 的 `validatePagination` 中增加 `pageSize > 200` 的校验,返回 400。 + +**常量定义**:`MAX_PAGE_SIZE = 200`,定义在 `middleware.ts` 中。 + +**理由**:200 条/页对于拨测历史记录的展示场景足够。前端当前使用 20,不受影响。 + +### Decision 6: CheckerDefinition 泛型化 + +**选择**: + +```typescript +interface CheckerDefinition { + execute(target: TResolved, ctx: CheckerContext): Promise; + resolve(target: RawTargetConfig, context: ResolveContext): TResolved; + serialize(target: TResolved): { config: string; target: string }; + validate(input: CheckerValidationInput): ConfigValidationIssue[]; + readonly configKey: string; + readonly schemas: CheckerSchemas; + readonly type: string; +} +``` + +- 默认泛型参数 `= ResolvedTargetBase` 保证 registry 等中间层无需指定泛型 +- `CheckerRegistry` 内部存储 `CheckerDefinition`(类型擦除) +- 各 checker 实现 `implements CheckerDefinition` 等具体类型 +- checker 内部 `execute`、`serialize` 方法直接接收具体类型,无需 `as` 断言 + +**替代方案**: +- Discriminated union → 每加 checker 改 union,违背插件化设计 +- 维持现状 → 5+ checker 时 `as` 断言散落各处 + +**影响范围**: +- `runner/types.ts`:接口加泛型参数 +- `runner/registry.ts`:内部 Map 类型为 `CheckerDefinition`(使用默认参数) +- `http/execute.ts`、`command/execute.ts`:`implements CheckerDefinition<具体类型>`,移除方法内的 `as` 断言 +- `engine.ts`、`config-loader.ts`、`store.ts`:不变(依赖 base interface) + +## Risks / Trade-offs + +- **window function 兼容性** → Bun 内置 SQLite >= 3.25,已验证支持。如果未来需要外部 SQLite,需确认版本。 +- **Engine rejected 写入依赖索引关联** → 通过 `Promise.allSettled` 的索引关联回 target 数组获取 targetName。前提是 `probeGroup` 的 targets 数组与 `Promise.allSettled` 结果数组保持一一对应,当前实现满足此条件。 +- **bootstrap.ts 增加一层间接** → 启动流程从 2 处直接代码变为 1 处函数调用。复杂度不增加,只是位置移动。 +- **泛型擦除在 registry 层** → `registry.get()` 返回 `CheckerDefinition`(base 类型),engine 调用时仍是 base 类型。这是设计意图:中间层不感知具体 checker 类型。 diff --git a/openspec/changes/backend-architecture-hardening/proposal.md b/openspec/changes/backend-architecture-hardening/proposal.md new file mode 100644 index 0000000..25851e9 --- /dev/null +++ b/openspec/changes/backend-architecture-hardening/proposal.md @@ -0,0 +1,34 @@ +## Why + +后端在 target 规模增长(预计到 100)和 checker 类型扩展(预计超过 5 种)的趋势下,存在查询性能瓶颈、可观测性盲区、启动逻辑重复、路径解析 bug 和类型安全不足等问题。本次变更集中修复这些架构短板,为后续扩展打好基础。 + +## What Changes + +- **targets 路由 N+1 查询优化**:`handleTargets` 中对每个 target 单独调用 `getRecentSamples` 改为批量查询,消除 N 次独立 SQL +- **Engine rejected 结果持久化**:`probeGroup` 中 `Promise.allSettled` 的 rejected 结果写入 `matched: false` 的 check_result(failure 标记为 internal error),替代仅 `console.warn` +- **启动逻辑统一**:抽取 `bootstrap.ts`,`dev.ts` 和 build 生成的 entry 共用同一启动序列,消除重复 +- **dataDir 相对路径修复**:`config-loader.ts` 中用 `resolve(configDir, dataDir)` 处理相对路径,确保从任意 cwd 启动时数据库位置一致 +- **validatePagination 加 pageSize 上限**:限制最大 pageSize 为 200,超出返回 400 +- **CheckerDefinition 泛型化**:为 `CheckerDefinition` 加泛型参数 ``,checker 内部获得完整类型安全,registry 用类型擦除保持解耦 +- **availability 精度统一**:`getAllTargetStats` 和 `getTargetStats` 的 availability 计算精度不一致,统一为相同的四舍五入策略 + +## Capabilities + +### New Capabilities + +- `server-bootstrap`: 统一的服务启动引导流程,dev 和 production 共用 + +### Modified Capabilities + +- `batch-data-queries`: 新增 `getAllRecentSamples` 批量采样查询,消除 targets 路由的 N+1 问题;修复 availability 精度不一致 +- `probe-engine`: Engine 对 rejected 结果写入 matched:false 记录而非静默丢弃 +- `probe-config`: dataDir 相对路径基于 configDir 解析 +- `probe-api`: validatePagination 增加 pageSize 上限校验 +- `checker-runner-abstraction`: CheckerDefinition 接口泛型化,checker 内部类型安全 + +## Impact + +- **代码**:`src/server/` 下约 8 个文件变更,新增 `bootstrap.ts` 和 `store.ts` 的批量查询方法;另修复 `src/web/components/ErrorBoundary.tsx` 的 `override` 标记(typecheck 前置修复) +- **API**:pageSize 超过 200 时返回 400(新增约束,当前前端未使用超大 pageSize) +- **构建**:`scripts/build.ts` 生成的 entry 改为调用 bootstrap +- **测试**:需新增/更新 engine、store、middleware、bootstrap 相关测试 diff --git a/openspec/changes/backend-architecture-hardening/specs/batch-data-queries/spec.md b/openspec/changes/backend-architecture-hardening/specs/batch-data-queries/spec.md new file mode 100644 index 0000000..2d6fec1 --- /dev/null +++ b/openspec/changes/backend-architecture-hardening/specs/batch-data-queries/spec.md @@ -0,0 +1,44 @@ +## ADDED Requirements + +### Requirement: 批量查询所有目标的最近采样数据 +系统 SHALL 提供 `getAllRecentSamples(limit: number)` 方法,通过单次 SQL 查询获取所有 target 的最近 N 条采样数据,返回 `Map>` 结构。 + +#### Scenario: 获取所有目标的最近采样 +- **WHEN** 调用 `getAllRecentSamples(30)` +- **THEN** 系统 SHALL 通过单次 SQL 查询获取每个 target 最近 30 条记录,返回按 target_id 索引的 Map + +#### Scenario: 目标无历史记录 +- **WHEN** 某 target 在 check_results 表中无任何记录 +- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key + +#### Scenario: 采样数据排序 +- **WHEN** 获取采样数据 +- **THEN** 每个 target 的记录 SHALL 按 timestamp 降序排列(最新在前) + +## MODIFIED Requirements + +### Requirement: targets 列表使用批量方法 +`handleTargets`(routes/targets.ts 中生成 TargetStatus[] 的逻辑)SHALL 使用 `getLatestChecksMap`、`getAllTargetStats` 和 `getAllRecentSamples` 替代逐目标查询,消除 N+1 查询。 + +#### Scenario: 目标列表使用批量查询 +- **WHEN** 处理 `GET /api/targets` 请求 +- **THEN** 系统 SHALL 分别调用 `getLatestChecksMap()`、`getAllTargetStats()`、`getAllRecentSamples(30)` 批量获取数据,在内存中组装 TargetStatus 数组,而非对每个 target 逐条查询数据库 + +#### Scenario: 目标无采样数据 +- **WHEN** 某 target 在 getAllRecentSamples 返回的 Map 中不存在 +- **THEN** 该 target 的 recentSamples SHALL 为空数组 + +### Requirement: 批量查询目标统计 +系统 SHALL 提供 `getAllTargetStats` 方法,通过单次 SQL GROUP BY 聚合查询获取所有 target 的拨测统计(totalChecks 和 availability)。availability 计算精度 SHALL 与 `getTargetStats` 一致,统一使用 `Math.round(value * 100) / 100` 保留两位小数。 + +#### Scenario: 获取所有目标的聚合统计 +- **WHEN** 调用 `getAllTargetStats()` +- **THEN** 系统 SHALL 执行单次 GROUP BY 聚合查询,在内存中计算 availability 并返回 `Map` + +#### Scenario: availability 精度 +- **WHEN** 计算 availability(upCount / totalChecks * 100) +- **THEN** 结果 SHALL 使用 `Math.round(value * 100) / 100` 四舍五入保留两位小数,与 `getTargetStats` 方法一致 + +#### Scenario: 目标无历史记录 +- **WHEN** 某 target 在 check_results 表中无任何记录 +- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key diff --git a/openspec/changes/backend-architecture-hardening/specs/checker-runner-abstraction/spec.md b/openspec/changes/backend-architecture-hardening/specs/checker-runner-abstraction/spec.md new file mode 100644 index 0000000..d49717e --- /dev/null +++ b/openspec/changes/backend-architecture-hardening/specs/checker-runner-abstraction/spec.md @@ -0,0 +1,47 @@ +## MODIFIED Requirements + +### Requirement: Checker 接口定义 +系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的泛型 `CheckerDefinition`,包含 `type`、`configKey`、TypeBox 配置契约、启动期语义校验、`resolve`、`execute`、`serialize` 成员。泛型参数 SHALL 约束 `execute` 和 `serialize` 方法的 target 参数类型,使 checker 实现内部获得编译期类型安全。默认泛型参数 `= ResolvedTargetBase` 保证中间层(registry、engine、config-loader)无需指定泛型。 + +#### Scenario: Checker 接口包含必要方法 +- **WHEN** 开发者实现一个新的 Checker +- **THEN** 该实现 MUST 提供 `type`(字符串标识)、`configKey`(配置分组名)、TypeBox 配置契约、启动期语义校验、`resolve(target, context): TResolved`(解析配置并填充默认值)、`execute(target: TResolved, ctx)`(执行探测返回 CheckResult)和 `serialize(target: TResolved)`(返回 target 展示文本和 config JSON) + +#### Scenario: CheckerContext 注入 signal +- **WHEN** 引擎调用 `checker.execute(target, ctx)` +- **THEN** `ctx.signal` SHALL 是一个由引擎创建的 `AbortSignal`,在超时或引擎关闭时 abort + +#### Scenario: resolve 不承担通用契约校验 +- **WHEN** config-loader 调用 checker.resolve() +- **THEN** checker.resolve() SHALL 假定配置已经通过 TypeBox/Ajv 契约校验和启动期语义校验,只负责默认值填充、路径解析和领域配置转换 + +#### Scenario: 接口方法使用泛型约束 +- **WHEN** 开发者查看 `CheckerDefinition` 接口签名 +- **THEN** `resolve` 的返回值 SHALL 为 `TResolved`;`execute` 的参数 SHALL 为 `TResolved`;`serialize` 的参数 SHALL 为 `TResolved` + +#### Scenario: checker 实现无需手动断言 +- **WHEN** HttpChecker 实现 `CheckerDefinition` +- **THEN** `execute` 方法的 target 参数类型 SHALL 直接为 `ResolvedHttpTarget`,无需在方法内部使用 `as` 类型断言 + +#### Scenario: registry 使用默认泛型参数 +- **WHEN** CheckerRegistry 存储和返回 checker 实例 +- **THEN** registry 内部 SHALL 使用 `CheckerDefinition`(等价于 `CheckerDefinition`),实现类型擦除 + +### Requirement: CheckerRegistry 注册中心 +系统 SHALL 在 `src/server/checker/runner/registry.ts` 中提供 `CheckerRegistry` 类,支持 `register(checker)`、`get(type)` 和 `supportedTypes`。重复注册同一 type SHALL 抛出错误。registry 内部 SHALL 存储 `CheckerDefinition`(使用默认泛型参数),对外提供类型擦除后的接口。 + +#### Scenario: 注册并获取 Checker +- **WHEN** 调用 `registry.register(new HttpChecker())` 后再调用 `registry.get("http")` +- **THEN** 返回的 SHALL 是之前注册的 HttpChecker 实例(类型为 `CheckerDefinition`) + +#### Scenario: 获取未注册的 type +- **WHEN** 调用 `registry.get("unknown")` 且未注册对应 type 的 checker +- **THEN** 系统 SHALL 抛出错误,提示不支持的 probe type + +#### Scenario: 重复注册 +- **WHEN** 同一 type 值被重复 `register()` +- **THEN** 系统 SHALL 抛出错误,提示该 type 已注册 + +#### Scenario: 查询支持的 type 列表 +- **WHEN** 注册了 "http" 和 "command" 两个 checker 后查询 `registry.supportedTypes` +- **THEN** 返回的数组 SHALL 包含 `["http", "command"]`(按注册顺序) diff --git a/openspec/changes/backend-architecture-hardening/specs/probe-api/spec.md b/openspec/changes/backend-architecture-hardening/specs/probe-api/spec.md new file mode 100644 index 0000000..71cee49 --- /dev/null +++ b/openspec/changes/backend-architecture-hardening/specs/probe-api/spec.md @@ -0,0 +1,32 @@ +## MODIFIED Requirements + +### Requirement: API 错误处理 +系统 SHALL 对不存在的目标 ID、无效参数和超出范围的分页参数返回适当的 HTTP 错误响应。 + +#### Scenario: 查询不存在的目标 +- **WHEN** 客户端请求 `GET /api/targets/999/history` +- **THEN** 系统 SHALL 返回 404 状态码和错误信息 + +#### Scenario: 无效的 from/to 参数 +- **WHEN** 客户端请求 `GET /api/targets/1/history?from=invalid` +- **THEN** 系统 SHALL 返回 400 状态码和错误信息 + +#### Scenario: 无效的分页参数 +- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&page=abc` +- **THEN** 系统 SHALL 返回 400 状态码和错误信息 + +#### Scenario: pageSize 超过上限 +- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&pageSize=201` +- **THEN** 系统 SHALL 返回 400 状态码和错误信息,提示 pageSize 不能超过 200 + +#### Scenario: pageSize 等于上限 +- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&pageSize=200` +- **THEN** 系统 SHALL 正常返回数据 + +#### Scenario: from 或 to 参数缺失 +- **WHEN** 客户端请求 `GET /api/targets/1/trend` 未提供 from 或 to 参数 +- **THEN** 系统 SHALL 返回 400 状态码和错误信息 + +#### Scenario: 无效的目标 ID +- **WHEN** 客户端请求 `GET /api/targets/abc/history` +- **THEN** 系统 SHALL 返回 400 状态码和错误信息 diff --git a/openspec/changes/backend-architecture-hardening/specs/probe-config/spec.md b/openspec/changes/backend-architecture-hardening/specs/probe-config/spec.md new file mode 100644 index 0000000..7a5fb40 --- /dev/null +++ b/openspec/changes/backend-architecture-hardening/specs/probe-config/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: 数据目录路径解析 +配置加载流程 SHALL 将 `server.dataDir` 相对路径基于配置文件所在目录(configDir)解析为绝对路径。绝对路径 SHALL 保持不变。 + +#### Scenario: dataDir 为相对路径 +- **WHEN** 配置文件位于 `/opt/dial/probes.yaml`,且 `server.dataDir` 配置为 `./data` +- **THEN** 系统 SHALL 将 dataDir 解析为 `/opt/dial/data`,而非依赖进程 cwd + +#### Scenario: dataDir 为绝对路径 +- **WHEN** `server.dataDir` 配置为 `/var/lib/dial/data` +- **THEN** 系统 SHALL 直接使用该绝对路径,不做额外解析 + +#### Scenario: dataDir 使用默认值 +- **WHEN** 未配置 `server.dataDir`(使用默认值 `./data`) +- **THEN** 系统 SHALL 将默认值 `./data` 基于 configDir 解析为绝对路径 diff --git a/openspec/changes/backend-architecture-hardening/specs/probe-engine/spec.md b/openspec/changes/backend-architecture-hardening/specs/probe-engine/spec.md new file mode 100644 index 0000000..9d979b3 --- /dev/null +++ b/openspec/changes/backend-architecture-hardening/specs/probe-engine/spec.md @@ -0,0 +1,24 @@ +## MODIFIED Requirements + +### Requirement: 组内并发拨测 +系统 SHALL 在每次调度 tick 时并发执行同组内目标的检查,但实际同时运行的检查数 MUST 受全局 `runtime.maxConcurrentChecks` 限制。当某个目标的 checker 执行 rejected(非正常 CheckResult 返回,而是 Promise reject)时,系统 SHALL 将该异常记录为 `matched: false` 的 check_result,而非仅 console.warn。 + +#### Scenario: 同组目标并发执行 +- **WHEN** 调度器触发一次 tick,该组有 3 个目标,且全局并发余量至少为 3 +- **THEN** 系统 SHALL 同时执行 3 个 checker,而非顺序执行 + +#### Scenario: 单个目标失败不影响同组其他目标 +- **WHEN** 同组中某个目标的检查请求超时或失败(checker 正常返回 CheckResult) +- **THEN** 其他目标的检查 SHALL 正常完成并记录结果 + +#### Scenario: 同组中某个目标的 checker 执行 rejected +- **WHEN** 同组中某个目标的 checker 执行抛出未捕获异常(Promise rejected) +- **THEN** 系统 SHALL 为该目标写入一条 `matched: false` 的 check_result,failure 为 `{ kind: "error", phase: "internal", path: "engine", message: }`,其他目标的检查 SHALL 不受影响 + +#### Scenario: rejected 结果通过索引关联 targetName +- **WHEN** checker 执行 rejected +- **THEN** 系统 SHALL 通过 Promise.allSettled 的索引关联回 target 数组,获取对应的 targetName 用于写入 check_result + +#### Scenario: 全局并发限制生效 +- **WHEN** 调度器同时触发 10 个目标且 runtime.maxConcurrentChecks 为 3 +- **THEN** 系统 MUST 同时最多运行 3 个检查,其余检查等待并发槽位释放 diff --git a/openspec/changes/backend-architecture-hardening/specs/server-bootstrap/spec.md b/openspec/changes/backend-architecture-hardening/specs/server-bootstrap/spec.md new file mode 100644 index 0000000..00ce1fa --- /dev/null +++ b/openspec/changes/backend-architecture-hardening/specs/server-bootstrap/spec.md @@ -0,0 +1,38 @@ +## ADDED Requirements + +### Requirement: 统一启动引导函数 +系统 SHALL 提供 `src/server/bootstrap.ts` 导出 `bootstrap(options: BootstrapOptions)` 函数,封装完整的服务启动序列:加载配置、创建 store、同步 targets、创建并启动 engine、启动 HTTP server、注册 shutdown handler。 + +#### Scenario: 开发模式启动 +- **WHEN** `dev.ts` 调用 `bootstrap({ configPath, mode: "development" })` +- **THEN** 系统 SHALL 完成完整启动序列,不传入 staticAssets + +#### Scenario: 生产模式启动 +- **WHEN** build entry 调用 `bootstrap({ configPath, mode: "production", staticAssets })` +- **THEN** 系统 SHALL 完成完整启动序列,并将 staticAssets 传递给 startServer + +#### Scenario: 启动失败处理 +- **WHEN** 启动过程中任何步骤抛出异常 +- **THEN** 系统 SHALL 输出错误信息并以非零退出码退出进程 + +#### Scenario: 优雅关机 +- **WHEN** 进程收到 SIGINT 或 SIGTERM 信号 +- **THEN** bootstrap 注册的 shutdown handler SHALL 调用 engine.stop() 和 store.close() 后退出 + +### Requirement: BootstrapOptions 接口 +`bootstrap` 函数 SHALL 接受 `BootstrapOptions` 参数,包含 `configPath: string`、`mode: RuntimeMode`、`staticAssets?: StaticAssets`。 + +#### Scenario: 最小配置 +- **WHEN** 仅传入 configPath 和 mode +- **THEN** 系统 SHALL 正常启动,staticAssets 为 undefined + +### Requirement: dev.ts 和 build entry 使用 bootstrap +`dev.ts` 和 `scripts/build.ts` 生成的 server entry SHALL 调用 `bootstrap()` 而非各自维护启动序列。 + +#### Scenario: dev.ts 调用 bootstrap +- **WHEN** 开发者运行 `bun run dev:server` +- **THEN** `dev.ts` SHALL 调用 `bootstrap` 完成启动 + +#### Scenario: build entry 调用 bootstrap +- **WHEN** 生产可执行文件启动 +- **THEN** 生成的 entry SHALL 调用 `bootstrap` 完成启动 diff --git a/openspec/changes/backend-architecture-hardening/tasks.md b/openspec/changes/backend-architecture-hardening/tasks.md new file mode 100644 index 0000000..607a9a8 --- /dev/null +++ b/openspec/changes/backend-architecture-hardening/tasks.md @@ -0,0 +1,42 @@ +## 1. CheckerDefinition 泛型化 + +- [x] 1.1 修改 `src/server/checker/runner/types.ts`:为 CheckerDefinition 接口添加泛型参数 ``,约束 execute、resolve、serialize 方法的 target 参数类型 +- [x] 1.2 修改 `src/server/checker/runner/registry.ts`:内部 Map 类型使用 `CheckerDefinition`(默认泛型参数),确保类型擦除 +- [x] 1.3 修改 `src/server/checker/runner/http/execute.ts`:HttpChecker 实现 `CheckerDefinition`,移除 execute/serialize 方法内的 `as ResolvedHttpTarget` 断言(resolve 方法内对 RawTargetConfig 的断言保留,泛型不覆盖输入参数窄化) +- [x] 1.4 修改 `src/server/checker/runner/command/execute.ts`:CommandChecker 实现 `CheckerDefinition`,移除 execute/serialize 方法内的 `as ResolvedCommandTarget` 断言(resolve 方法内对 RawTargetConfig 的断言保留) +- [x] 1.5 修复 `src/web/components/ErrorBoundary.tsx` 的 `override` 标记(`noImplicitOverride` 规则要求的既有代码修复),运行 `bun run typecheck` 确认类型系统无错误 + +## 2. ProbeStore 批量查询优化 + +- [x] 2.1 在 `src/server/checker/store.ts` 中新增 `getAllRecentSamples(limit: number)` 方法,使用 `ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY timestamp DESC)` 实现单次批量查询 +- [x] 2.2 修改 `src/server/checker/store.ts` 中 `getAllTargetStats` 的 availability 计算:将 `Math.round((row.upCount / row.totalChecks) * 10000) / 100` 改为 `Math.round((row.upCount / row.totalChecks) * 100 * 100) / 100`,与 `getTargetStats` 精度一致 +- [x] 2.3 修改 `src/server/routes/targets.ts`:`handleTargets` 使用 `store.getAllRecentSamples(30)` 替代循环调用 `store.getRecentSamples` +- [x] 2.4 在 `tests/server/checker/store.test.ts` 中新增 `getAllRecentSamples` 的单元测试和 availability 精度一致性测试 + +## 3. Engine rejected 结果持久化 + +- [x] 3.1 修改 `src/server/checker/engine.ts`:`probeGroup` 中对 rejected 结果通过索引关联 target,构造 `matched: false`、`failure: { kind: "error", phase: "internal", path: "engine", message }` 的 check_result 写入 store +- [x] 3.2 在 `tests/server/checker/engine.test.ts` 中新增 rejected 结果写入的测试用例 + +## 4. 启动逻辑统一 + +- [x] 4.1 新增 `src/server/bootstrap.ts`,导出 `bootstrap(options: BootstrapOptions)` 函数,封装 loadConfig → ProbeStore → syncTargets → ProbeEngine → startServer → shutdown handler 完整序列 +- [x] 4.2 修改 `src/server/dev.ts`:改为调用 `bootstrap({ configPath, mode: "development" })` +- [x] 4.3 修改 `scripts/build.ts`:生成的 server entry 改为调用 `bootstrap({ configPath, mode: "production", staticAssets })` +- [x] 4.4 在 `tests/server/` 中新增 bootstrap 相关测试 + +## 5. dataDir 路径修复 + +- [x] 5.1 修改 `src/server/checker/config-loader.ts`:对 dataDir 使用 `resolve(configDir, dataDir)` 处理相对路径 +- [x] 5.2 在 `tests/server/checker/config-loader.test.ts` 中新增 dataDir 路径解析的测试用例 + +## 6. pageSize 上限 + +- [x] 6.1 修改 `src/server/middleware.ts`:`validatePagination` 增加 `pageSize > 200` 的校验,返回 400 +- [x] 6.2 在 `tests/server/app.test.ts` 中新增 pageSize 超限的测试用例 + +## 7. 质量保障与文档 + +- [x] 7.1 运行 `bun run check`(schema:check + typecheck + lint + test)确认全部通过 +- [x] 7.2 运行 `bun run build` 确认构建成功 +- [x] 7.3 更新 DEVELOPMENT.md 中相关章节(bootstrap 启动流程、CheckerDefinition 泛型说明、pageSize 上限说明) diff --git a/scripts/build.ts b/scripts/build.ts index 5a5a04a..a277814 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -107,37 +107,13 @@ ${assetEntries.join("\n")} async function writeGeneratedEntry() { await writeFile( generatedEntryPath, - `import { loadConfig } from "../src/server/checker/config-loader"; -import { ProbeStore } from "../src/server/checker/store"; -import { ProbeEngine } from "../src/server/checker/engine"; -import { startServer } from "../src/server/server"; + `import { bootstrap } from "../src/server/bootstrap"; import { readRuntimeConfig } from "../src/server/config"; import { staticAssets } from "./static-assets"; async function main() { const { configPath } = readRuntimeConfig(); - const config = await loadConfig(configPath); - - const store = new ProbeStore(config.dataDir + "/probe.db"); - store.syncTargets(config.targets); - - const engine = new ProbeEngine(store, config.targets, config.maxConcurrentChecks, config.retentionMs); - engine.start(); - - const shutdown = () => { - engine.stop(); - store.close(); - process.exit(0); - }; - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); - - startServer({ - config: { host: config.host, port: config.port }, - mode: "production", - staticAssets, - store, - }); + await bootstrap({ configPath, mode: "production", staticAssets }); } void main().catch((error) => { diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts new file mode 100644 index 0000000..2ccc086 --- /dev/null +++ b/src/server/bootstrap.ts @@ -0,0 +1,83 @@ +import { join } from "node:path"; + +import type { RuntimeMode } from "../shared/api"; +import type { StaticAssets } from "./app"; +import type { StartServerOptions } from "./server"; + +import { loadConfig, type ResolvedConfig } from "./checker/config-loader"; +import { ProbeEngine } from "./checker/engine"; +import { ProbeStore } from "./checker/store"; +import { startServer } from "./server"; + +export interface BootstrapDependencies { + createEngine?: ( + store: ProbeStore, + targets: ResolvedConfig["targets"], + maxConcurrentChecks: number, + retentionMs: number, + ) => BootstrapEngine; + createStore?: (dbPath: string) => ProbeStore; + exit?: (code: number) => never; + loadConfig?: (configPath: string) => Promise; + logError?: (...data: unknown[]) => void; + onSignal?: (signal: ShutdownSignal, handler: () => void) => void; + startServer?: (options: StartServerOptions) => unknown; +} + +export interface BootstrapOptions { + configPath: string; + mode: RuntimeMode; + staticAssets?: StaticAssets; +} + +type BootstrapEngine = Pick; +type ShutdownSignal = "SIGINT" | "SIGTERM"; + +export async function bootstrap(options: BootstrapOptions, dependencies: BootstrapDependencies = {}): Promise { + const load = dependencies.loadConfig ?? loadConfig; + const createStore = dependencies.createStore ?? ((dbPath: string) => new ProbeStore(dbPath)); + const createEngine = + dependencies.createEngine ?? + ((store: ProbeStore, targets: ResolvedConfig["targets"], maxConcurrentChecks: number, retentionMs: number) => + new ProbeEngine(store, targets, maxConcurrentChecks, retentionMs)); + const serve = dependencies.startServer ?? startServer; + const onSignal = + dependencies.onSignal ?? + ((signal: ShutdownSignal, handler: () => void) => { + process.on(signal, handler); + }); + const exit = dependencies.exit ?? ((code: number) => process.exit(code)); + const logError = dependencies.logError ?? console.error; + + let store: ProbeStore | undefined; + let engine: BootstrapEngine | undefined; + + try { + const config = await load(options.configPath); + store = createStore(join(config.dataDir, "probe.db")); + store.syncTargets(config.targets); + + engine = createEngine(store, config.targets, config.maxConcurrentChecks, config.retentionMs); + engine.start(); + + const shutdown = () => { + engine?.stop(); + store?.close(); + exit(0); + }; + onSignal("SIGINT", shutdown); + onSignal("SIGTERM", shutdown); + + serve({ + config: { host: config.host, port: config.port }, + mode: options.mode, + staticAssets: options.staticAssets, + store, + }); + } catch (error) { + engine?.stop(); + store?.close(); + logError("启动失败:", error instanceof Error ? error.message : error); + exit(1); + } +} diff --git a/src/server/checker/config-loader.ts b/src/server/checker/config-loader.ts index bcea036..839787f 100644 --- a/src/server/checker/config-loader.ts +++ b/src/server/checker/config-loader.ts @@ -67,7 +67,7 @@ export async function loadConfig(configPath: string): Promise { const host = server.host ?? DEFAULT_HOST; const port = server.port ?? DEFAULT_PORT; - const dataDir = server.dataDir ?? DEFAULT_DATA_DIR; + const dataDir = resolve(configDir, server.dataDir ?? DEFAULT_DATA_DIR); const maxConcurrentChecks = resolveMaxConcurrentChecks(runtime); const retentionMs = resolveRetention(runtime); diff --git a/src/server/checker/engine.ts b/src/server/checker/engine.ts index 4b0d302..cc208b1 100644 --- a/src/server/checker/engine.ts +++ b/src/server/checker/engine.ts @@ -1,8 +1,9 @@ -import { groupBy, Semaphore } from "es-toolkit"; +import { groupBy, isError, Semaphore } from "es-toolkit"; import type { ProbeStore } from "./store"; import type { CheckResult, ResolvedTargetBase } from "./types"; +import { errorFailure } from "./expect/failure"; import { checkerRegistry } from "./runner"; const PRUNE_INTERVAL_MS = 3600000; @@ -64,11 +65,21 @@ export class ProbeEngine { }), ); - for (const result of results) { + for (const [index, result] of results.entries()) { if (result.status === "fulfilled") { this.writeResult(result.value); } else { + const target = targets[index]; console.warn("探针执行失败:", result.reason); + if (!target) continue; + this.writeResult({ + durationMs: null, + failure: errorFailure("internal", "engine", formatReason(result.reason)), + matched: false, + statusDetail: null, + targetName: target.name, + timestamp: new Date().toISOString(), + }); } } } @@ -106,3 +117,7 @@ export class ProbeEngine { }); } } + +function formatReason(reason: unknown): string { + return isError(reason) ? reason.message : String(reason); +} diff --git a/src/server/checker/runner/command/execute.ts b/src/server/checker/runner/command/execute.ts index b8a6bfa..09e0029 100644 --- a/src/server/checker/runner/command/execute.ts +++ b/src/server/checker/runner/command/execute.ts @@ -1,8 +1,8 @@ import { isError } from "es-toolkit"; import { resolve } from "node:path"; -import type { CheckResult, RawTargetConfig, ResolvedTargetBase } from "../../types"; -import type { Checker, CheckerContext, CheckerValidationInput, ResolveContext } from "../types"; +import type { CheckResult, RawTargetConfig } from "../../types"; +import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; import type { CommandExpectConfig, CommandTargetConfig, ResolvedCommandTarget } from "./types"; import { checkDuration } from "../../expect/duration"; @@ -13,15 +13,14 @@ import { commandCheckerSchemas } from "./schema"; import { checkTextRules } from "./text"; import { validateCommandConfig } from "./validate"; -export class CommandChecker implements Checker { +export class CommandChecker implements CheckerDefinition { readonly configKey = "command"; readonly schemas = commandCheckerSchemas; readonly type = "command"; - async execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise { - const t = target as ResolvedCommandTarget; + async execute(t: ResolvedCommandTarget, ctx: CheckerContext): Promise { const timestamp = new Date().toISOString(); const start = performance.now(); @@ -169,7 +168,7 @@ export class CommandChecker implements Checker { }; } - resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTargetBase { + resolve(target: RawTargetConfig, context: ResolveContext): ResolvedCommandTarget { const t = target as RawTargetConfig & { command: CommandTargetConfig; type: "command" }; const commandDefaults = context.defaults["command"] as undefined | { cwd?: string; maxOutputBytes?: string }; @@ -197,8 +196,7 @@ export class CommandChecker implements Checker { } satisfies ResolvedCommandTarget; } - serialize(target: ResolvedTargetBase): { config: string; target: string } { - const t = target as ResolvedCommandTarget; + serialize(t: ResolvedCommandTarget): { config: string; target: string } { const parts = [t.command.exec, ...t.command.args]; return { config: JSON.stringify({ diff --git a/src/server/checker/runner/http/execute.ts b/src/server/checker/runner/http/execute.ts index 7642c84..c94e0de 100644 --- a/src/server/checker/runner/http/execute.ts +++ b/src/server/checker/runner/http/execute.ts @@ -1,7 +1,7 @@ import { isError } from "es-toolkit"; -import type { CheckResult, RawTargetConfig, ResolvedTargetBase } from "../../types"; -import type { Checker, CheckerContext, CheckerValidationInput, ResolveContext } from "../types"; +import type { CheckResult, RawTargetConfig } from "../../types"; +import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; import type { HttpExpectConfig, HttpTargetConfig, ResolvedHttpTarget } from "./types"; import { checkDuration } from "../../expect/duration"; @@ -16,15 +16,14 @@ const CHARSET_RE = /charset="?([^";\s]+)"?/i; const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]); -export class HttpChecker implements Checker { +export class HttpChecker implements CheckerDefinition { readonly configKey = "http"; readonly schemas = httpCheckerSchemas; readonly type = "http"; - async execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise { - const t = target as ResolvedHttpTarget; + async execute(t: ResolvedHttpTarget, ctx: CheckerContext): Promise { const timestamp = new Date().toISOString(); const expect = t.expect; const start = performance.now(); @@ -117,7 +116,7 @@ export class HttpChecker implements Checker { } } - resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTargetBase { + resolve(target: RawTargetConfig, context: ResolveContext): ResolvedHttpTarget { const t = target as RawTargetConfig & { http: HttpTargetConfig; type: "http" }; const httpDefaults = context.defaults["http"] as | undefined @@ -145,8 +144,7 @@ export class HttpChecker implements Checker { } satisfies ResolvedHttpTarget; } - serialize(target: ResolvedTargetBase): { config: string; target: string } { - const t = target as ResolvedHttpTarget; + serialize(t: ResolvedHttpTarget): { config: string; target: string } { return { config: JSON.stringify({ body: t.http.body, diff --git a/src/server/checker/runner/types.ts b/src/server/checker/runner/types.ts index 3e90b5a..c28eb5c 100644 --- a/src/server/checker/runner/types.ts +++ b/src/server/checker/runner/types.ts @@ -3,18 +3,18 @@ import type { TSchema } from "@sinclair/typebox"; import type { ConfigValidationIssue } from "../schema/issues"; import type { CheckResult, DefaultsConfig, RawTargetConfig, ResolvedTargetBase } from "../types"; -export type Checker = CheckerDefinition; +export type Checker = CheckerDefinition; export interface CheckerContext { signal: AbortSignal; } -export interface CheckerDefinition { +export interface CheckerDefinition { readonly configKey: string; - execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise; - resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTargetBase; + execute(target: TResolved, ctx: CheckerContext): Promise; + resolve(target: RawTargetConfig, context: ResolveContext): TResolved; readonly schemas: CheckerSchemas; - serialize(target: ResolvedTargetBase): { config: string; target: string }; + serialize(target: TResolved): { config: string; target: string }; readonly type: string; validate(input: CheckerValidationInput): ConfigValidationIssue[]; } diff --git a/src/server/checker/store.ts b/src/server/checker/store.ts index cef3ebc..0afda87 100644 --- a/src/server/checker/store.ts +++ b/src/server/checker/store.ts @@ -57,6 +57,40 @@ export class ProbeStore { this.db.close(); } + getAllRecentSamples( + limit: number, + ): Map> { + const rows = this.db + .query( + `SELECT target_id, timestamp, duration_ms, matched + FROM ( + SELECT + target_id, + timestamp, + duration_ms, + matched, + ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY timestamp DESC) as row_num + FROM check_results + ) + WHERE row_num <= ? + ORDER BY target_id, timestamp DESC`, + ) + .all(limit) as Array<{ + duration_ms: null | number; + matched: number; + target_id: number; + timestamp: string; + }>; + + const result = new Map>(); + for (const row of rows) { + const samples = result.get(row.target_id) ?? []; + samples.push({ duration_ms: row.duration_ms, matched: row.matched, timestamp: row.timestamp }); + result.set(row.target_id, samples); + } + return result; + } + getAllTargetStats(): Map { const rows = this.db .query( @@ -69,7 +103,7 @@ export class ProbeStore { const result = new Map(); for (const row of rows) { - const availability = row.totalChecks > 0 ? Math.round((row.upCount / row.totalChecks) * 10000) / 100 : 0; + const availability = row.totalChecks > 0 ? Math.round((row.upCount / row.totalChecks) * 100 * 100) / 100 : 0; result.set(row.target_id, { availability, totalChecks: row.totalChecks }); } return result; diff --git a/src/server/dev.ts b/src/server/dev.ts index 88c16d9..a88019c 100644 --- a/src/server/dev.ts +++ b/src/server/dev.ts @@ -1,32 +1,9 @@ -import { loadConfig } from "./checker/config-loader"; -import { ProbeEngine } from "./checker/engine"; -import { ProbeStore } from "./checker/store"; +import { bootstrap } from "./bootstrap"; import { readRuntimeConfig } from "./config"; -import { startServer } from "./server"; async function main() { const { configPath } = readRuntimeConfig(); - const config = await loadConfig(configPath); - - const store = new ProbeStore(`${config.dataDir}/probe.db`); - store.syncTargets(config.targets); - - const engine = new ProbeEngine(store, config.targets, config.maxConcurrentChecks, config.retentionMs); - engine.start(); - - const shutdown = () => { - engine.stop(); - store.close(); - process.exit(0); - }; - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); - - startServer({ - config: { host: config.host, port: config.port }, - mode: "development", - store, - }); + await bootstrap({ configPath, mode: "development" }); } void main().catch((error) => { diff --git a/src/server/middleware.ts b/src/server/middleware.ts index 05ed9ae..8e91a66 100644 --- a/src/server/middleware.ts +++ b/src/server/middleware.ts @@ -2,6 +2,8 @@ import type { RuntimeMode } from "../shared/api"; import { allowsGetHead, createApiError, jsonResponse, methodNotAllowedResponse } from "./helpers"; +const MAX_PAGE_SIZE = 200; + export function guardGetHead(method: string, mode: RuntimeMode): null | Response { if (!allowsGetHead(method)) { return methodNotAllowedResponse(["GET", "HEAD"], mode); @@ -29,6 +31,9 @@ export function validatePagination( if (!Number.isInteger(pageSize) || pageSize <= 0) { return jsonResponse(createApiError("Invalid pageSize parameter", 400), { mode, status: 400 }); } + if (pageSize > MAX_PAGE_SIZE) { + return jsonResponse(createApiError(`pageSize must not exceed ${MAX_PAGE_SIZE}`, 400), { mode, status: 400 }); + } } return { page, pageSize }; diff --git a/src/server/routes/targets.ts b/src/server/routes/targets.ts index f0134b5..79f5b53 100644 --- a/src/server/routes/targets.ts +++ b/src/server/routes/targets.ts @@ -7,11 +7,12 @@ export function handleTargets(store: ProbeStore, method: string, mode: RuntimeMo const targets = store.getTargets(); const latestChecksMap = store.getLatestChecksMap(); const allStats = store.getAllTargetStats(); + const allRecentSamples = store.getAllRecentSamples(30); const result: TargetStatus[] = targets.map((target) => { const latest = latestChecksMap.get(target.id) ?? null; const stats = allStats.get(target.id) ?? { availability: 0, totalChecks: 0 }; - const recentSamples = store.getRecentSamples(target.id, 30); + const recentSamples = allRecentSamples.get(target.id) ?? []; return { group: target.grp, diff --git a/src/web/components/ErrorBoundary.tsx b/src/web/components/ErrorBoundary.tsx index 3122c9b..ddf2dae 100644 --- a/src/web/components/ErrorBoundary.tsx +++ b/src/web/components/ErrorBoundary.tsx @@ -12,17 +12,17 @@ interface State { } export class ErrorBoundary extends Component { - state: State = { hasError: false }; + override state: State = { hasError: false }; static getDerivedStateFromError(): State { return { hasError: true }; } - componentDidCatch(error: Error, info: ErrorInfo): void { + override componentDidCatch(error: Error, info: ErrorInfo): void { console.error("渲染错误:", error, info.componentStack); } - render() { + override render() { if (this.state.hasError) { return ( diff --git a/tests/server/app.test.ts b/tests/server/app.test.ts index fc0ee4b..e5acafe 100644 --- a/tests/server/app.test.ts +++ b/tests/server/app.test.ts @@ -183,6 +183,19 @@ describe("API 路由", () => { expect(body.total).toBe(2); }); + test("history pageSize 超过上限返回 400", async () => { + const targets = store.getTargets(); + const from = "2024-01-01T00:00:00.000Z"; + const to = "2026-12-31T23:59:59.999Z"; + const response = fetchHandler( + new Request(`http://localhost/api/targets/${targets[0]!.id}/history?from=${from}&to=${to}&pageSize=201`), + ); + const body = (await response.json()) as Record; + + expect(response.status).toBe(400); + expect(body["error"]).toBe("pageSize must not exceed 200"); + }); + test("/api/targets/:id/trend 返回趋势数据", async () => { const targets = store.getTargets(); const from = "2024-01-01T00:00:00.000Z"; diff --git a/tests/server/bootstrap.test.ts b/tests/server/bootstrap.test.ts new file mode 100644 index 0000000..04cca28 --- /dev/null +++ b/tests/server/bootstrap.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from "bun:test"; + +import type { StaticAssets } from "../../src/server/app"; +import type { ResolvedConfig } from "../../src/server/checker/config-loader"; +import type { ProbeEngine } from "../../src/server/checker/engine"; +import type { ProbeStore } from "../../src/server/checker/store"; +import type { ResolvedTargetBase } from "../../src/server/checker/types"; + +import { bootstrap, type BootstrapDependencies } from "../../src/server/bootstrap"; + +type ShutdownSignal = "SIGINT" | "SIGTERM"; + +const target: ResolvedTargetBase = { + group: "default", + intervalMs: 30000, + name: "test", + timeoutMs: 5000, + type: "command", +}; + +function createHarness(overrides: BootstrapDependencies = {}) { + const calls: string[] = []; + const shutdownHandlers = new Map void>(); + const config: ResolvedConfig = { + configDir: "/tmp", + dataDir: "/tmp/dial-data", + host: "127.0.0.1", + maxConcurrentChecks: 3, + port: 3000, + retentionMs: 1000, + targets: [target], + }; + const store = { + close() { + calls.push("store.close"); + }, + syncTargets(targets: ResolvedTargetBase[]) { + calls.push(`syncTargets:${targets.length}`); + }, + } as unknown as ProbeStore; + const engine = { + start() { + calls.push("engine.start"); + }, + stop() { + calls.push("engine.stop"); + }, + } as unknown as ProbeEngine; + + const dependencies: BootstrapDependencies = { + createEngine(actualStore, targets, maxConcurrentChecks, retentionMs) { + expect(actualStore).toBe(store); + calls.push(`createEngine:${targets.length}:${maxConcurrentChecks}:${retentionMs}`); + return engine; + }, + createStore(dbPath) { + calls.push(`createStore:${dbPath}`); + return store; + }, + exit(code) { + calls.push(`exit:${code}`); + throw new Error(`exit:${code}`); + }, + loadConfig(configPath) { + calls.push(`loadConfig:${configPath}`); + return Promise.resolve(config); + }, + logError(...data) { + calls.push(`logError:${String(data[1])}`); + }, + onSignal(signal, handler) { + calls.push(`onSignal:${signal}`); + shutdownHandlers.set(signal, handler); + }, + startServer(options) { + expect(options.config).toEqual({ host: config.host, port: config.port }); + expect(options.store).toBe(store); + calls.push(`startServer:${options.mode}:${options.staticAssets ? "static" : "no-static"}`); + }, + ...overrides, + }; + + return { calls, dependencies, shutdownHandlers }; +} + +describe("bootstrap", () => { + test("开发模式执行完整启动序列", async () => { + const { calls, dependencies } = createHarness(); + + await bootstrap({ configPath: "/tmp/probes.yaml", mode: "development" }, dependencies); + + expect(calls).toEqual([ + "loadConfig:/tmp/probes.yaml", + "createStore:/tmp/dial-data/probe.db", + "syncTargets:1", + "createEngine:1:3:1000", + "engine.start", + "onSignal:SIGINT", + "onSignal:SIGTERM", + "startServer:development:no-static", + ]); + }); + + test("生产模式传递 staticAssets", async () => { + const { calls, dependencies } = createHarness(); + const staticAssets: StaticAssets = { files: {}, indexHtml: new Blob(["ok"]) }; + + await bootstrap({ configPath: "/tmp/probes.yaml", mode: "production", staticAssets }, dependencies); + + expect(calls.at(-1)).toBe("startServer:production:static"); + }); + + test("收到退出信号时停止 engine 并关闭 store", async () => { + const { calls, dependencies, shutdownHandlers } = createHarness(); + + await bootstrap({ configPath: "/tmp/probes.yaml", mode: "development" }, dependencies); + + expect(() => shutdownHandlers.get("SIGINT")!()).toThrow("exit:0"); + + expect(calls.slice(-3)).toEqual(["engine.stop", "store.close", "exit:0"]); + }); + + test("启动失败时输出错误并以非零退出", async () => { + const { calls, dependencies } = createHarness({ + loadConfig() { + return Promise.reject(new Error("bad config")); + }, + }); + + let error: unknown; + try { + await bootstrap({ configPath: "/tmp/probes.yaml", mode: "development" }, dependencies); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("exit:1"); + expect(calls).toEqual(["logError:bad config", "exit:1"]); + }); +}); diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index 247c6b7..2a302e1 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -116,7 +116,7 @@ describe("loadConfig", () => { const config = await loadConfig(configPath); expect(config.host).toBe("127.0.0.1"); expect(config.port).toBe(3000); - expect(config.dataDir).toBe("./data"); + expect(config.dataDir).toBe(join(tempDir, "data")); expect(config.maxConcurrentChecks).toBe(20); expect(config.targets).toHaveLength(1); const t = config.targets[0]! as ResolvedHttpTarget; @@ -205,7 +205,7 @@ targets: const config = await loadConfig(configPath); expect(config.host).toBe("0.0.0.0"); expect(config.port).toBe(8080); - expect(config.dataDir).toBe("./my-data"); + expect(config.dataDir).toBe(join(tempDir, "my-data")); expect(config.maxConcurrentChecks).toBe(5); expect(config.targets).toHaveLength(2); @@ -228,6 +228,25 @@ targets: expect(cmd.command.maxOutputBytes).toBe(10485760); }); + test("绝对 dataDir 保持不变", async () => { + const dataDir = join(tempDir, "absolute-data"); + const configPath = join(tempDir, "absolute-data-dir.yaml"); + await writeFile( + configPath, + `server: + dataDir: "${dataDir}" +targets: + - name: "test" + type: http + http: + url: "http://example.com" +`, + ); + + const config = await loadConfig(configPath); + expect(config.dataDir).toBe(dataDir); + }); + test("per-target 覆盖 defaults", async () => { const configPath = join(tempDir, "override.yaml"); await writeFile( diff --git a/tests/server/checker/engine.test.ts b/tests/server/checker/engine.test.ts index 45a7c96..5b373cd 100644 --- a/tests/server/checker/engine.test.ts +++ b/tests/server/checker/engine.test.ts @@ -131,6 +131,48 @@ describe("ProbeEngine", () => { expect(goodResult).toBeDefined(); }); + test("checker rejected 时写入 internal error 结果", async () => { + ensureRegistered(); + const checker = checkerRegistry.get("command"); + const originalExecute = checker.execute.bind(checker); + checker.execute = async (target, ctx) => { + if (target.name === "reject-cmd") { + throw new Error("boom"); + } + return originalExecute(target, ctx); + }; + + try { + const rejectTarget = makeCommandTarget("reject-cmd"); + const goodTarget = makeCommandTarget("good-cmd"); + const mockStore = createMockStore(["reject-cmd", "good-cmd"]) as unknown as ProbeStore; + const engine = new ProbeEngine(mockStore, [rejectTarget, goodTarget]); + + const probeGroup = ( + engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise } + ).probeGroup.bind(engine); + await probeGroup([rejectTarget, goodTarget]); + + const results = (mockStore as unknown as { _results: Array> })._results; + expect(results.length).toBe(2); + expect(results[0]!["targetId"]).toBe(1); + expect(results[0]!["matched"]).toBe(false); + expect(results[0]!["durationMs"]).toBeNull(); + expect(results[0]!["statusDetail"]).toBeNull(); + expect(results[0]!["failure"]).toEqual({ + kind: "error", + message: "boom", + path: "engine", + phase: "internal", + }); + expect(typeof results[0]!["timestamp"]).toBe("string"); + expect(results[1]!["targetId"]).toBe(2); + expect(results[1]!["matched"]).toBe(true); + } finally { + checker.execute = originalExecute; + } + }); + test("并发限制 maxConcurrentChecks", async () => { const targets = Array.from({ length: 5 }, (_, i) => makeCommandTarget(`cmd-${i}`, { diff --git a/tests/server/checker/runner/http/runner.test.ts b/tests/server/checker/runner/http/runner.test.ts index 809a323..ab5aaf4 100644 --- a/tests/server/checker/runner/http/runner.test.ts +++ b/tests/server/checker/runner/http/runner.test.ts @@ -757,7 +757,7 @@ describe("HttpChecker.resolve", () => { { http: { url: "https://example.com" }, name: "test", type: "http" }, makeResolveContext(), ); - expect((result as ResolvedHttpTarget).http.ignoreSSL).toBe(false); + expect(result.http.ignoreSSL).toBe(false); }); test("maxRedirects 默认值为 0", () => { @@ -765,7 +765,7 @@ describe("HttpChecker.resolve", () => { { http: { url: "https://example.com" }, name: "test", type: "http" }, makeResolveContext(), ); - expect((result as ResolvedHttpTarget).http.maxRedirects).toBe(0); + expect(result.http.maxRedirects).toBe(0); }); test("合法 status 范围模式通过校验", () => { @@ -773,7 +773,7 @@ describe("HttpChecker.resolve", () => { { expect: { status: ["2xx", 301] }, http: { url: "https://example.com" }, name: "test", type: "http" }, makeResolveContext(), ); - expect((result as ResolvedHttpTarget).expect?.status).toEqual(["2xx", 301]); + expect(result.expect?.status).toEqual(["2xx", 301]); }); test("显式 ignoreSSL 和 maxRedirects 正确解析", () => { @@ -781,7 +781,7 @@ describe("HttpChecker.resolve", () => { { http: { ignoreSSL: true, maxRedirects: 3, url: "https://example.com" }, name: "test", type: "http" }, makeResolveContext(), ); - expect((result as ResolvedHttpTarget).http.ignoreSSL).toBe(true); - expect((result as ResolvedHttpTarget).http.maxRedirects).toBe(3); + expect(result.http.ignoreSSL).toBe(true); + expect(result.http.maxRedirects).toBe(3); }); }); diff --git a/tests/server/checker/store.test.ts b/tests/server/checker/store.test.ts index e43bce9..bd7e9f1 100644 --- a/tests/server/checker/store.test.ts +++ b/tests/server/checker/store.test.ts @@ -280,6 +280,71 @@ describe("ProbeStore", () => { } }); + test("getAllRecentSamples 返回每个 target 的最近采样数据", () => { + const sampleStore = new ProbeStore(join(tempDir, "all-samples.db")); + const httpA: ResolvedHttpTarget = { ...httpTarget, name: "sample-http-a" }; + const httpB: ResolvedHttpTarget = { + ...httpTarget, + http: { ...httpTarget.http, url: "https://example.com/other" }, + name: "sample-http-b", + }; + const httpEmpty: ResolvedHttpTarget = { + ...httpTarget, + http: { ...httpTarget.http, url: "https://example.com/empty" }, + name: "sample-http-empty", + }; + 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; + + for (const [index, timestamp] of [ + "2025-01-01T00:00:00.000Z", + "2025-01-01T00:01:00.000Z", + "2025-01-01T00:02:00.000Z", + ].entries()) { + sampleStore.insertCheckResult({ + durationMs: 100 + index, + failure: null, + matched: index !== 1, + statusDetail: "200 OK", + targetId: targetAId, + timestamp, + }); + } + sampleStore.insertCheckResult({ + durationMs: 200, + failure: null, + matched: true, + statusDetail: "200 OK", + targetId: targetBId, + timestamp: "2025-01-01T00:03:00.000Z", + }); + sampleStore.insertCheckResult({ + durationMs: null, + failure: { kind: "error", message: "fail", path: "request", phase: "request" }, + matched: false, + statusDetail: null, + targetId: targetBId, + timestamp: "2025-01-01T00:04:00.000Z", + }); + + const samples = sampleStore.getAllRecentSamples(2); + + expect(samples.get(targetAId)).toEqual([ + { duration_ms: 102, matched: 1, timestamp: "2025-01-01T00:02:00.000Z" }, + { duration_ms: 101, matched: 0, timestamp: "2025-01-01T00:01:00.000Z" }, + ]); + expect(samples.get(targetBId)).toEqual([ + { duration_ms: null, matched: 0, timestamp: "2025-01-01T00:04:00.000Z" }, + { duration_ms: 200, matched: 1, timestamp: "2025-01-01T00:03:00.000Z" }, + ]); + expect(samples.has(emptyTargetId)).toBe(false); + + sampleStore.close(); + }); + test("关闭后操作不报错", () => { const closedStore = new ProbeStore(join(tempDir, "closed.db")); closedStore.close(); @@ -420,6 +485,33 @@ describe("ProbeStore", () => { freshStore.close(); }); + test("getAllTargetStats 与 getTargetStats 的 availability 精度一致", () => { + const statsStore = new ProbeStore(join(tempDir, "stats-precision.db")); + const target: ResolvedHttpTarget = { ...httpTarget, name: "stats-precision" }; + statsStore.syncTargets([target]); + const targetId = statsStore.getTargets()[0]!.id; + + for (const [index, matched] of [true, true, false].entries()) { + statsStore.insertCheckResult({ + durationMs: 100, + failure: null, + matched, + statusDetail: matched ? "200 OK" : "500 ERROR", + targetId, + timestamp: `2025-01-01T00:0${index}:00.000Z`, + }); + } + + const targetStats = statsStore.getTargetStats(targetId); + const allStats = statsStore.getAllTargetStats().get(targetId)!; + + expect(targetStats.availability).toBe(66.67); + expect(allStats.availability).toBe(66.67); + expect(allStats.availability).toBe(targetStats.availability); + + statsStore.close(); + }); + test("prune 删除过期数据", () => { const pruneStore = new ProbeStore(join(tempDir, "prune.db")); pruneStore.syncTargets([httpTarget]);