From f7facb72323a4f5dadaacb505e6723f7c45985e7 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Tue, 12 May 2026 15:15:36 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=85=A8=E9=9D=A2=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=90=8E=E7=AB=AF=E4=BB=A3=E7=A0=81=E8=B4=A8=E9=87=8F?= =?UTF-8?q?=E4=B8=8E=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app.ts 单体路由拆分为 routes/ + helpers + middleware + static 独立模块 - 类型去重:CheckFailure/CheckResult 以 shared/api.ts 为唯一源头,收紧 phase 联合类型 - es-toolkit 替换:isPlainObject/isNil/isEmptyObject/isEqual/isError/Semaphore/groupBy - Bun 内置 API:Object.fromEntries 替代手写 headersToRecord - bun:sqlite 规范:prepare() → query() 利用内置缓存,避免 N+1 查询 - 新增 getLatestChecksMap/allGetTargetStats 批量查询方法 - 新增 backend-code-quality/api-route-separation/batch-data-queries 规范 - 补充 openspec/config.yaml 后端开发规范与 DEVELOPMENT.md 后端开发指引 --- DEVELOPMENT.md | 158 ++++++++++- bun.lock | 1 + openspec/config.yaml | 4 +- openspec/specs/api-route-separation/spec.md | 78 ++++++ openspec/specs/backend-code-quality/spec.md | 102 +++++++ openspec/specs/batch-data-queries/spec.md | 73 +++++ package.json | 1 + src/server/app.ts | 295 +------------------- src/server/checker/command-runner.ts | 3 +- src/server/checker/engine.ts | 48 +--- src/server/checker/expect/body.ts | 13 +- src/server/checker/fetcher.ts | 13 +- src/server/checker/store.ts | 47 +++- src/server/checker/types.ts | 19 +- src/server/helpers.ts | 79 ++++++ src/server/middleware.ts | 58 ++++ src/server/routes/health.ts | 10 + src/server/routes/history.ts | 30 ++ src/server/routes/summary.ts | 15 + src/server/routes/targets.ts | 36 +++ src/server/routes/trend.ts | 26 ++ src/server/static.ts | 54 ++++ src/shared/api.ts | 2 +- tests/server/checker/store.test.ts | 71 +++++ 24 files changed, 868 insertions(+), 368 deletions(-) create mode 100644 openspec/specs/api-route-separation/spec.md create mode 100644 openspec/specs/backend-code-quality/spec.md create mode 100644 openspec/specs/batch-data-queries/spec.md create mode 100644 src/server/helpers.ts create mode 100644 src/server/middleware.ts create mode 100644 src/server/routes/health.ts create mode 100644 src/server/routes/history.ts create mode 100644 src/server/routes/summary.ts create mode 100644 src/server/routes/targets.ts create mode 100644 src/server/routes/trend.ts create mode 100644 src/server/static.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e895bb7..f1795f8 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -9,10 +9,19 @@ ```text src/ server/ - app.ts Bun HTTP 路由(API + 静态资源 + SPA fallback) + app.ts Bun HTTP 路由入口(路由分发 + API 汇聚) config.ts CLI 参数解析 dev.ts 开发期启动入口 server.ts HTTP server 启动 + helpers.ts 共享响应格式化工具(jsonResponse、createHeaders 等) + middleware.ts API 参数校验中间件(guardGetHead、validateTargetId 等) + static.ts 静态资源服务 与 SPA fallback + routes/ API 路由 handler(按端点拆分) + health.ts GET /health + summary.ts GET /api/summary + targets.ts GET /api/targets + history.ts GET /api/targets/:id/history + trend.ts GET /api/targets/:id/trend checker/ types.ts 类型定义 config-loader.ts YAML 配置解析与校验 @@ -20,11 +29,11 @@ src/ fetcher.ts HTTP 拨测执行 command-runner.ts 命令行拨测执行 size.ts 大小单位解析 - engine.ts 调度引擎(按 interval 分组、组内并发) + engine.ts 调度引擎(按 interval 分组的 es-toolkit groupBy + Semaphore 并发控制) expect/ http.ts HTTP 响应断言 command.ts 命令行输出断言 - body.ts HTTP body 断言(JSONPath/XPath/CSS) + body.ts HTTP body 断言(JSONPath/XPath/CSS,类型判断使用 es-toolkit) failure.ts 失败信息类型 shared/ api.ts 前后端共享 TypeScript 类型 @@ -82,6 +91,149 @@ bun run verify 前端只通过 HTTP 调用后端,API 路径为 `/api/*`。共享类型放在 `src/shared`,前端不得 import `src/server` 的运行时实现。 +## 后端开发指引 + +### 架构概览 + +``` +启动流程: + dev.ts → readRuntimeConfig(cli args) → loadConfig(yaml) + → ProbeStore(db) → ProbeEngine(store, targets) → startServer(store) + +运行时: + 定时器(tick) → ProbeEngine.probeGroup() + → HTTP: fetcher.ts / Command: command-runner.ts + → expect/*.ts 校验 → store.insertCheckResult() + +HTTP 请求: + Request → app.ts(路由分发) → routes/*.ts(handler) + → middleware.ts(参数校验) → helpers.ts(响应格式化) → Response +``` + +### 库使用优先级 + +后端代码开发遵循严格的库选择顺序: + +| 优先级 | 来源 | 典型用途 | +| ------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Bun 内置 API | `Bun.serve`、`bun:sqlite`、`Bun.spawn`、`Bun.file`、`Bun.YAML` | +| 2 | es-toolkit | 类型判断(`isPlainObject`/`isNil`/`isEmptyObject`)、深度比较(`isEqual`)、错误判断(`isError`)、并发控制(`Semaphore`)、集合操作(`groupBy`) | +| 3 | 标准 Web API | `Object.fromEntries`、`Headers`、`fetch`、`AbortController` | +| 4 | 主流三方库 | cheerio(HTML 解析)、xpath + @xmldom/xmldom(XML 解析) | +| 5 | 自行实现 | 仅在以上都无法满足时(如 `parseDuration`、`parseSize`、`evaluateJsonPath` 等专项逻辑) | + +### API 路由开发 + +路由文件位于 `src/server/routes/`,每个端点一个文件。handler 函数签名统一为: + +```typescript +export function handleXxx(params, store: ProbeStore, method: string, mode: RuntimeMode): Response; +``` + +**请求处理流程**: + +1. `app.ts` 的 `createFetchHandler` 作为总入口,根据 URL pattern 匹配路由 +2. API 路由统一经过 `guardGetHead` 做方法检查(仅允许 GET/HEAD) +3. 各 handler 内部通过 `middleware.ts` 提供的 `validateTargetId`、`validateTimeRange`、`validatePagination` 做参数校验 +4. 校验函数返回 `Response` 表示校验失败(直接返回),返回数据对象表示通过 +5. 业务逻辑通过 `store` 查询数据,用 `helpers.ts` 的 `jsonResponse`、`mapCheckResult`、`formatDuration` 等格式化输出 + +**新增路由步骤**: + +1. 在 `src/server/routes/` 下创建 `.ts` +2. 实现 handler 函数并 export +3. 在 `app.ts` 的 `handleApiRoute` 中注册路径匹配和调用 +4. 在 `tests/server/app.test.ts` 中添加对应测试 + +### 共享工具 + +- **`helpers.ts`**:跨路由共用的响应工具函数(`jsonResponse`、`createHeaders`、`createApiError`、`mapCheckResult`、`formatDuration`、`createHealthResponse`) +- **`middleware.ts`**:API 参数校验函数(`guardGetHead`、`validateTargetId`、`validateTimeRange`、`validatePagination`) +- **`static.ts`**:生产模式下的静态资源服务与 SPA fallback + +### 类型定义规范 + +- **共享类型**以 `src/shared/api.ts` 为唯一源头,前后端共同引用 +- 前端不得 `import src/server/` 下的任何文件 +- **严格联合类型**优先于宽类型:如 `phase: "status" | "duration" | ...` 而非 `phase: string` +- **后端内部扩展**:`checker/types.ts` 中 `CheckResult` 通过 `extends` 共享版本的 `ApiCheckResult` 增加 `targetName` 等内部字段 +- 存储层类型(`StoredTarget`、`StoredCheckResult`)独立定义,与 API 类型分离 + +### 数据存储规范 + +基于 `bun:sqlite`,WAL 模式运行,数据库文件位于配置的 `dataDir` 下。 + +**Statement 使用规范**: + +| 场景 | 方式 | 原因 | +| -------------- | -------------------------------------- | ---------------------------------------- | +| 单次读/写 | `this.db.query(sql).get()/all()/run()` | bun:sqlite 内置 statement 缓存,自动复用 | +| 事务内多次复用 | `this.db.prepare(sql)` 缓存为局部变量 | 事务闭包中需要持有引用 | + +**查询优化**: + +- 避免 N+1 查询:批量场景优先用单次 SQL 聚合(GROUP BY、子查询 JOIN)+ 内存组装 +- 新增批量查询方法时必须编写对应单元测试 +- `getSummary()` 和 `GET /api/targets` 的响应组装已通过 `getLatestChecksMap` + `getAllTargetStats` 实现批量查询 + +**Schema**: + +- `targets` 表:name(UNIQUE)、type、target(展示摘要)、config(JSON)、interval_ms、timeout_ms、expect(JSON)、grp +- `check_results` 表:target_id(FK CASCADE)、timestamp、matched(0/1)、duration_ms、status_detail、failure(JSON) +- 复合索引:`(target_id, timestamp)` + +### 拨测引擎 + +- **调度**:`ProbeEngine` 用 `es-toolkit/groupBy` 按 interval 分组,每组独立 `setInterval` 定时触发 +- **并发控制**:`es-toolkit/Semaphore` 限制全局最大并发数(`maxConcurrentChecks`),`acquire()` 阻塞等待 +- **Runner 选择**:`engine.runCheck()` 按 `target.type` 分发到 `runHttpCheck` 或 `runCommandCheck` +- **超时控制**:HTTP 用 `AbortController`,Command 用 `setTimeout` + `proc.kill()` +- **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLite,engine 通过 `targetNameToId` 缓存 name→id 映射 + +### expect 断言系统 + +两层模型:**观测值收集** → **规则校验**。 + +**HTTP 校验流程**: + +``` +runHttpCheck → 收集观测(statusCode/headers/body/durationMs) +→ checkHttpExpect → status → duration → headers → body(可选) +→ 首个失败即停止,返回 CheckFailure +``` + +**Command 校验流程**: + +``` +runCommandCheck → 收集观测(exitCode/stdout/stderr/durationMs) +→ checkCommandExpect → exitCode → duration → stdout → stderr +→ 首个失败即停止 +``` + +**Body 规则类型**: + +- `contains`:文本包含匹配 +- `regex`:正则表达式匹配 +- `json`:JSONPath 提取 + 操作符比较(使用 `es-toolkit/isPlainObject` 区分纯值和操作符) +- `css`:cheerio CSS 选择器 + 操作符比较 +- `xpath`:XPath 节点提取 + 操作符比较 + +**操作符**:`equals`(深度比较,`es-toolkit/isEqual`)、`contains`、`match`(正则)、`empty`(`isNil`+`isEmptyObject`)、`exists`、`gte`/`lte`/`gt`/`lt` + +### 错误模式 + +- **API 错误**:`{ error: "描述", status: }`,状态码 400/404/405/503 +- **CheckFailure**:`{ kind: "error"|"mismatch", phase, path, expected?, actual?, message }` +- **错误处理**:expect 校验失败记录首个失败原因;网络/超时/进程崩溃统一为 `kind:"error"` +- **日志**:解析失败等非致命异常用 `console.warn`,启动失败用 `console.error` + `process.exit(1)` + +### 测试规范 + +- 测试文件与源文件对应:`tests/server/checker/store.test.ts` ↔ `src/server/checker/store.ts` +- 使用 `bun:test` 框架(`describe`/`test`/`expect`),测试数据库用临时目录 + `tmpdir()` +- 新增 store 方法必须编写单元测试;新增 API 端点必须在 `app.test.ts` 中添加集成测试 +- 测试后清理:`afterAll` 中 `store.close()` + `rm(tempDir, { recursive: true })` + ## 前端样式规范 前端基于 TDesign React 构建UI,样式开发遵循以下优先级(从高到低): diff --git a/bun.lock b/bun.lock index 1d048e1..cda026d 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@tanstack/react-query": "^5.100.10", "@xmldom/xmldom": "^0.9.10", "cheerio": "^1.2.0", + "es-toolkit": "^1.46.1", "react": "^19.2.6", "react-dom": "^19.2.6", "recharts": "^3.8.1", diff --git a/openspec/config.yaml b/openspec/config.yaml index 8c8c067..eb6c3fc 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -10,9 +10,9 @@ context: | - 这是基于bun实现的前端后一体化项目,使用bun作为唯一包管理器,严禁使用pnpm、npm,使用bunx运行工具,严禁使用npx、pnpx - src/server目录下是基于bun实现的后端代码 - src/web目录下是基于vite、react、TDesign实现的前端代码 - - 代码开发优先使用公共组件实现功能逻辑(优先级:官方库>主流三方库>项目公共工具>自行实现) + - 后端库使用优先级:Bun 内置 API > es-toolkit > 主流三方库 > 项目公共工具 > 自行实现 - 前端样式开发优先级:TDesign组件 > 组件props > TDesign CSS tokens(--td-*) > styles.css CSS类 > 自行开发组件 - - 前端严禁:组件内联style属性、CSS覆盖TD内部类名、使用!important、硬编码色值 + - 前端严禁:组件内联style属性、CSS覆盖TD内部类名、使用!important、硬编码色值 - Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明 - 禁止创建git操作task - 积极使用subagents精心设计并行任务,节省上下文空间,加速任务执行 diff --git a/openspec/specs/api-route-separation/spec.md b/openspec/specs/api-route-separation/spec.md new file mode 100644 index 0000000..5cb06df --- /dev/null +++ b/openspec/specs/api-route-separation/spec.md @@ -0,0 +1,78 @@ +## Purpose + +定义后端 API 路由的组织规范:按端点拆分为独立 handler、共享响应工具集中管理、参数校验逻辑抽取为中间件、静态资源服务独立维护。 + +## Requirements + + +### Requirement: 路由按职责拆分 +系统 SHALL 将 HTTP 路由处理逻辑按 API 端点拆分为独立模块,每个模块导出一个 handler 函数供 app.ts 统一注册。 + +#### Scenario: health 端点独立路由 +- **WHEN** 客户端请求 `GET /health` +- **THEN** `routes/health.ts` 导出的 handler 负责处理,返回 HealthResponse JSON + +#### Scenario: summary 端点独立路由 +- **WHEN** 客户端请求 `GET /api/summary` +- **THEN** `routes/summary.ts` 导出的 handler 负责处理,委托 store 查询并返回 SummaryResponse JSON + +#### Scenario: targets 端点独立路由 +- **WHEN** 客户端请求 `GET /api/targets` +- **THEN** `routes/targets.ts` 导出的 handler 负责处理,委托 store 查询并返回 TargetStatus[] JSON + +#### Scenario: history 端点独立路由 +- **WHEN** 客户端请求 `GET /api/targets/:id/history?from=ISO&to=ISO` +- **THEN** `routes/history.ts` 导出的 handler 负责处理,包含参数校验、store 查询和 HistoryResponse 返回 + +#### Scenario: trend 端点独立路由 +- **WHEN** 客户端请求 `GET /api/targets/:id/trend?from=ISO&to=ISO` +- **THEN** `routes/trend.ts` 导出的 handler 负责处理,包含参数校验、store 查询和 TrendPoint[] 返回 + +### Requirement: 共享辅助函数集中管理 +系统 SHALL 将跨路由共享的响应格式化函数抽取到 helpers.ts 模块,单一职责、集中管理。 + +#### Scenario: createApiError 集中定义 +- **WHEN** 任意路由需要返回 API 错误响应 +- **THEN** 从 `helpers.ts` 导入 `createApiError` 函数,提供错误信息和状态码 + +#### Scenario: jsonResponse 集中定义 +- **WHEN** 任意路由需要返回 JSON 响应 +- **THEN** 从 `helpers.ts` 导入 `jsonResponse` 函数,处理 HEAD 方法、Content-Type 和安全头 + +#### Scenario: mapCheckResult 集中定义 +- **WHEN** 需要将 StoredCheckResult 映射为 API CheckResult +- **THEN** 从 `helpers.ts` 导入 `mapCheckResult` 函数,处理 failure JSON 解析和格式转换 + +### Requirement: 参数校验逻辑抽取为中间件 +系统 SHALL 将重复的参数校验逻辑(target ID 解析、时间范围校验、分页参数校验、方法检查)抽取到 middleware.ts 模块。 + +#### Scenario: 方法检查中间件 +- **WHEN** 请求方法不是 GET 或 HEAD +- **THEN** `guardGetHead(request, mode)` SHALL 返回 405 Response,否则返回 null 表示放行 + +#### Scenario: Target ID 校验 +- **WHEN** URL 中的 id 参数不是正整数 +- **THEN** `validateTargetId(idStr)` SHALL 返回 400 ApiError + +#### Scenario: 时间范围参数校验 +- **WHEN** from 或 to 参数缺失或格式无效 +- **THEN** `validateTimeRange(from, to)` SHALL 返回 400 ApiError + +#### Scenario: 分页参数校验 +- **WHEN** page 或 pageSize 参数不是正整数 +- **THEN** `validatePagination(page, pageSize)` SHALL 返回 400 ApiError + +### Requirement: 静态资源服务独立管理 +系统 SHALL 将静态资源服务、SPA fallback 和 Content-Type 映射逻辑抽取到 static.ts 模块。 + +#### Scenario: 根路径返回 index.html +- **WHEN** 客户端请求 `/` +- **THEN** `static.ts` 的 handler 返回 index.html,设置正确的 Content-Type 和 Cache-Control + +#### Scenario: 资源文件返回正确 Content-Type +- **WHEN** 客户端请求 `/assets/main.js` +- **THEN** `static.ts` 的 handler 根据文件扩展名返回正确的 Content-Type(如 `.js` → `text/javascript`) + +#### Scenario: SPA fallback +- **WHEN** 客户端请求非 API、非资源的路径(如 `/dashboard`) +- **THEN** `static.ts` 的 handler 返回 index.html 实现 SPA 的客户端路由 diff --git a/openspec/specs/backend-code-quality/spec.md b/openspec/specs/backend-code-quality/spec.md new file mode 100644 index 0000000..6eceaa6 --- /dev/null +++ b/openspec/specs/backend-code-quality/spec.md @@ -0,0 +1,102 @@ +## Purpose + +定义后端代码中 es-toolkit 和 Bun 内置 API 的使用规范:类型判断、空值检测、深度比较、错误判断、并发控制、集合分组和 Web API 标准方法,替代手写实现落实库使用优先级规则。 + +## Requirements + + +### Requirement: 使用 es-toolkit 进行类型判断 +系统 SHALL 使用 es-toolkit 的 `isPlainObject` 替代手写的对象类型判断函数,用于 expect 校验中区分纯值(原始值)和操作符对象。 + +#### Scenario: 识别纯对象为操作符 +- **WHEN** body 校验规则中 expected 配置为 `{ equals: "value" }`(纯对象操作符) +- **THEN** `isPlainObject(expected)` SHALL 返回 true,系统按操作符语义处理 + +#### Scenario: 排除非纯对象作为操作符 +- **WHEN** body 校验规则中 expected 为原始值如 `"value"` 或数字 `200` +- **THEN** `isPlainObject(expected)` SHALL 返回 false,系统按 equals 默认操作符处理 + +### Requirement: 使用 es-toolkit 进行空值检测 +系统 SHALL 使用 es-toolkit 的 `isNil` 替代手写的 `actual === null || actual === undefined` 检测,用于 expect 中 `empty` 操作符的空值判断。 + +#### Scenario: null 值判定为空 +- **WHEN** 校验值为 null +- **THEN** `isNil(null)` SHALL 返回 true + +#### Scenario: undefined 值判定为空 +- **WHEN** 校验值为 undefined +- **THEN** `isNil(undefined)` SHALL 返回 true + +#### Scenario: 非空值判定为非空 +- **WHEN** 校验值为 0、"false"、空数组 `[]` 等非 nil 值 +- **THEN** `isNil(value)` SHALL 返回 false + +### Requirement: 使用 es-toolkit 进行空对象检测 +系统 SHALL 使用 es-toolkit 的 `isEmptyObject` 替代手写的 `typeof actual === "object" && Object.keys(actual).length === 0` 检测,用于 expect 中 `empty` 操作符的空对象判断。 + +#### Scenario: 空对象判定为空 +- **WHEN** 校验值为 `{}` +- **THEN** `isEmptyObject({})` SHALL 返回 true + +#### Scenario: 非空对象判定为非空 +- **WHEN** 校验值为 `{ key: "val" }` +- **THEN** `isEmptyObject({ key: "val" })` SHALL 返回 false + +#### Scenario: null 不是空对象 +- **WHEN** 校验值为 null +- **THEN** `isEmptyObject(null)` SHALL 返回 false(空值由 isNil 前置处理) + +### Requirement: 使用 es-toolkit 进行深度相等比较 +系统 SHALL 使用 es-toolkit 的 `isEqual` 替代 `!==` 浅比较,用于 expect 中 `equals` 操作符的值比较,支持对象和数组的深度比较。 + +#### Scenario: 原始值浅比较 +- **WHEN** expected 和 actual 均为原始值(字符串、数字、布尔值、null) +- **THEN** `isEqual(actual, expected)` 的行为 SHALL 与 `actual === expected` 一致 + +#### Scenario: 对象深度比较 +- **WHEN** expected 和 actual 均为对象(如从 JSONPath 提取的结构化数据) +- **THEN** `isEqual(actual, expected)` SHALL 递归比较所有属性值,而非引用比较 + +### Requirement: 使用 es-toolkit 进行错误类型判断 +系统 SHALL 使用 es-toolkit 的 `isError` 替代 `error instanceof Error`,用于 HTTP runner 和 command runner 中的错误类型判断。 + +#### Scenario: Error 实例识别 +- **WHEN** 错误对象为 `new Error("msg")` +- **THEN** `isError(error)` SHALL 返回 true + +#### Scenario: Error 子类识别 +- **WHEN** 错误对象为继承 Error 的自定义类型 +- **THEN** `isError(error)` SHALL 返回 true + +#### Scenario: 非 Error 对象识别 +- **WHEN** 错误对象为字符串或普通对象 +- **THEN** `isError(error)` SHALL 返回 false + +### Requirement: 使用 es-toolkit Semaphore 实现并发控制 +系统 SHALL 使用 es-toolkit 的 `Semaphore` 类替代手写的信号量实现(计数器 + Promise 队列),用于 ProbeEngine 中的组内并发拨测控制。 + +#### Scenario: 获取并发槽位 +- **WHEN** 当前并发数未达上限 +- **THEN** `semaphore.acquire()` SHALL 立即返回,不阻塞 + +#### Scenario: 等待并发槽位 +- **WHEN** 当前并发数已达上限 maxConcurrentChecks +- **THEN** `semaphore.acquire()` SHALL 阻塞等待,直到其他任务调用 `semaphore.release()` + +#### Scenario: 释放并发槽位 +- **WHEN** 调用 `semaphore.release()` +- **THEN** 系统 SHALL 唤醒一个等待中的 acquire() 调用 + +### Requirement: 使用 es-toolkit groupBy 实现 target 分组 +系统 SHALL 使用 es-toolkit 的 `groupBy` 函数替代手写的 Map 循环分组,用于 ProbeEngine 中按 interval 分组拨测目标。 + +#### Scenario: 按 interval 分组 +- **WHEN** 输入包含不同 intervalMs 值的多个 target +- **THEN** `groupBy(targets, t => t.intervalMs)` SHALL 返回 key 为 intervalMs 值的分组对象,值为对应 target 数组 + +### Requirement: 使用 Bun 内置 API 进行 Headers 转换 +系统 SHALL 使用 `Object.fromEntries(headers)` 标准 Web API 替代手写的 `headersToRecord` 函数,用于将 Fetch API 的 Headers 对象转换为键值对。 + +#### Scenario: 转换响应头 +- **WHEN** HTTP runner 获取到 response headers +- **THEN** `Object.fromEntries(response.headers)` SHALL 返回以 header 名称为 key、header 值为 value 的对象 diff --git a/openspec/specs/batch-data-queries/spec.md b/openspec/specs/batch-data-queries/spec.md new file mode 100644 index 0000000..7715b3c --- /dev/null +++ b/openspec/specs/batch-data-queries/spec.md @@ -0,0 +1,73 @@ +## Purpose + +定义 ProbeStore 的批量查询方法:getLatestChecksMap、getAllTargetStats,以及 getSummary 和 createTargetsResponse 的 N+1 查询优化规范。同时约定单次查询操作使用 db.query() 利用内置缓存。 + +## Requirements + + +### Requirement: 批量查询最新检查结果 +系统 SHALL 提供 `getLatestChecksMap` 方法,通过单次 SQL 查询获取所有 target 的最新一次 check 结果,返回 Map 结构供调用方按 target_id 索引。 + +#### Scenario: 获取所有目标的最新检查 +- **WHEN** 调用 `getLatestChecksMap()` +- **THEN** 系统 SHALL 执行子查询找到每个 target_id 的 MAX(timestamp),再 JOIN 回 check_results 获取完整行,返回 `Map` + +#### Scenario: 目标无历史记录 +- **WHEN** 某 target 在 check_results 表中无任何记录 +- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key + +### Requirement: 批量查询目标统计 +系统 SHALL 提供 `getAllTargetStats` 方法,通过单次 SQL GROUP BY 聚合查询获取所有 target 的拨测统计(totalChecks 和 availability)。 + +#### Scenario: 获取所有目标的聚合统计 +- **WHEN** 调用 `getAllTargetStats()` +- **THEN** 系统 SHALL 执行 `SELECT target_id, COUNT(*), SUM(CASE WHEN matched=1 THEN 1 ELSE 0 END) FROM check_results GROUP BY target_id`,在内存中计算 availability 并返回 `Map` + +#### Scenario: 目标无历史记录 +- **WHEN** 某 target 在 check_results 表中无任何记录 +- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key + +#### Scenario: availability 精度 +- **WHEN** 计算 availability(upCount / totalChecks * 100) +- **THEN** 结果 SHALL 四舍五入保留两位小数 + +### Requirement: summary 查询使用批量方法 +`getSummary` 方法 SHALL 使用 `getLatestChecksMap` 一次性获取所有 target 的最新检查结果,而非对每个 target 逐条查询。 + +#### Scenario: 统计总览使用批量查询 +- **WHEN** 调用 `store.getSummary()` +- **THEN** 系统 SHALL 调用 `getLatestChecksMap()` 一次获取所有最新结果,在内存中遍历统计 up/down 数量,而非循环 N 次调用 `getLatestCheck()` + +### Requirement: targets 列表使用批量方法 +`createTargetsResponse`(app.ts 中生成 TargetStatus[] 的逻辑)SHALL 使用 `getLatestChecksMap` 和 `getAllTargetStats` 替代逐目标查询 latest checkout、stats 和 samples。 + +#### Scenario: 目标列表使用批量查询 +- **WHEN** 处理 `GET /api/targets` 请求 +- **THEN** 系统 SHALL 分别调用 `getLatestChecksMap()`、`getAllTargetStats()` 批量获取数据,在内存中组装 TargetStatus 数组,而非对每个 target 逐条查询数据库 + +### Requirement: prepared statement 使用 query() 缓存 +ProbeStore 中不涉及事务内复用的单次读/写操作 SHALL 使用 `this.db.query()` 而非 `this.db.prepare()`,利用 bun:sqlite 内置的 statement 缓存机制。 + +#### Scenario: insertCheckResult 使用 query +- **WHEN** 写入一条检查结果 +- **THEN** `insertCheckResult` SHALL 使用 `this.db.query("INSERT INTO ...").run(...)` 而非 `this.db.prepare("INSERT INTO ...").run(...)` + +#### Scenario: getHistory 查询使用 query +- **WHEN** 查询历史记录(包括 COUNT 和分页查询) +- **THEN** `getHistory` SHALL 使用 `this.db.query(...)` 而非 `this.db.prepare(...)` + +#### Scenario: getTargetStats 查询使用 query +- **WHEN** 查询单目标统计 +- **THEN** `getTargetStats` SHALL 使用 `this.db.query(...)` 而非 `this.db.prepare(...)` + +#### Scenario: getTrend 查询使用 query +- **WHEN** 查询趋势数据 +- **THEN** `getTrend` SHALL 使用 `this.db.query(...)` 而非 `this.db.prepare(...)` + +#### Scenario: getRecentSamples 查询使用 query +- **WHEN** 查询采样数据 +- **THEN** `getRecentSamples` SHALL 使用 `this.db.query(...)` 而非 `this.db.prepare(...)` + +#### Scenario: syncTargets 事务保持 prepare(例外) +- **WHEN** 同步 targets 配置(事务内多次复用 insertStmt/updateStmt/deleteStmt) +- **THEN** `syncTargets` 方法 SHALL 保持使用 `this.db.prepare()`,因需要在事务闭包内持有引用 diff --git a/package.json b/package.json index 7f8aebc..cfe58ed 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@tanstack/react-query": "^5.100.10", "@xmldom/xmldom": "^0.9.10", "cheerio": "^1.2.0", + "es-toolkit": "^1.46.1", "react": "^19.2.6", "react-dom": "^19.2.6", "recharts": "^3.8.1", diff --git a/src/server/app.ts b/src/server/app.ts index bea351e..8db6730 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -1,16 +1,13 @@ -import type { - ApiErrorResponse, - CheckFailure, - CheckResult, - HealthResponse, - HistoryResponse, - RuntimeMode, - SummaryResponse, - TargetStatus, - TrendPoint, -} from "../shared/api"; -import type { StoredCheckResult } from "./checker/types"; +import type { RuntimeMode } from "../shared/api"; import type { ProbeStore } from "./checker/store"; +import { jsonResponse, createApiError } from "./helpers"; +import { guardGetHead } from "./middleware"; +import { serveStaticAsset } from "./static"; +import { handleHealth } from "./routes/health"; +import { handleSummary } from "./routes/summary"; +import { handleTargets } from "./routes/targets"; +import { handleHistory } from "./routes/history"; +import { handleTrend } from "./routes/trend"; export interface StaticAssets { indexHtml: Blob; @@ -28,11 +25,7 @@ export function createFetchHandler(options: AppOptions) { const url = new URL(request.url); if (url.pathname === "/health") { - if (!allowsGetHead(request.method)) { - return methodNotAllowedResponse(["GET", "HEAD"], options.mode); - } - - return jsonResponse(createHealthResponse(), { method: request.method, mode: options.mode }); + return handleHealth(request.method, options.mode); } if (url.pathname.startsWith("/api/") && options.store) { @@ -59,18 +52,17 @@ export function createFetchHandler(options: AppOptions) { } function handleApiRoute(url: URL, request: Request, store: ProbeStore, mode: RuntimeMode): Response { - const { method } = request; + const guardResult = guardGetHead(request.method, mode); + if (guardResult) return guardResult; - if (!allowsGetHead(method)) { - return methodNotAllowedResponse(["GET", "HEAD"], mode); - } + const method = request.method; if (url.pathname === "/api/summary") { - return jsonResponse(createSummaryResponse(store), { method, mode }); + return handleSummary(store, method, mode); } if (url.pathname === "/api/targets") { - return jsonResponse(createTargetsResponse(store), { method, mode }); + return handleTargets(store, method, mode); } const historyMatch = url.pathname.match(/^\/api\/targets\/([^/]+)\/history$/); @@ -85,260 +77,3 @@ function handleApiRoute(url: URL, request: Request, store: ProbeStore, mode: Run return jsonResponse(createApiError("API route not found", 404), { method, mode, status: 404 }); } - -function handleHistory(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response { - const id = Number(idStr); - - if (!Number.isInteger(id) || id <= 0) { - return jsonResponse(createApiError("Invalid target ID", 400), { method, mode, status: 400 }); - } - - const target = store.getTargetById(id); - if (!target) { - return jsonResponse(createApiError("Target not found", 404), { method, mode, status: 404 }); - } - - const from = url.searchParams.get("from"); - const to = url.searchParams.get("to"); - - if (!from || !to) { - return jsonResponse(createApiError("from and to parameters are required", 400), { method, mode, status: 400 }); - } - - const fromDate = new Date(from); - const toDate = new Date(to); - if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) { - return jsonResponse(createApiError("Invalid from or to parameter format", 400), { method, mode, status: 400 }); - } - - const pageParam = url.searchParams.get("page"); - const pageSizeParam = url.searchParams.get("pageSize"); - let page = 1; - let pageSize = 20; - - if (pageParam !== null) { - page = Number(pageParam); - if (!Number.isInteger(page) || page <= 0) { - return jsonResponse(createApiError("Invalid page parameter", 400), { method, mode, status: 400 }); - } - } - - if (pageSizeParam !== null) { - pageSize = Number(pageSizeParam); - if (!Number.isInteger(pageSize) || pageSize <= 0) { - return jsonResponse(createApiError("Invalid pageSize parameter", 400), { method, mode, status: 400 }); - } - } - - const result = store.getHistory(id, from, to, page, pageSize); - const response: HistoryResponse = { - items: result.items.map(mapCheckResult), - total: result.total, - page: result.page, - pageSize: result.pageSize, - }; - - return jsonResponse(response, { method, mode }); -} - -function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response { - const id = Number(idStr); - - if (!Number.isInteger(id) || id <= 0) { - return jsonResponse(createApiError("Invalid target ID", 400), { method, mode, status: 400 }); - } - - const target = store.getTargetById(id); - if (!target) { - return jsonResponse(createApiError("Target not found", 404), { method, mode, status: 404 }); - } - - const from = url.searchParams.get("from"); - const to = url.searchParams.get("to"); - - if (!from || !to) { - return jsonResponse(createApiError("from and to parameters are required", 400), { method, mode, status: 400 }); - } - - const fromDate = new Date(from); - const toDate = new Date(to); - if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) { - return jsonResponse(createApiError("Invalid from or to parameter format", 400), { method, mode, status: 400 }); - } - - const trend: TrendPoint[] = store.getTrend(id, from, to).map((row) => ({ - hour: row.hour, - avgDurationMs: row.avgDurationMs, - availability: Math.round(row.availability * 100) / 100, - totalChecks: row.totalChecks, - })); - - return jsonResponse(trend, { method, mode }); -} - -function createSummaryResponse(store: ProbeStore): SummaryResponse { - const summary = store.getSummary(); - return { - total: summary.total, - up: summary.up, - down: summary.down, - lastCheckTime: summary.lastCheckTime, - }; -} - -function createTargetsResponse(store: ProbeStore): TargetStatus[] { - const targets = store.getTargets(); - - return targets.map((target) => { - const latest = store.getLatestCheck(target.id); - const stats = store.getTargetStats(target.id); - const recentSamples = store.getRecentSamples(target.id, 30); - - return { - id: target.id, - name: target.name, - type: target.type, - target: target.target, - group: target.grp, - interval: formatDuration(target.interval_ms), - latestCheck: latest ? mapCheckResult(latest) : null, - recentSamples: recentSamples.map((s) => ({ - timestamp: s.timestamp, - durationMs: s.duration_ms, - up: s.matched === 1, - })), - stats: { - totalChecks: stats.totalChecks, - availability: stats.availability, - }, - }; - }); -} - -function mapCheckResult(row: StoredCheckResult): CheckResult { - let failure: CheckFailure | null = null; - if (row.failure) { - try { - failure = JSON.parse(row.failure) as CheckFailure; - } catch { - console.warn(`无法解析 failure 数据: target_id=${row.target_id}, timestamp=${row.timestamp}`); - failure = null; - } - } - - return { - timestamp: row.timestamp, - matched: row.matched === 1, - durationMs: row.duration_ms, - statusDetail: row.status_detail, - failure, - }; -} - -function formatDuration(ms: number): string { - if (ms >= 60000 && ms % 60000 === 0) return `${ms / 60000}m`; - if (ms >= 1000 && ms % 1000 === 0) return `${ms / 1000}s`; - return `${ms}ms`; -} - -function createHealthResponse(): HealthResponse { - return { - ok: true, - service: "dial-server", - timestamp: new Date().toISOString(), - }; -} - -function createApiError(error: string, status: number): ApiErrorResponse { - return { error, status }; -} - -function allowsGetHead(method: string): boolean { - return method === "GET" || method === "HEAD"; -} - -function methodNotAllowedResponse(allow: string[], mode: RuntimeMode): Response { - return jsonResponse(createApiError("Method not allowed", 405), { - mode, - status: 405, - headers: { Allow: allow.join(", ") }, - }); -} - -function jsonResponse( - body: unknown, - options: { method?: string; mode: RuntimeMode; status?: number; headers?: HeadersInit }, -): Response { - const headers = createHeaders(options.mode, { - "Content-Type": "application/json; charset=utf-8", - ...options.headers, - }); - const responseBody = options.method === "HEAD" ? null : JSON.stringify(body); - - return new Response(responseBody, { - status: options.status, - headers, - }); -} - -function serveStaticAsset(pathname: string, staticAssets: StaticAssets, mode: RuntimeMode): Response { - if (pathname === "/") { - return htmlResponse(staticAssets.indexHtml, mode); - } - - const asset = staticAssets.files[pathname]; - - if (asset) { - return new Response(asset, { - headers: createHeaders(mode, { - "Content-Type": contentTypeFor(pathname), - "Cache-Control": "public, max-age=31536000, immutable", - }), - }); - } - - if (pathname.startsWith("/assets/") || hasFileExtension(pathname)) { - return new Response("Not Found", { - status: 404, - headers: createHeaders(mode, { "Content-Type": "text/plain; charset=utf-8" }), - }); - } - - return htmlResponse(staticAssets.indexHtml, mode); -} - -function htmlResponse(indexHtml: Blob, mode: RuntimeMode): Response { - return new Response(indexHtml, { - headers: createHeaders(mode, { - "Content-Type": "text/html; charset=utf-8", - "Cache-Control": "no-cache", - }), - }); -} - -function createHeaders(mode: RuntimeMode, init: HeadersInit): Headers { - const headers = new Headers(init); - - if (mode === "production") { - headers.set("X-Content-Type-Options", "nosniff"); - headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); - } - - return headers; -} - -function hasFileExtension(pathname: string): boolean { - return /\/[^/]+\.[^/]+$/.test(pathname); -} - -function contentTypeFor(pathname: string): string { - if (pathname.endsWith(".js") || pathname.endsWith(".mjs")) return "text/javascript; charset=utf-8"; - if (pathname.endsWith(".css")) return "text/css; charset=utf-8"; - if (pathname.endsWith(".svg")) return "image/svg+xml"; - if (pathname.endsWith(".json")) return "application/json; charset=utf-8"; - if (pathname.endsWith(".png")) return "image/png"; - if (pathname.endsWith(".jpg") || pathname.endsWith(".jpeg")) return "image/jpeg"; - if (pathname.endsWith(".ico")) return "image/x-icon"; - - return "application/octet-stream"; -} diff --git a/src/server/checker/command-runner.ts b/src/server/checker/command-runner.ts index 18f4e93..b68c35a 100644 --- a/src/server/checker/command-runner.ts +++ b/src/server/checker/command-runner.ts @@ -1,3 +1,4 @@ +import { isError } from "es-toolkit"; import type { CheckResult, ResolvedCommandTarget } from "./types"; import { checkCommandExpect } from "./expect/command"; import { errorFailure } from "./expect/failure"; @@ -73,7 +74,7 @@ export async function runCommandCheck(target: ResolvedCommandTarget): Promise[] = []; private store: ProbeStore; private targets: ResolvedTarget[]; private targetNameToId: Map = new Map(); - private maxConcurrentChecks: number; - private running = 0; - private queue: Array<() => void> = []; + private semaphore: Semaphore; constructor(store: ProbeStore, targets: ResolvedTarget[], maxConcurrentChecks?: number) { this.store = store; this.targets = targets; - this.maxConcurrentChecks = maxConcurrentChecks ?? 20; + this.semaphore = new Semaphore(maxConcurrentChecks ?? 20); this.refreshCache(); } start(): void { - const groups = this.groupByInterval(this.targets); + const groups = groupBy(this.targets, (t) => t.intervalMs); - for (const [intervalMs, groupTargets] of groups) { + for (const [intervalMs, groupTargets] of Object.entries(groups)) { void this.probeGroup(groupTargets); const timer = setInterval(() => { void this.probeGroup(groupTargets); - }, intervalMs); + }, Number(intervalMs)); this.timers.push(timer); } @@ -40,45 +39,14 @@ export class ProbeEngine { this.timers = []; } - private groupByInterval(targets: ResolvedTarget[]): Map { - const groups = new Map(); - - for (const target of targets) { - const group = groups.get(target.intervalMs) ?? []; - group.push(target); - groups.set(target.intervalMs, group); - } - - return groups; - } - - private async acquire(): Promise { - if (this.running < this.maxConcurrentChecks) { - this.running++; - return; - } - return new Promise((resolve) => { - this.queue.push(resolve); - }); - } - - private release(): void { - const next = this.queue.shift(); - if (next) { - next(); - } else { - this.running--; - } - } - private async probeGroup(targets: ResolvedTarget[]): Promise { const results = await Promise.allSettled( targets.map(async (target) => { - await this.acquire(); + await this.semaphore.acquire(); try { return await this.runCheck(target); } finally { - this.release(); + this.semaphore.release(); } }), ); diff --git a/src/server/checker/expect/body.ts b/src/server/checker/expect/body.ts index 25d240a..71302fb 100644 --- a/src/server/checker/expect/body.ts +++ b/src/server/checker/expect/body.ts @@ -3,8 +3,7 @@ import * as cheerio from "cheerio"; import * as xpath from "xpath"; import { DOMParser } from "@xmldom/xmldom"; import { mismatchFailure, errorFailure } from "./failure"; - -const isObject = (v: unknown): v is Record => v !== null && typeof v === "object" && !Array.isArray(v); +import { isNil, isEmptyObject, isEqual, isPlainObject } from "es-toolkit"; export function evaluateJsonPath(json: unknown, path: string): unknown { if (!path.startsWith("$.")) return undefined; @@ -34,7 +33,7 @@ export function applyOperator(actual: unknown, op: ExpectOperator): boolean { switch (key) { case "equals": - if (actual !== expected) return false; + if (!isEqual(actual, expected)) return false; break; case "contains": if (!String(actual).includes(expected as string)) return false; @@ -44,11 +43,7 @@ export function applyOperator(actual: unknown, op: ExpectOperator): boolean { break; case "empty": { const isEmpty = - actual === null || - actual === undefined || - actual === "" || - (Array.isArray(actual) && actual.length === 0) || - (typeof actual === "object" && Object.keys(actual as object).length === 0); + isNil(actual) || actual === "" || (Array.isArray(actual) && actual.length === 0) || isEmptyObject(actual); if (expected !== isEmpty) return false; break; } @@ -78,7 +73,7 @@ export function applyOperator(actual: unknown, op: ExpectOperator): boolean { } export function checkExpectValue(actual: unknown, expected: ExpectValue): boolean { - if (isObject(expected)) { + if (isPlainObject(expected)) { return applyOperator(actual, expected as ExpectOperator); } return applyOperator(actual, { equals: expected as string | number | boolean | null }); diff --git a/src/server/checker/fetcher.ts b/src/server/checker/fetcher.ts index b49dc30..063b8f0 100644 --- a/src/server/checker/fetcher.ts +++ b/src/server/checker/fetcher.ts @@ -1,14 +1,7 @@ import type { CheckResult, ResolvedHttpTarget } from "./types"; import { checkHttpExpect } from "./expect/http"; import { errorFailure } from "./expect/failure"; - -function headersToRecord(headers: Headers): Record { - const result: Record = {}; - headers.forEach((value, key) => { - result[key] = value; - }); - return result; -} +import { isError } from "es-toolkit"; export async function runHttpCheck(target: ResolvedHttpTarget): Promise { const timestamp = new Date().toISOString(); @@ -27,7 +20,7 @@ export async function runHttpCheck(target: ResolvedHttpTarget): Promise 0); @@ -93,7 +86,7 @@ export async function runHttpCheck(target: ResolvedHttpTarget): Promise= ? AND timestamp <= ?") + .query("SELECT COUNT(*) as total FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?") .get(targetId, from, to) as { total: number }; const offset = (page - 1) * pageSize; const items = this.db - .prepare( + .query( "SELECT * FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT ? OFFSET ?", ) .all(targetId, from, to, pageSize, offset) as StoredCheckResult[]; @@ -157,7 +157,7 @@ export class ProbeStore { availability: number; } { const row = this.db - .prepare( + .query( `SELECT COUNT(*) as totalChecks, COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount @@ -186,7 +186,7 @@ export class ProbeStore { totalChecks: number; }> { return this.db - .prepare( + .query( `SELECT strftime('%Y-%m-%dT%H:00:00', timestamp) as hour, AVG(CASE WHEN matched = 1 THEN duration_ms END) as avgDurationMs, @@ -212,12 +212,13 @@ export class ProbeStore { lastCheckTime: string | null; } { const targets = this.getTargets(); + const latestChecksMap = this.getLatestChecksMap(); let up = 0; let down = 0; let lastCheckTime: string | null = null; for (const target of targets) { - const latest = this.getLatestCheck(target.id); + const latest = latestChecksMap.get(target.id); if (latest) { if (latest.matched) { @@ -247,7 +248,7 @@ export class ProbeStore { limit: number, ): Array<{ timestamp: string; duration_ms: number | null; matched: number }> { return this.db - .prepare( + .query( "SELECT timestamp, duration_ms, matched FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT ?", ) .all(targetId, limit) as Array<{ @@ -257,6 +258,38 @@ export class ProbeStore { }>; } + getLatestChecksMap(): Map { + const rows = this.db + .query( + `SELECT cr.* FROM check_results cr + INNER JOIN ( + SELECT target_id, MAX(timestamp) as max_ts + FROM check_results + GROUP BY target_id + ) latest ON cr.target_id = latest.target_id AND cr.timestamp = latest.max_ts`, + ) + .all() as StoredCheckResult[]; + return new Map(rows.map((r) => [r.target_id, r])); + } + + getAllTargetStats(): Map { + const rows = this.db + .query( + `SELECT target_id, COUNT(*) as totalChecks, + COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount + FROM check_results + GROUP BY target_id`, + ) + .all() as Array<{ target_id: number; totalChecks: number; upCount: number }>; + + const result = new Map(); + for (const row of rows) { + const availability = row.totalChecks > 0 ? Math.round((row.upCount / row.totalChecks) * 10000) / 100 : 0; + result.set(row.target_id, { totalChecks: row.totalChecks, availability }); + } + return result; + } + close(): void { this.closed = true; this.db.close(); diff --git a/src/server/checker/types.ts b/src/server/checker/types.ts index af98d09..c6261a5 100644 --- a/src/server/checker/types.ts +++ b/src/server/checker/types.ts @@ -1,3 +1,5 @@ +import type { CheckResult as ApiCheckResult, CheckFailure } from "../../shared/api"; + export type TargetType = "http" | "command"; export interface ProbeConfig { @@ -147,22 +149,9 @@ export interface ResolvedCommandConfig { export type ResolvedTarget = ResolvedHttpTarget | ResolvedCommandTarget; -export interface CheckFailure { - kind: "error" | "mismatch"; - phase: "status" | "duration" | "headers" | "body" | "exitCode" | "stdout" | "stderr"; - path: string; - expected?: unknown; - actual?: unknown; - message: string; -} - -export interface CheckResult { +export type { CheckFailure }; +export interface CheckResult extends ApiCheckResult { targetName: string; - timestamp: string; - matched: boolean; - durationMs: number | null; - statusDetail: string | null; - failure: CheckFailure | null; } export interface StoredTarget { diff --git a/src/server/helpers.ts b/src/server/helpers.ts new file mode 100644 index 0000000..08d15a8 --- /dev/null +++ b/src/server/helpers.ts @@ -0,0 +1,79 @@ +import type { ApiErrorResponse, CheckFailure, CheckResult, HealthResponse, RuntimeMode } from "../shared/api"; +import type { StoredCheckResult } from "./checker/types"; + +export function createApiError(error: string, status: number): ApiErrorResponse { + return { error, status }; +} + +export function createHeaders(mode: RuntimeMode, init: HeadersInit): Headers { + const headers = new Headers(init); + + if (mode === "production") { + headers.set("X-Content-Type-Options", "nosniff"); + headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); + } + + return headers; +} + +export function jsonResponse( + body: unknown, + options: { method?: string; mode: RuntimeMode; status?: number; headers?: HeadersInit }, +): Response { + const headers = createHeaders(options.mode, { + "Content-Type": "application/json; charset=utf-8", + ...options.headers, + }); + const responseBody = options.method === "HEAD" ? null : JSON.stringify(body); + + return new Response(responseBody, { + status: options.status, + headers, + }); +} + +export function methodNotAllowedResponse(allow: string[], mode: RuntimeMode): Response { + return jsonResponse(createApiError("Method not allowed", 405), { + mode, + status: 405, + headers: { Allow: allow.join(", ") }, + }); +} + +export function allowsGetHead(method: string): boolean { + return method === "GET" || method === "HEAD"; +} + +export function formatDuration(ms: number): string { + if (ms >= 60000 && ms % 60000 === 0) return `${ms / 60000}m`; + if (ms >= 1000 && ms % 1000 === 0) return `${ms / 1000}s`; + return `${ms}ms`; +} + +export function mapCheckResult(row: StoredCheckResult): CheckResult { + let failure: CheckFailure | null = null; + if (row.failure) { + try { + failure = JSON.parse(row.failure) as CheckFailure; + } catch { + console.warn(`无法解析 failure 数据: target_id=${row.target_id}, timestamp=${row.timestamp}`); + failure = null; + } + } + + return { + timestamp: row.timestamp, + matched: row.matched === 1, + durationMs: row.duration_ms, + statusDetail: row.status_detail, + failure, + }; +} + +export function createHealthResponse(): HealthResponse { + return { + ok: true, + service: "dial-server", + timestamp: new Date().toISOString(), + }; +} diff --git a/src/server/middleware.ts b/src/server/middleware.ts new file mode 100644 index 0000000..f9bff5e --- /dev/null +++ b/src/server/middleware.ts @@ -0,0 +1,58 @@ +import type { RuntimeMode } from "../shared/api"; +import { allowsGetHead, createApiError, jsonResponse, methodNotAllowedResponse } from "./helpers"; + +export function guardGetHead(method: string, mode: RuntimeMode): Response | null { + if (!allowsGetHead(method)) { + return methodNotAllowedResponse(["GET", "HEAD"], mode); + } + return null; +} + +export function validateTargetId(idStr: string, mode: RuntimeMode): { id: number } | Response { + const id = Number(idStr); + if (!Number.isInteger(id) || id <= 0) { + return jsonResponse(createApiError("Invalid target ID", 400), { mode, status: 400 }); + } + return { id }; +} + +export function validateTimeRange( + from: string | null, + to: string | null, + mode: RuntimeMode, +): { from: string; to: string } | Response { + if (!from || !to) { + return jsonResponse(createApiError("from and to parameters are required", 400), { mode, status: 400 }); + } + + if (isNaN(new Date(from).getTime()) || isNaN(new Date(to).getTime())) { + return jsonResponse(createApiError("Invalid from or to parameter format", 400), { mode, status: 400 }); + } + + return { from, to }; +} + +export function validatePagination( + pageParam: string | null, + pageSizeParam: string | null, + mode: RuntimeMode, +): { page: number; pageSize: number } | Response { + let page = 1; + let pageSize = 20; + + if (pageParam !== null) { + page = Number(pageParam); + if (!Number.isInteger(page) || page <= 0) { + return jsonResponse(createApiError("Invalid page parameter", 400), { mode, status: 400 }); + } + } + + if (pageSizeParam !== null) { + pageSize = Number(pageSizeParam); + if (!Number.isInteger(pageSize) || pageSize <= 0) { + return jsonResponse(createApiError("Invalid pageSize parameter", 400), { mode, status: 400 }); + } + } + + return { page, pageSize }; +} diff --git a/src/server/routes/health.ts b/src/server/routes/health.ts new file mode 100644 index 0000000..dd05b2e --- /dev/null +++ b/src/server/routes/health.ts @@ -0,0 +1,10 @@ +import type { RuntimeMode } from "../../shared/api"; +import { createHealthResponse, jsonResponse, allowsGetHead, methodNotAllowedResponse } from "../helpers"; + +export function handleHealth(method: string, mode: RuntimeMode): Response { + if (!allowsGetHead(method)) { + return methodNotAllowedResponse(["GET", "HEAD"], mode); + } + + return jsonResponse(createHealthResponse(), { method, mode }); +} diff --git a/src/server/routes/history.ts b/src/server/routes/history.ts new file mode 100644 index 0000000..cda65c8 --- /dev/null +++ b/src/server/routes/history.ts @@ -0,0 +1,30 @@ +import type { RuntimeMode, HistoryResponse } from "../../shared/api"; +import type { ProbeStore } from "../checker/store"; +import { jsonResponse, mapCheckResult } from "../helpers"; +import { validateTargetId, validateTimeRange, validatePagination } from "../middleware"; + +export function handleHistory(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response { + const idResult = validateTargetId(idStr, mode); + if (idResult instanceof Response) return idResult; + + const target = store.getTargetById(idResult.id); + if (!target) { + return jsonResponse({ error: "Target not found", status: 404 } as const, { method, mode, status: 404 }); + } + + const timeResult = validateTimeRange(url.searchParams.get("from"), url.searchParams.get("to"), mode); + if (timeResult instanceof Response) return timeResult; + + const pageResult = validatePagination(url.searchParams.get("page"), url.searchParams.get("pageSize"), mode); + if (pageResult instanceof Response) return pageResult; + + const result = store.getHistory(idResult.id, timeResult.from, timeResult.to, pageResult.page, pageResult.pageSize); + const response: HistoryResponse = { + items: result.items.map(mapCheckResult), + total: result.total, + page: result.page, + pageSize: result.pageSize, + }; + + return jsonResponse(response, { method, mode }); +} diff --git a/src/server/routes/summary.ts b/src/server/routes/summary.ts new file mode 100644 index 0000000..59226d6 --- /dev/null +++ b/src/server/routes/summary.ts @@ -0,0 +1,15 @@ +import type { RuntimeMode, SummaryResponse } from "../../shared/api"; +import type { ProbeStore } from "../checker/store"; +import { jsonResponse } from "../helpers"; + +export function handleSummary(store: ProbeStore, method: string, mode: RuntimeMode): Response { + const summary = store.getSummary(); + const response: SummaryResponse = { + total: summary.total, + up: summary.up, + down: summary.down, + lastCheckTime: summary.lastCheckTime, + }; + + return jsonResponse(response, { method, mode }); +} diff --git a/src/server/routes/targets.ts b/src/server/routes/targets.ts new file mode 100644 index 0000000..5374497 --- /dev/null +++ b/src/server/routes/targets.ts @@ -0,0 +1,36 @@ +import type { RuntimeMode, TargetStatus } from "../../shared/api"; +import type { ProbeStore } from "../checker/store"; +import { formatDuration, jsonResponse, mapCheckResult } from "../helpers"; + +export function handleTargets(store: ProbeStore, method: string, mode: RuntimeMode): Response { + const targets = store.getTargets(); + const latestChecksMap = store.getLatestChecksMap(); + const allStats = store.getAllTargetStats(); + + const result: TargetStatus[] = targets.map((target) => { + const latest = latestChecksMap.get(target.id) ?? null; + const stats = allStats.get(target.id) ?? { totalChecks: 0, availability: 0 }; + const recentSamples = store.getRecentSamples(target.id, 30); + + return { + id: target.id, + name: target.name, + type: target.type, + target: target.target, + group: target.grp, + interval: formatDuration(target.interval_ms), + latestCheck: latest ? mapCheckResult(latest) : null, + recentSamples: recentSamples.map((s) => ({ + timestamp: s.timestamp, + durationMs: s.duration_ms, + up: s.matched === 1, + })), + stats: { + totalChecks: stats.totalChecks, + availability: stats.availability, + }, + }; + }); + + return jsonResponse(result, { method, mode }); +} diff --git a/src/server/routes/trend.ts b/src/server/routes/trend.ts new file mode 100644 index 0000000..98a7105 --- /dev/null +++ b/src/server/routes/trend.ts @@ -0,0 +1,26 @@ +import type { RuntimeMode, TrendPoint } from "../../shared/api"; +import type { ProbeStore } from "../checker/store"; +import { jsonResponse } from "../helpers"; +import { validateTargetId, validateTimeRange } from "../middleware"; + +export function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response { + const idResult = validateTargetId(idStr, mode); + if (idResult instanceof Response) return idResult; + + const target = store.getTargetById(idResult.id); + if (!target) { + return jsonResponse({ error: "Target not found", status: 404 } as const, { method, mode, status: 404 }); + } + + const timeResult = validateTimeRange(url.searchParams.get("from"), url.searchParams.get("to"), mode); + if (timeResult instanceof Response) return timeResult; + + const trend: TrendPoint[] = store.getTrend(idResult.id, timeResult.from, timeResult.to).map((row) => ({ + hour: row.hour, + avgDurationMs: row.avgDurationMs, + availability: Math.round(row.availability * 100) / 100, + totalChecks: row.totalChecks, + })); + + return jsonResponse(trend, { method, mode }); +} diff --git a/src/server/static.ts b/src/server/static.ts new file mode 100644 index 0000000..de0738d --- /dev/null +++ b/src/server/static.ts @@ -0,0 +1,54 @@ +import type { RuntimeMode } from "../shared/api"; +import { createHeaders } from "./helpers"; +import type { StaticAssets } from "./app"; + +export function serveStaticAsset(pathname: string, staticAssets: StaticAssets, mode: RuntimeMode): Response { + if (pathname === "/") { + return htmlResponse(staticAssets.indexHtml, mode); + } + + const asset = staticAssets.files[pathname]; + + if (asset) { + return new Response(asset, { + headers: createHeaders(mode, { + "Content-Type": contentTypeFor(pathname), + "Cache-Control": "public, max-age=31536000, immutable", + }), + }); + } + + if (pathname.startsWith("/assets/") || hasFileExtension(pathname)) { + return new Response("Not Found", { + status: 404, + headers: createHeaders(mode, { "Content-Type": "text/plain; charset=utf-8" }), + }); + } + + return htmlResponse(staticAssets.indexHtml, mode); +} + +export function htmlResponse(indexHtml: Blob, mode: RuntimeMode): Response { + return new Response(indexHtml, { + headers: createHeaders(mode, { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-cache", + }), + }); +} + +export function hasFileExtension(pathname: string): boolean { + return /\/[^/]+\.[^/]+$/.test(pathname); +} + +export function contentTypeFor(pathname: string): string { + if (pathname.endsWith(".js") || pathname.endsWith(".mjs")) return "text/javascript; charset=utf-8"; + if (pathname.endsWith(".css")) return "text/css; charset=utf-8"; + if (pathname.endsWith(".svg")) return "image/svg+xml"; + if (pathname.endsWith(".json")) return "application/json; charset=utf-8"; + if (pathname.endsWith(".png")) return "image/png"; + if (pathname.endsWith(".jpg") || pathname.endsWith(".jpeg")) return "image/jpeg"; + if (pathname.endsWith(".ico")) return "image/x-icon"; + + return "application/octet-stream"; +} diff --git a/src/shared/api.ts b/src/shared/api.ts index 09864a2..6b24dc6 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -58,7 +58,7 @@ export interface CheckResult { export interface CheckFailure { kind: "error" | "mismatch"; - phase: string; + phase: "status" | "duration" | "headers" | "body" | "exitCode" | "stdout" | "stderr"; path: string; expected?: unknown; actual?: unknown; diff --git a/tests/server/checker/store.test.ts b/tests/server/checker/store.test.ts index ac0051c..54b577a 100644 --- a/tests/server/checker/store.test.ts +++ b/tests/server/checker/store.test.ts @@ -297,4 +297,75 @@ describe("ProbeStore", () => { cascadeStore.close(); }); + + test("getLatestChecksMap 返回所有 target 的最新 check", () => { + const targets = store.getTargets(); + const map = store.getLatestChecksMap(); + expect(map).toBeInstanceOf(Map); + + for (const target of targets) { + const latest = map.get(target.id); + if (latest) { + expect(latest.target_id).toBe(target.id); + } + } + }); + + test("getLatestChecksMap 对无记录的 target 不包含 key", () => { + const freshStore = new ProbeStore(join(tempDir, "fresh-map.db")); + freshStore.syncTargets([ + { + type: "http", + name: "no-records", + group: "default", + http: { url: "http://no.records", method: "GET", headers: {}, maxBodyBytes: 104857600 }, + intervalMs: 30000, + timeoutMs: 10000, + }, + ]); + + const map = freshStore.getLatestChecksMap(); + expect(map.size).toBe(0); + + freshStore.close(); + }); + + test("getAllTargetStats 返回所有 target 的聚合统计", () => { + const targets = store.getTargets(); + const t1Id = targets[0]!.id; + const t2Id = targets[1]!.id; + + const stats = store.getAllTargetStats(); + expect(stats).toBeInstanceOf(Map); + + const stats1 = stats.get(t1Id); + expect(stats1).toBeDefined(); + expect(stats1!.totalChecks).toBeGreaterThan(0); + expect(stats1!.availability).toBeGreaterThanOrEqual(0); + + const stats2 = stats.get(t2Id); + if (stats2) { + expect(stats2!.totalChecks).toBe(0); + expect(stats2!.availability).toBe(0); + } + }); + + test("getAllTargetStats 对无记录的 target 不包含 key", () => { + const freshStore = new ProbeStore(join(tempDir, "fresh-stats.db")); + freshStore.syncTargets([ + { + type: "http", + name: "no-stats", + group: "default", + http: { url: "http://no.stats", method: "GET", headers: {}, maxBodyBytes: 104857600 }, + intervalMs: 30000, + timeoutMs: 10000, + }, + ]); + + const stats = freshStore.getAllTargetStats(); + expect(stats.size).toBe(0); + + freshStore.close(); + }); });