1
0

refactor: 后端架构加固 — 泛型化、批量查询、bootstrap 统一、路径修复与 pageSize 上限

- 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 文档,新增完整测试覆盖
This commit is contained in:
2026-05-13 18:15:46 +08:00
parent 6ea185315f
commit 147a2559ae
30 changed files with 930 additions and 129 deletions

View File

@@ -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 shutdownengine.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<TResolved extends ResolvedTargetBase = ResolvedTargetBase>` 使用泛型约束 `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()` | 查询全部 targetsdefault 分组优先排序) |
| `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()` | 查询全部 targetsdefault 分组优先排序) |
| `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` 触发 abortchecker 必须使用 `CheckerContext.signal` 感知超时HTTP 将 signal 传给 `fetch()`Command 在 signal abort 时 `proc.kill()`
- **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLiteengine 通过 `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

View File

@@ -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 路由 |
## 运行参数

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-13

View File

@@ -0,0 +1,121 @@
## Context
DiAL 后端是基于 Bun 的拨测服务,当前有 2 个 checker 类型http、commandtarget 规模预计增长到 100checker 类型预计超过 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 signatureregistry 层仍用类型擦除)
- 不改变前端行为
## 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<TResolved extends ResolvedTargetBase = ResolvedTargetBase> {
execute(target: TResolved, ctx: CheckerContext): Promise<CheckResult>;
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<ResolvedTargetBase>`(类型擦除)
- 各 checker 实现 `implements CheckerDefinition<ResolvedHttpTarget>` 等具体类型
- 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 类型。

View File

@@ -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_resultfailure 标记为 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` 加泛型参数 `<TResolved extends ResolvedTargetBase>`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 相关测试

View File

@@ -0,0 +1,44 @@
## ADDED Requirements
### Requirement: 批量查询所有目标的最近采样数据
系统 SHALL 提供 `getAllRecentSamples(limit: number)` 方法,通过单次 SQL 查询获取所有 target 的最近 N 条采样数据,返回 `Map<number, Array<{ timestamp: string; duration_ms: number | null; matched: number }>>` 结构。
#### 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<number, { totalChecks, availability }>`
#### Scenario: availability 精度
- **WHEN** 计算 availabilityupCount / totalChecks * 100
- **THEN** 结果 SHALL 使用 `Math.round(value * 100) / 100` 四舍五入保留两位小数,与 `getTargetStats` 方法一致
#### Scenario: 目标无历史记录
- **WHEN** 某 target 在 check_results 表中无任何记录
- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key

View File

@@ -0,0 +1,47 @@
## MODIFIED Requirements
### Requirement: Checker 接口定义
系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的泛型 `CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase>`,包含 `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<TResolved>` 接口签名
- **THEN** `resolve` 的返回值 SHALL 为 `TResolved``execute` 的参数 SHALL 为 `TResolved``serialize` 的参数 SHALL 为 `TResolved`
#### Scenario: checker 实现无需手动断言
- **WHEN** HttpChecker 实现 `CheckerDefinition<ResolvedHttpTarget>`
- **THEN** `execute` 方法的 target 参数类型 SHALL 直接为 `ResolvedHttpTarget`,无需在方法内部使用 `as` 类型断言
#### Scenario: registry 使用默认泛型参数
- **WHEN** CheckerRegistry 存储和返回 checker 实例
- **THEN** registry 内部 SHALL 使用 `CheckerDefinition`(等价于 `CheckerDefinition<ResolvedTargetBase>`),实现类型擦除
### 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"]`(按注册顺序)

View File

@@ -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 状态码和错误信息

View File

@@ -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 解析为绝对路径

View File

