refactor: 全面优化后端代码质量与架构
- 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 后端开发指引
This commit is contained in:
158
DEVELOPMENT.md
158
DEVELOPMENT.md
@@ -9,10 +9,19 @@
|
|||||||
```text
|
```text
|
||||||
src/
|
src/
|
||||||
server/
|
server/
|
||||||
app.ts Bun HTTP 路由(API + 静态资源 + SPA fallback)
|
app.ts Bun HTTP 路由入口(路由分发 + API 汇聚)
|
||||||
config.ts CLI 参数解析
|
config.ts CLI 参数解析
|
||||||
dev.ts 开发期启动入口
|
dev.ts 开发期启动入口
|
||||||
server.ts HTTP server 启动
|
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/
|
checker/
|
||||||
types.ts 类型定义
|
types.ts 类型定义
|
||||||
config-loader.ts YAML 配置解析与校验
|
config-loader.ts YAML 配置解析与校验
|
||||||
@@ -20,11 +29,11 @@ src/
|
|||||||
fetcher.ts HTTP 拨测执行
|
fetcher.ts HTTP 拨测执行
|
||||||
command-runner.ts 命令行拨测执行
|
command-runner.ts 命令行拨测执行
|
||||||
size.ts 大小单位解析
|
size.ts 大小单位解析
|
||||||
engine.ts 调度引擎(按 interval 分组、组内并发)
|
engine.ts 调度引擎(按 interval 分组的 es-toolkit groupBy + Semaphore 并发控制)
|
||||||
expect/
|
expect/
|
||||||
http.ts HTTP 响应断言
|
http.ts HTTP 响应断言
|
||||||
command.ts 命令行输出断言
|
command.ts 命令行输出断言
|
||||||
body.ts HTTP body 断言(JSONPath/XPath/CSS)
|
body.ts HTTP body 断言(JSONPath/XPath/CSS,类型判断使用 es-toolkit)
|
||||||
failure.ts 失败信息类型
|
failure.ts 失败信息类型
|
||||||
shared/
|
shared/
|
||||||
api.ts 前后端共享 TypeScript 类型
|
api.ts 前后端共享 TypeScript 类型
|
||||||
@@ -82,6 +91,149 @@ bun run verify
|
|||||||
|
|
||||||
前端只通过 HTTP 调用后端,API 路径为 `/api/*`。共享类型放在 `src/shared`,前端不得 import `src/server` 的运行时实现。
|
前端只通过 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/` 下创建 `<name>.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: <code> }`,状态码 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,样式开发遵循以下优先级(从高到低):
|
前端基于 TDesign React 构建UI,样式开发遵循以下优先级(从高到低):
|
||||||
|
|||||||
1
bun.lock
1
bun.lock
@@ -8,6 +8,7 @@
|
|||||||
"@tanstack/react-query": "^5.100.10",
|
"@tanstack/react-query": "^5.100.10",
|
||||||
"@xmldom/xmldom": "^0.9.10",
|
"@xmldom/xmldom": "^0.9.10",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
|
"es-toolkit": "^1.46.1",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ context: |
|
|||||||
- 这是基于bun实现的前端后一体化项目,使用bun作为唯一包管理器,严禁使用pnpm、npm,使用bunx运行工具,严禁使用npx、pnpx
|
- 这是基于bun实现的前端后一体化项目,使用bun作为唯一包管理器,严禁使用pnpm、npm,使用bunx运行工具,严禁使用npx、pnpx
|
||||||
- src/server目录下是基于bun实现的后端代码
|
- src/server目录下是基于bun实现的后端代码
|
||||||
- src/web目录下是基于vite、react、TDesign实现的前端代码
|
- src/web目录下是基于vite、react、TDesign实现的前端代码
|
||||||
- 代码开发优先使用公共组件实现功能逻辑(优先级:官方库>主流三方库>项目公共工具>自行实现)
|
- 后端库使用优先级:Bun 内置 API > es-toolkit > 主流三方库 > 项目公共工具 > 自行实现
|
||||||
- 前端样式开发优先级:TDesign组件 > 组件props > TDesign CSS tokens(--td-*) > styles.css CSS类 > 自行开发组件
|
- 前端样式开发优先级: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提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
|
||||||
- 禁止创建git操作task
|
- 禁止创建git操作task
|
||||||
- 积极使用subagents精心设计并行任务,节省上下文空间,加速任务执行
|
- 积极使用subagents精心设计并行任务,节省上下文空间,加速任务执行
|
||||||
|
|||||||
78
openspec/specs/api-route-separation/spec.md
Normal file
78
openspec/specs/api-route-separation/spec.md
Normal file
@@ -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 的客户端路由
|
||||||
102
openspec/specs/backend-code-quality/spec.md
Normal file
102
openspec/specs/backend-code-quality/spec.md
Normal file
@@ -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 的对象
|
||||||
73
openspec/specs/batch-data-queries/spec.md
Normal file
73
openspec/specs/batch-data-queries/spec.md
Normal file
@@ -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<number, StoredCheckResult | null>`
|
||||||
|
|
||||||
|
#### 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<number, { totalChecks, availability }>`
|
||||||
|
|
||||||
|
#### 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()`,因需要在事务闭包内持有引用
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
"@tanstack/react-query": "^5.100.10",
|
"@tanstack/react-query": "^5.100.10",
|
||||||
"@xmldom/xmldom": "^0.9.10",
|
"@xmldom/xmldom": "^0.9.10",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
|
"es-toolkit": "^1.46.1",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import type {
|
import type { RuntimeMode } from "../shared/api";
|
||||||
ApiErrorResponse,
|
|
||||||
CheckFailure,
|
|
||||||
CheckResult,
|
|
||||||
HealthResponse,
|
|
||||||
HistoryResponse,
|
|
||||||
RuntimeMode,
|
|
||||||
SummaryResponse,
|
|
||||||
TargetStatus,
|
|
||||||
TrendPoint,
|
|
||||||
} from "../shared/api";
|
|
||||||
import type { StoredCheckResult } from "./checker/types";
|
|
||||||
import type { ProbeStore } from "./checker/store";
|
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 {
|
export interface StaticAssets {
|
||||||
indexHtml: Blob;
|
indexHtml: Blob;
|
||||||
@@ -28,11 +25,7 @@ export function createFetchHandler(options: AppOptions) {
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
if (url.pathname === "/health") {
|
if (url.pathname === "/health") {
|
||||||
if (!allowsGetHead(request.method)) {
|
return handleHealth(request.method, options.mode);
|
||||||
return methodNotAllowedResponse(["GET", "HEAD"], options.mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(createHealthResponse(), { method: request.method, mode: options.mode });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname.startsWith("/api/") && options.store) {
|
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 {
|
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)) {
|
const method = request.method;
|
||||||
return methodNotAllowedResponse(["GET", "HEAD"], mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname === "/api/summary") {
|
if (url.pathname === "/api/summary") {
|
||||||
return jsonResponse(createSummaryResponse(store), { method, mode });
|
return handleSummary(store, method, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === "/api/targets") {
|
if (url.pathname === "/api/targets") {
|
||||||
return jsonResponse(createTargetsResponse(store), { method, mode });
|
return handleTargets(store, method, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const historyMatch = url.pathname.match(/^\/api\/targets\/([^/]+)\/history$/);
|
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 });
|
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";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isError } from "es-toolkit";
|
||||||
import type { CheckResult, ResolvedCommandTarget } from "./types";
|
import type { CheckResult, ResolvedCommandTarget } from "./types";
|
||||||
import { checkCommandExpect } from "./expect/command";
|
import { checkCommandExpect } from "./expect/command";
|
||||||
import { errorFailure } from "./expect/failure";
|
import { errorFailure } from "./expect/failure";
|
||||||
@@ -73,7 +74,7 @@ export async function runCommandCheck(target: ResolvedCommandTarget): Promise<Ch
|
|||||||
matched: false,
|
matched: false,
|
||||||
durationMs,
|
durationMs,
|
||||||
statusDetail: null,
|
statusDetail: null,
|
||||||
failure: errorFailure("exitCode", "spawn", error instanceof Error ? error.message : String(error)),
|
failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,32 +2,31 @@ import type { CheckResult, ResolvedTarget } from "./types";
|
|||||||
import type { ProbeStore } from "./store";
|
import type { ProbeStore } from "./store";
|
||||||
import { runHttpCheck } from "./fetcher";
|
import { runHttpCheck } from "./fetcher";
|
||||||
import { runCommandCheck } from "./command-runner";
|
import { runCommandCheck } from "./command-runner";
|
||||||
|
import { groupBy, Semaphore } from "es-toolkit";
|
||||||
|
|
||||||
export class ProbeEngine {
|
export class ProbeEngine {
|
||||||
private timers: ReturnType<typeof setInterval>[] = [];
|
private timers: ReturnType<typeof setInterval>[] = [];
|
||||||
private store: ProbeStore;
|
private store: ProbeStore;
|
||||||
private targets: ResolvedTarget[];
|
private targets: ResolvedTarget[];
|
||||||
private targetNameToId: Map<string, number> = new Map();
|
private targetNameToId: Map<string, number> = new Map();
|
||||||
private maxConcurrentChecks: number;
|
private semaphore: Semaphore;
|
||||||
private running = 0;
|
|
||||||
private queue: Array<() => void> = [];
|
|
||||||
|
|
||||||
constructor(store: ProbeStore, targets: ResolvedTarget[], maxConcurrentChecks?: number) {
|
constructor(store: ProbeStore, targets: ResolvedTarget[], maxConcurrentChecks?: number) {
|
||||||
this.store = store;
|
this.store = store;
|
||||||
this.targets = targets;
|
this.targets = targets;
|
||||||
this.maxConcurrentChecks = maxConcurrentChecks ?? 20;
|
this.semaphore = new Semaphore(maxConcurrentChecks ?? 20);
|
||||||
this.refreshCache();
|
this.refreshCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
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);
|
void this.probeGroup(groupTargets);
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
void this.probeGroup(groupTargets);
|
void this.probeGroup(groupTargets);
|
||||||
}, intervalMs);
|
}, Number(intervalMs));
|
||||||
|
|
||||||
this.timers.push(timer);
|
this.timers.push(timer);
|
||||||
}
|
}
|
||||||
@@ -40,45 +39,14 @@ export class ProbeEngine {
|
|||||||
this.timers = [];
|
this.timers = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private groupByInterval(targets: ResolvedTarget[]): Map<number, ResolvedTarget[]> {
|
|
||||||
const groups = new Map<number, ResolvedTarget[]>();
|
|
||||||
|
|
||||||
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<void> {
|
|
||||||
if (this.running < this.maxConcurrentChecks) {
|
|
||||||
this.running++;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return new Promise<void>((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<void> {
|
private async probeGroup(targets: ResolvedTarget[]): Promise<void> {
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
targets.map(async (target) => {
|
targets.map(async (target) => {
|
||||||
await this.acquire();
|
await this.semaphore.acquire();
|
||||||
try {
|
try {
|
||||||
return await this.runCheck(target);
|
return await this.runCheck(target);
|
||||||
} finally {
|
} finally {
|
||||||
this.release();
|
this.semaphore.release();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import * as cheerio from "cheerio";
|
|||||||
import * as xpath from "xpath";
|
import * as xpath from "xpath";
|
||||||
import { DOMParser } from "@xmldom/xmldom";
|
import { DOMParser } from "@xmldom/xmldom";
|
||||||
import { mismatchFailure, errorFailure } from "./failure";
|
import { mismatchFailure, errorFailure } from "./failure";
|
||||||
|
import { isNil, isEmptyObject, isEqual, isPlainObject } from "es-toolkit";
|
||||||
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
||||||
|
|
||||||
export function evaluateJsonPath(json: unknown, path: string): unknown {
|
export function evaluateJsonPath(json: unknown, path: string): unknown {
|
||||||
if (!path.startsWith("$.")) return undefined;
|
if (!path.startsWith("$.")) return undefined;
|
||||||
@@ -34,7 +33,7 @@ export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
|
|||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "equals":
|
case "equals":
|
||||||
if (actual !== expected) return false;
|
if (!isEqual(actual, expected)) return false;
|
||||||
break;
|
break;
|
||||||
case "contains":
|
case "contains":
|
||||||
if (!String(actual).includes(expected as string)) return false;
|
if (!String(actual).includes(expected as string)) return false;
|
||||||
@@ -44,11 +43,7 @@ export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
|
|||||||
break;
|
break;
|
||||||
case "empty": {
|
case "empty": {
|
||||||
const isEmpty =
|
const isEmpty =
|
||||||
actual === null ||
|
isNil(actual) || actual === "" || (Array.isArray(actual) && actual.length === 0) || isEmptyObject(actual);
|
||||||
actual === undefined ||
|
|
||||||
actual === "" ||
|
|
||||||
(Array.isArray(actual) && actual.length === 0) ||
|
|
||||||
(typeof actual === "object" && Object.keys(actual as object).length === 0);
|
|
||||||
if (expected !== isEmpty) return false;
|
if (expected !== isEmpty) return false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -78,7 +73,7 @@ export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
|
export function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
|
||||||
if (isObject(expected)) {
|
if (isPlainObject(expected)) {
|
||||||
return applyOperator(actual, expected as ExpectOperator);
|
return applyOperator(actual, expected as ExpectOperator);
|
||||||
}
|
}
|
||||||
return applyOperator(actual, { equals: expected as string | number | boolean | null });
|
return applyOperator(actual, { equals: expected as string | number | boolean | null });
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import type { CheckResult, ResolvedHttpTarget } from "./types";
|
import type { CheckResult, ResolvedHttpTarget } from "./types";
|
||||||
import { checkHttpExpect } from "./expect/http";
|
import { checkHttpExpect } from "./expect/http";
|
||||||
import { errorFailure } from "./expect/failure";
|
import { errorFailure } from "./expect/failure";
|
||||||
|
import { isError } from "es-toolkit";
|
||||||
function headersToRecord(headers: Headers): Record<string, string> {
|
|
||||||
const result: Record<string, string> = {};
|
|
||||||
headers.forEach((value, key) => {
|
|
||||||
result[key] = value;
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runHttpCheck(target: ResolvedHttpTarget): Promise<CheckResult> {
|
export async function runHttpCheck(target: ResolvedHttpTarget): Promise<CheckResult> {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
@@ -27,7 +20,7 @@ export async function runHttpCheck(target: ResolvedHttpTarget): Promise<CheckRes
|
|||||||
|
|
||||||
const durationMs = Math.round(performance.now() - start);
|
const durationMs = Math.round(performance.now() - start);
|
||||||
const statusCode = response.status;
|
const statusCode = response.status;
|
||||||
const responseHeaders = headersToRecord(response.headers);
|
const responseHeaders = Object.fromEntries(response.headers);
|
||||||
|
|
||||||
const hasBodyRules = !!(target.expect?.body && target.expect.body.length > 0);
|
const hasBodyRules = !!(target.expect?.body && target.expect.body.length > 0);
|
||||||
|
|
||||||
@@ -93,7 +86,7 @@ export async function runHttpCheck(target: ResolvedHttpTarget): Promise<CheckRes
|
|||||||
failure: errorFailure(
|
failure: errorFailure(
|
||||||
"status",
|
"status",
|
||||||
"request",
|
"request",
|
||||||
isTimeout ? `请求超时 (${target.timeoutMs}ms)` : error instanceof Error ? error.message : String(error),
|
isTimeout ? `请求超时 (${target.timeoutMs}ms)` : isError(error) ? error.message : String(error),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export class ProbeStore {
|
|||||||
}): void {
|
}): void {
|
||||||
if (this.closed) return;
|
if (this.closed) return;
|
||||||
this.db
|
this.db
|
||||||
.prepare(
|
.query(
|
||||||
"INSERT INTO check_results (target_id, timestamp, matched, duration_ms, status_detail, failure) VALUES (?, ?, ?, ?, ?, ?)",
|
"INSERT INTO check_results (target_id, timestamp, matched, duration_ms, status_detail, failure) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
@@ -139,12 +139,12 @@ export class ProbeStore {
|
|||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
): { items: StoredCheckResult[]; total: number; page: number; pageSize: number } {
|
): { items: StoredCheckResult[]; total: number; page: number; pageSize: number } {
|
||||||
const countRow = this.db
|
const countRow = this.db
|
||||||
.prepare("SELECT COUNT(*) as total FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?")
|
.query("SELECT COUNT(*) as total FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?")
|
||||||
.get(targetId, from, to) as { total: number };
|
.get(targetId, from, to) as { total: number };
|
||||||
|
|
||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
const items = this.db
|
const items = this.db
|
||||||
.prepare(
|
.query(
|
||||||
"SELECT * FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
|
"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[];
|
.all(targetId, from, to, pageSize, offset) as StoredCheckResult[];
|
||||||
@@ -157,7 +157,7 @@ export class ProbeStore {
|
|||||||
availability: number;
|
availability: number;
|
||||||
} {
|
} {
|
||||||
const row = this.db
|
const row = this.db
|
||||||
.prepare(
|
.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
COUNT(*) as totalChecks,
|
COUNT(*) as totalChecks,
|
||||||
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount
|
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount
|
||||||
@@ -186,7 +186,7 @@ export class ProbeStore {
|
|||||||
totalChecks: number;
|
totalChecks: number;
|
||||||
}> {
|
}> {
|
||||||
return this.db
|
return this.db
|
||||||
.prepare(
|
.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
strftime('%Y-%m-%dT%H:00:00', timestamp) as hour,
|
strftime('%Y-%m-%dT%H:00:00', timestamp) as hour,
|
||||||
AVG(CASE WHEN matched = 1 THEN duration_ms END) as avgDurationMs,
|
AVG(CASE WHEN matched = 1 THEN duration_ms END) as avgDurationMs,
|
||||||
@@ -212,12 +212,13 @@ export class ProbeStore {
|
|||||||
lastCheckTime: string | null;
|
lastCheckTime: string | null;
|
||||||
} {
|
} {
|
||||||
const targets = this.getTargets();
|
const targets = this.getTargets();
|
||||||
|
const latestChecksMap = this.getLatestChecksMap();
|
||||||
let up = 0;
|
let up = 0;
|
||||||
let down = 0;
|
let down = 0;
|
||||||
let lastCheckTime: string | null = null;
|
let lastCheckTime: string | null = null;
|
||||||
|
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
const latest = this.getLatestCheck(target.id);
|
const latest = latestChecksMap.get(target.id);
|
||||||
|
|
||||||
if (latest) {
|
if (latest) {
|
||||||
if (latest.matched) {
|
if (latest.matched) {
|
||||||
@@ -247,7 +248,7 @@ export class ProbeStore {
|
|||||||
limit: number,
|
limit: number,
|
||||||
): Array<{ timestamp: string; duration_ms: number | null; matched: number }> {
|
): Array<{ timestamp: string; duration_ms: number | null; matched: number }> {
|
||||||
return this.db
|
return this.db
|
||||||
.prepare(
|
.query(
|
||||||
"SELECT timestamp, duration_ms, matched FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT ?",
|
"SELECT timestamp, duration_ms, matched FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT ?",
|
||||||
)
|
)
|
||||||
.all(targetId, limit) as Array<{
|
.all(targetId, limit) as Array<{
|
||||||
@@ -257,6 +258,38 @@ export class ProbeStore {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLatestChecksMap(): Map<number, StoredCheckResult> {
|
||||||
|
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<number, { totalChecks: number; availability: number }> {
|
||||||
|
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<number, { totalChecks: number; availability: number }>();
|
||||||
|
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 {
|
close(): void {
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
this.db.close();
|
this.db.close();
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { CheckResult as ApiCheckResult, CheckFailure } from "../../shared/api";
|
||||||
|
|
||||||
export type TargetType = "http" | "command";
|
export type TargetType = "http" | "command";
|
||||||
|
|
||||||
export interface ProbeConfig {
|
export interface ProbeConfig {
|
||||||
@@ -147,22 +149,9 @@ export interface ResolvedCommandConfig {
|
|||||||
|
|
||||||
export type ResolvedTarget = ResolvedHttpTarget | ResolvedCommandTarget;
|
export type ResolvedTarget = ResolvedHttpTarget | ResolvedCommandTarget;
|
||||||
|
|
||||||
export interface CheckFailure {
|
export type { CheckFailure };
|
||||||
kind: "error" | "mismatch";
|
export interface CheckResult extends ApiCheckResult {
|
||||||
phase: "status" | "duration" | "headers" | "body" | "exitCode" | "stdout" | "stderr";
|
|
||||||
path: string;
|
|
||||||
expected?: unknown;
|
|
||||||
actual?: unknown;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CheckResult {
|
|
||||||
targetName: string;
|
targetName: string;
|
||||||
timestamp: string;
|
|
||||||
matched: boolean;
|
|
||||||
durationMs: number | null;
|
|
||||||
statusDetail: string | null;
|
|
||||||
failure: CheckFailure | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoredTarget {
|
export interface StoredTarget {
|
||||||
|
|||||||
79
src/server/helpers.ts
Normal file
79
src/server/helpers.ts
Normal file
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
58
src/server/middleware.ts
Normal file
58
src/server/middleware.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
10
src/server/routes/health.ts
Normal file
10
src/server/routes/health.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
30
src/server/routes/history.ts
Normal file
30
src/server/routes/history.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
15
src/server/routes/summary.ts
Normal file
15
src/server/routes/summary.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
36
src/server/routes/targets.ts
Normal file
36
src/server/routes/targets.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
26
src/server/routes/trend.ts
Normal file
26
src/server/routes/trend.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
54
src/server/static.ts
Normal file
54
src/server/static.ts
Normal file
@@ -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";
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ export interface CheckResult {
|
|||||||
|
|
||||||
export interface CheckFailure {
|
export interface CheckFailure {
|
||||||
kind: "error" | "mismatch";
|
kind: "error" | "mismatch";
|
||||||
phase: string;
|
phase: "status" | "duration" | "headers" | "body" | "exitCode" | "stdout" | "stderr";
|
||||||
path: string;
|
path: string;
|
||||||
expected?: unknown;
|
expected?: unknown;
|
||||||
actual?: unknown;
|
actual?: unknown;
|
||||||
|
|||||||
@@ -297,4 +297,75 @@ describe("ProbeStore", () => {
|
|||||||
|
|
||||||
cascadeStore.close();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user