@@ -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_resultfailure 为 `{ kind: "error", phase: "internal", path: "engine", message: <rejected reason> }`,其他目标的检查 SHALL 不受影响
#### Scenario: rejected 结果通过索引关联 targetName
- **WHEN** checker 执行 rejected
- **THEN** 系统 SHALL 通过 Promise.allSettled 的索引关联回 target 数组,获取对应的 targetName 用于写入 check_result
#### Scenario: 全局并发限制生效
- **WHEN** 调度器同时触发 10 个目标且 runtime.maxConcurrentChecks 为 3
- **THEN** 系统 MUST 同时最多运行 3 个检查,其余检查等待并发槽位释放

View File

@@ -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` 完成启动

View File

@@ -0,0 +1,42 @@
## 1. CheckerDefinition 泛型化
- [x] 1.1 修改 `src/server/checker/runner/types.ts`:为 CheckerDefinition 接口添加泛型参数 `<TResolved extends ResolvedTargetBase = ResolvedTargetBase>`,约束 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<ResolvedHttpTarget>`,移除 execute/serialize 方法内的 `as ResolvedHttpTarget` 断言resolve 方法内对 RawTargetConfig 的断言保留,泛型不覆盖输入参数窄化)
- [x] 1.4 修改 `src/server/checker/runner/command/execute.ts`CommandChecker 实现 `CheckerDefinition<ResolvedCommandTarget>`,移除 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 上限说明)

View File

@@ -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) => {

83
src/server/bootstrap.ts Normal file
View File

@@ -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<ResolvedConfig>;
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<ProbeEngine, "start" | "stop">;
type ShutdownSignal = "SIGINT" | "SIGTERM";
export async function bootstrap(options: BootstrapOptions, dependencies: BootstrapDependencies = {}): Promise<void> {
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);
}
}

View File

@@ -67,7 +67,7 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
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);

View File

@@ -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);
}

View File

@@ -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<ResolvedCommandTarget> {
readonly configKey = "command";
readonly schemas = commandCheckerSchemas;
readonly type = "command";
async execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise<CheckResult> {
const t = target as ResolvedCommandTarget;
async execute(t: ResolvedCommandTarget, ctx: CheckerContext): Promise<CheckResult> {
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({

View File

@@ -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<ResolvedHttpTarget> {
readonly configKey = "http";
readonly schemas = httpCheckerSchemas;
readonly type = "http";
async execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise<CheckResult> {
const t = target as ResolvedHttpTarget;
async execute(t: ResolvedHttpTarget, ctx: CheckerContext): Promise<CheckResult> {
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,

View File

@@ -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<TResolved extends ResolvedTargetBase = ResolvedTargetBase> = CheckerDefinition<TResolved>;
export interface CheckerContext {
signal: AbortSignal;
}
export interface CheckerDefinition {
export interface CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase> {
readonly configKey: string;
execute(target: ResolvedTargetBase, ctx: CheckerContext): Promise<CheckResult>;
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTargetBase;
execute(target: TResolved, ctx: CheckerContext): Promise<CheckResult>;
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[];
}

View File

@@ -57,6 +57,40 @@ export class ProbeStore {
this.db.close();
}
getAllRecentSamples(
limit: number,
): Map<number, Array<{ duration_ms: null | number; matched: number; timestamp: string }>> {
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<number, Array<{ duration_ms: null | number; matched: number; timestamp: string }>>();
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<number, { availability: number; totalChecks: number }> {
const rows = this.db
.query(
@@ -69,7 +103,7 @@ export class ProbeStore {
const result = new Map<number, { availability: number; totalChecks: number }>();
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;

View File

@@ -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) => {

View File

@@ -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 };

View File

@@ -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,

View File

@@ -12,17 +12,17 @@ interface State {
}
export class ErrorBoundary extends Component<Props, State> {
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 (
<Space align="center" className="error-boundary-fallback" direction="vertical" size="large">

View File

@@ -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<string, unknown>;
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";

View File

@@ -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<ShutdownSignal, () => 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"]);
});
});

View File

@@ -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(

View File

@@ -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<void> }
).probeGroup.bind(engine);
await probeGroup([rejectTarget, goodTarget]);
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._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}`, {

View File

@@ -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);
});
});

View File

@@ -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]);