1
0

Compare commits

...

9 Commits

Author SHA1 Message Date
7926514986 feat: 配置变量系统与 target id/name 双字段标识
- 新增顶层 variables 段支持 string/number/boolean 字面量
- target 字符串字段支持 、、{...} 转义语法
- 变量解析优先级: variables -> process.env -> 默认值 -> 报错
- 完整引用保留原始类型,部分引用拼接为字符串
- 变量替换在 YAML 解析后、AJV 校验前执行
- 替换仅作用于 targets,跳过 id/type 字段
- target 新增必填 id 字段作为唯一标识,name 改为可选展示名称
- 数据库存储/API/前端全面迁移到 id 标识
- 统一 checker 运行时类型检查为 es-toolkit predicates
- 同步 delta specs 到主 specs,归档 config-variables 变更
2026-05-17 00:37:54 +08:00
366b3211c8 fix: 移除 README 示例配置中的 defaults.http.method 2026-05-16 21:46:20 +08:00
e924732a02 refactor: 移除 defaults.http.method 配置,简化默认值体系
- HTTP checker defaults schema 不再支持 method 字段
- resolve 逻辑从三级 fallback 简化为两级(target -> 内置默认)
- 配置文件中出现 defaults.http.method 将触发未知字段校验错误
- per-target http.method 覆盖功能保持不变
- 同步更新示例配置、README 文档和测试用例
2026-05-16 21:45:08 +08:00
04c24e6796 docs: 重写 README 为结构化项目文档,精简示例配置 2026-05-16 20:58:04 +08:00
146cef982e feat: 新增 DB checker — 支持 PostgreSQL/MySQL/SQLite 连接测试与 SQL 查询断言
- 实现 db 类型 checker,使用 Bun 内置 SQL 类
- 支持 db.url 连接字符串和可选 db.query 查询语句
- expect 支持 maxDurationMs、rowCount、rows 逐列校验
- 凭据屏蔽序列化输出
- SQLite 内存数据库测试覆盖
2026-05-16 09:00:15 +08:00
c36df94e59 chore: 归档 responsive-resizable-drawer 变更 2026-05-16 00:17:31 +08:00
f8d563c668 feat: Header 倒计时数字滚动动画 — @number-flow/react 替换静态文本 2026-05-16 00:14:35 +08:00
88f4119a4e feat: Drawer 响应式默认宽度与拖拽调整,统计卡片上下布局优化
Drawer 宽度从固定百分比改为按视口响应式默认值(6段断点),宽屏占比更小、窄屏占比更大。

启用 TDesign sizeDraggable 原生拖拽调整能力,配置 min/max 视口安全边界,不持久化拖拽宽度。

概览统计卡片改为 TDesign Statistic 上下布局(与 SummaryCards 一致),提升窄屏视觉体验。

Drawer header 间距调大,MutationObserver polyfill 补全。
2026-05-15 23:10:08 +08:00
c46ab14cce feat: Dashboard 主题模式切换 — 系统跟随/明亮/黑暗,localStorage 持久化,TDesign theme-mode 驱动
新增 useThemePreference hook 和纯工具函数,支持系统/明亮/黑暗三态主题选择、
matchMedia 系统主题跟随、localStorage 持久化和启动期主题预应用,通过 <html
theme-mode> 驱动 TDesign 主题变量切换。

Header 右侧控件重新组织为 .dashboard-header-controls 单行桌面布局,主题
RadioGroup 位于刷新频率 RadioGroup 前。

附带:build.ts import specifier 改为跨平台 sep 转换;config-loader 测试适配
Windows PATH 和 YAML 路径转义;test-utils 类型窄化修复。
2026-05-15 22:18:29 +08:00
81 changed files with 3708 additions and 724 deletions

View File

@@ -36,6 +36,7 @@ src/
checker/
types.ts 基础类型定义ResolvedTargetBase、RawTargetConfig、DefaultsConfig、CheckResult 等基础 interface
config-loader.ts YAML 配置解析、契约校验、语义校验与运行期解析(输出 ResolvedConfig
variables.ts 配置 variables 提取、target 字符串变量替换和 unresolved-variable issue 生成
schema/ TypeBox + Ajv 配置契约、schema fragments、issue 渲染和 schema 导出入口
builder.ts 全量 JSON Schema 组装(遍历 registry 生成)
fragments.ts 共享 TypeBox schema 片段duration、size、operator 等)
@@ -71,9 +72,10 @@ src/
target-table-filters.ts 表格筛选器
target-table-sorters.ts 表格排序器
color-threshold.ts 可用率颜色阈值函数
hooks/ TanStack Query 数据层
hooks/ React hooks数据查询、Drawer 状态、浏览器 UI 偏好)
use-queries.ts 全局面板查询 hookdashboard/meta/metrics
use-target-detail.ts 目标详情 Drawer 状态与条件查询 hook
use-theme-preference.ts 主题模式偏好、本地存储和 TDesign theme-mode 应用 hook
utils/ 前端工具函数
time.ts 时间处理subtractHours、相对时间、动态时长单位
scripts/ 构建、schema 生成和清理脚本
@@ -98,7 +100,8 @@ probe-config.schema.json 用户配置 JSON Schema 导出物(用于 IDE 自动
启动流程:
dev.ts / main.ts → readRuntimeConfig(cli args, 仅提取 configPath)
→ bootstrap({ configPath, mode })
→ loadConfig(yaml) → ResolvedConfig{ host, port, dataDir, maxConcurrentChecks, retentionMs, targets }
→ loadConfig(yamlYAML 解析 → 变量替换 → 契约校验 → 语义校验 → resolve)
→ ResolvedConfig{ host, port, dataDir, maxConcurrentChecks, retentionMs, targets }
→ ProbeStore(db) → store.syncTargets(targets)
→ ProbeEngine(store, targets, maxConcurrentChecks, retentionMs) → engine.start()
→ startServer({ config, mode, store })
@@ -191,7 +194,7 @@ export function handleMetrics(idStr: string, url: URL, store: ProbeStore, mode:
- **共享类型**以 `src/shared/api.ts` 为唯一源头,前后端共同引用
- 前端不得 `import src/server/` 下的任何文件
- **严格联合类型**优先于宽类型:如 `phase: "status" | "duration" | ...` 而非 `phase: string`
- **后端内部扩展**`checker/types.ts``CheckResult` 通过 `extends` 共享版本的 `ApiCheckResult` 增加 `targetName` 等内部字段
- **后端内部扩展**`checker/types.ts``CheckResult` 通过 `extends` 共享版本的 `ApiCheckResult` 增加 `targetId` 等内部字段
- 存储层类型(`StoredTarget``StoredCheckResult`)独立定义,与 API 类型分离
- **Checker 类型分层**
- `checker/types.ts` 定义 base interface`ResolvedTargetBase``RawTargetConfig``DefaultsConfig`),使用 index signature 支持扩展
@@ -203,7 +206,9 @@ export function handleMetrics(idStr: string, url: URL, store: ProbeStore, mode:
### 1.6 配置契约与校验
配置加载流程固定为:`unknown -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig`
配置加载流程固定为:`unknown -> 变量替换 -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig`
变量替换阶段由 `variables.ts` 负责,在 YAML 解析之后、AJV 契约校验之前执行。顶层 `variables` 支持 string/number/boolean 字面量target 字符串字段支持 `${key}``${key|default}``$${key}`,解析优先级为 `variables -> process.env -> 默认值`;替换范围仅限 `targets`,且跳过 `id``type` 字段。
`config-loader.ts` 只负责 YAML 解析、契约调度、公共语义校验和最终运行期解析checker 专属规则必须下沉到对应 checker 的 `schema.ts``validate.ts`
@@ -221,7 +226,7 @@ export function handleMetrics(idStr: string, url: URL, store: ProbeStore, mode:
契约层使用 `src/server/checker/schema/` 中的 TypeBox fragments 生成 JSON Schema并用 Ajv 执行启动期校验。Ajv 必须保持严格拒绝模式:`allErrors: true`、不启用类型强制转换、不注入默认值、不自动删除未知字段。
默认对象策略是 `additionalProperties: false`。只有明确声明的动态键值表可以开放任意键名,例如 `http.headers``defaults.http.headers``expect.headers``cmd.env`
默认对象策略是 `additionalProperties: false`。只有明确声明的动态键值表可以开放任意键名,例如 `variables``http.headers``defaults.http.headers``expect.headers``cmd.env`
契约校验和语义 validator 都必须返回 `ConfigValidationIssue[]`,不要在 validator 内直接拼接最终用户错误字符串。最终错误由 `formatConfigIssues()` 统一渲染,错误路径需要尽量包含 `targetName``defaults`/root 路径。
@@ -424,7 +429,7 @@ TcpChecker implements Checker
| 方法 | 用途 |
| ------------------------------------------ | ----------------------------------------------------------- |
| `syncTargets(targets)` | 启动期同步 targets基于 name 做 upsert + delete 事务) |
| `syncTargets(targets)` | 启动期同步 targets基于配置 `id` 做 upsert + delete 事务) |
| `insertCheckResult()` | 写入单条检查结果 |
| `getTargets()` | 查询全部 targetsdefault 分组优先排序) |
| `getLatestChecksMap()` | 批量获取每个 target 的最新检查结果(单次 SQL 聚合) |
@@ -458,8 +463,8 @@ TcpChecker implements Checker
**Schema**
- `targets` 表:nameUNIQUE、type、target展示摘要、configJSON、interval_ms、timeout_ms、expectJSON、grp
- `check_results`target_idFK CASCADE、timestamp、matched0/1、duration_ms、status_detail、failureJSON
- `targets` 表:idTEXT PRIMARY KEY配置 target id、name展示名称、type、target展示摘要、configJSON、interval_ms、timeout_ms、expectJSON、grp
- `check_results`target_idTEXT FK CASCADE,引用配置 target id、timestamp、matched0/1、duration_ms、status_detail、failureJSON
- 复合索引:`(target_id, timestamp)`
### 1.9 拨测引擎
@@ -468,7 +473,7 @@ TcpChecker implements Checker
- **并发控制**`es-toolkit/Semaphore` 限制全局最大并发数(`maxConcurrentChecks`,默认 20`acquire()` 阻塞等待
- **Runner 选择**`engine.runCheck()` 通过 `checkerRegistry.get(target.type)` 获取 checker并调用 `checker.execute(target, { signal })`
- **超时控制**`ProbeEngine` 为每次检查创建 `AbortController` 并按 `target.timeoutMs` 触发 abortchecker 必须使用 `CheckerContext.signal` 感知超时HTTP 将 signal 传给 `fetch()`Cmd 在 signal abort 时 `proc.kill()`
- **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLiteengine 通过 `targetNameToId` 缓存 name→id 映射
- **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLiteengine 基于配置 target id 确认目标仍存在
- **异常可观测**`probeGroup()``Promise.allSettled` 的 rejected 结果通过索引关联 target并写入 `phase:"internal"` 的失败记录
- **数据清理**:当 `retentionMs > 0`engine 启动时立即执行一次 `store.prune()`,之后每小时定时执行,按 `timestamp` 清理过期数据
- **生命周期**`start()`/`stop()` 管理定时器(含调度定时器和清理定时器),`stop()` 清理所有 `setInterval`
@@ -542,6 +547,7 @@ CommandChecker.execute → 收集观测(exitCode/stdout/stderr/durationMs)
| UI 库 | TDesign React + tdesign-icons-react | UI 组件与图标 |
| 数据层 | TanStack Query (React Query) + React Query Devtools | 服务端状态管理与自动轮询 |
| 图表 | Recharts | 拨测趋势折线图 |
| 动画 | @number-flow/react | 倒计时数字滚动过渡 |
| 路由 | 无(单页面 Dashboard | 仅需 Drawer/Tab 做页面内导航 |
**不引入的依赖**React Router单页面场景不需要、状态管理库TanStack Query 即服务端状态层,组件内用 `useState` 足够、Vite已由 Bun 原生 fullstack 替代)
@@ -554,6 +560,7 @@ main.tsx
└── ErrorBoundaryReact 错误边界)
└── QueryClientProviderTanStack Query 全局挂载)
├── App根组件Layout + HeadMenu 骨架)
│ ├── useThemePreference() ─── Header 主题模式 RadioGroup系统/明亮/黑暗,本地存储记忆 + theme-mode 应用)
│ ├── useDashboard(refreshInterval) ─── GET /api/dashboard?window=24h&recentLimit=30动态刷新间隔RadioGroup 频率选择 + 倒计时/手动刷新按钮)
│ ├── SummaryCards单 Card 内嵌居中 Statistic无 shadow
│ └── TargetBoard目标列表Space 24px 间距)
@@ -561,7 +568,7 @@ main.tsx
│ ├── useMeta() ───── GET /api/meta应用生命周期内缓存
│ └── TargetGroup[]Card 包裹 PrimaryTableheaderBordered
│ └── PrimaryTable ← createTargetTableColumns(checkerTypes)
│ └── TargetDetailDrawer目标详情抽屉width=52%TDesign 生命周期控制)
│ └── TargetDetailDrawer目标详情抽屉响应式默认宽度、支持鼠标拖拽调整TDesign 生命周期控制)
│ └── useTargetDetail() ── 按需发起 metrics 查询history 延迟到记录 Tab 激活后请求
│ ├── activeTab 受控 Tabs 状态,每次打开重置为 overview
│ ├── OverviewTab → Descriptions直接展示+ 4×2 统计卡片 + TrendChart
@@ -569,7 +576,7 @@ main.tsx
└── ReactQueryDevtools开发工具仅开发环境
```
**数据层架构**
**Hook 架构**
```
hooks/use-queries.ts全局面板级查询
@@ -583,6 +590,12 @@ hooks/use-target-detail.tsDrawer 状态与详情级条件查询)
├── activeTab 受控 Tabs 状态(每次 openDrawer 重置为 overview
├── useTargetMetrics(/api/targets/:id/metrics)条件查询enabled 仅当 Drawer 打开且时间范围有效)
└── useQuery(/api/targets/:id/history)条件查询enabled 仅当 Drawer 打开 + 时间范围有效 + activeTab=history
hooks/use-theme-preference.ts浏览器 UI 偏好)
├── ThemePreference: system / light / darkRadioGroup 受控值)
├── EffectiveTheme: light / dark写入 document.documentElement theme-mode
├── localStorage key: dial.theme.preference同一浏览器记忆
└── matchMedia("(prefers-color-scheme: dark)")(系统模式下跟随系统明暗变化)
```
### 2.3 TanStack Query 数据层
@@ -681,19 +694,20 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps)
#### 现有组件清单
| 组件 | 文件 | 用途 |
| -------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `App` | `app.tsx` | 根组件Layout + HeadMenu 骨架、刷新倒计时、Skeleton 加载 |
| `ErrorBoundary` | `components/ErrorBoundary.tsx` | React 错误边界,捕获渲染异常并展示降级 UI |
| `SummaryCards` | `components/SummaryCards.tsx` | 总览统计卡片(单 Card 内嵌居中 Statistic无 shadow |
| `TargetBoard` | `components/TargetBoard.tsx` | 按分组渲染目标表格列表Space 24px 间距) |
| `TargetGroup` | `components/TargetGroup.tsx` | 单个分组 Cardtitle+actions+headerBordered+ PrimaryTable |
| `TargetDetailDrawer` | `components/TargetDetailDrawer.tsx` | 目标详情抽屉(width=52%、TDesign 生命周期控制、preventScrollThrough、受控 Tabs、记录 TabPanel 懒渲染) |
| `OverviewTab` | `components/OverviewTab.tsx` | 目标详情概览Descriptions 直接展示 + 4×2 统计卡片 + 趋势) |
| `HistoryTab` | `components/HistoryTab.tsx` | 目标历史记录表格和分页 |
| `TrendChart` | `components/TrendChart.tsx` | Recharts 趋势折线图(耗时+延迟范围) |
| `StatusDot` | `components/StatusDot.tsx` | 圆形状态指示点(绿/红) |
| `StatusBar` | `components/StatusBar.tsx` | 最近采样状态条(多色块 + Tooltip 提示时间和状态) |
| 组件 | 文件 | 用途 |
| -------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `App` | `app.tsx` | 根组件Layout + HeadMenu 骨架、主题模式选择、刷新倒计时、Skeleton 加载 |
| `ErrorBoundary` | `components/ErrorBoundary.tsx` | React 错误边界,捕获渲染异常并展示降级 UI |
| `SummaryCards` | `components/SummaryCards.tsx` | 总览统计卡片(单 Card 内嵌居中 Statistic无 shadow |
| `TargetBoard` | `components/TargetBoard.tsx` | 按分组渲染目标表格列表Space 24px 间距) |
| `TargetGroup` | `components/TargetGroup.tsx` | 单个分组 Cardtitle+actions+headerBordered+ PrimaryTable |
| `TargetDetailDrawer` | `components/TargetDetailDrawer.tsx` | 目标详情抽屉(响应式默认宽度、支持鼠标拖拽调整、TDesign 生命周期控制、preventScrollThrough、受控 Tabs、记录 TabPanel 懒渲染) |
| `OverviewTab` | `components/OverviewTab.tsx` | 目标详情概览Descriptions 直接展示 + 4×2 统计卡片 + 趋势) |
| `HistoryTab` | `components/HistoryTab.tsx` | 目标历史记录表格和分页 |
| `TrendChart` | `components/TrendChart.tsx` | Recharts 趋势折线图(耗时+延迟范围) |
| `StatusDot` | `components/StatusDot.tsx` | 圆形状态指示点(绿/红) |
| `StatusBar` | `components/StatusBar.tsx` | 最近采样状态条(多色块 + Tooltip 提示时间和状态) |
| `RefreshCountdown` | `components/RefreshCountdown.tsx` | Header 刷新倒计时NumberFlow 数字滚动),手动刷新按钮,刷新中/等待首次刷新文本 |
### 2.5 新增功能开发步骤
@@ -728,7 +742,7 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps)
**styles.css 组织**
- 自定义 CSS 变量(如可用率渐变色 `--avail-0` ~ `--avail-9`)定义在 `:root`
- 布局类(`.dashboard``.dashboard-header`)定义全局页面结构
- 布局类(`.dashboard``.dashboard-header-controls`)定义全局页面结构和 Header 右侧单行操作区
- 组件修饰类(`.status-dot--up``.latency-ok`)为自定义视觉组件提供样式变体
- TDesign 表格行高亮(`.row-down`)通过 `rowClassName` prop 应用

290
README.md
View File

@@ -1,29 +1,61 @@
# DiAL
基于 Bun + TypeScript 的多类型拨测监控工具。通过 YAML 配置文件定义 HTTP 和命令行拨测目标,后端按配置定时并发拨测,结果持久化到本地 SQLite前端 Dashboard 展示各目标实时状态、可用率、耗时趋势等并支持手动、10 秒、30 秒、1 分钟、5 分钟刷新频率切换。
<p align="center">
<strong>轻量级多类型拨测监控工具</strong>
</p>
<p align="center">
基于 Bun + TypeScript 构建 · YAML 配置驱动 · 内置 Dashboard
</p>
---
DiAL 是一个自托管的拨测监控工具,支持 **HTTP**、**命令行** 和 **数据库** 三种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
**功能亮点:**
- 多种拨测类型HTTPGET/POST/PUT 等、Cmd命令行执行、DBPostgreSQL/MySQL/SQLite
- 丰富的校验规则状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等
- 响应式 Dashboard实时状态、可用率统计、耗时趋势图、手动/自动刷新
- 多主题支持:系统、明亮、黑暗三种主题模式
- 零外部依赖:数据存储使用 SQLite无需额外数据库服务
## 快速开始
**前置条件:** [Bun](https://bun.sh/) >= 1.0
```bash
# 克隆仓库
git clone https://github.com/your-org/DiAL.git
cd DiAL
# 安装依赖
bun install
# 复制示例配置并按需修改
cp probes.example.yaml probes.yaml
# 启动开发服务器
bun run dev probes.yaml
```
`bun run dev` 启动双进程开发服务器Vite :5173 + Bun API :3000访问 `http://127.0.0.1:5173`
`bun run dev` 会同时启动 Vite 开发服务器(`http://127.0.0.1:5173`)和 API 服务器(`http://127.0.0.1:3000`),访问前端地址即可使用 Dashboard
## 开发验证
## 生产部署
```bash
bun run check # schema:check + typecheck + lint + bun test
bun run verify # check + build
# 构建
bun run build
# 运行
./dist/dial-server ./probes.yaml
```
`verify` 会基于当前源码重新构建生产 executable。原 smoke test 已移除executable/E2E 验证后续单独补充
构建产物为独立可执行文件,只需一个 YAML 配置文件即可运行
## 配置文件
程序通过 YAML 配置文件定义所有运行参数
程序通过 YAML 配置文件定义所有运行参数,完整示例参见 [`probes.example.yaml`](probes.example.yaml)。
```yaml
# yaml-language-server: $schema=./probe-config.schema.json
@@ -37,17 +69,23 @@ runtime:
maxConcurrentChecks: 20
retention: "7d"
variables:
env_name: "生产"
base_url: "https://api.example.com"
api_token: "Bearer demo-token"
sqlite_url: "sqlite:///path/to/db.sqlite"
defaults:
interval: "5s"
interval: "30s"
timeout: "10s"
http:
method: GET
maxBodyBytes: "10MB"
cmd:
maxOutputBytes: "1MB"
targets:
- name: "Baidu"
- id: "baidu-home"
name: "Baidu"
type: http
http:
url: "https://www.baidu.com"
@@ -55,34 +93,25 @@ targets:
status: [200]
maxDurationMs: 10000
- name: "JSON API 示例"
- id: "json-api"
name: "${env_name} JSON API 示例"
type: http
http:
url: "https://httpbin.org/json"
url: "${base_url}/json"
headers:
Authorization: "${api_token|Bearer fallback-token}"
expect:
status: [200]
headers:
Content-Type:
contains: "application/json"
body:
- contains: "slideshow"
- json:
path: "$.slideshow.title"
equals: "Sample Slide Show"
- name: "HTML 页面示例"
type: http
http:
url: "https://httpbin.org/html"
expect:
status: [200]
body:
- contains: "Moby-Dick"
- xpath:
path: "/html/body/h1/text()"
equals: "Herman Melville - Moby-Dick"
- name: "Bun 脚本检查"
- id: "bun-script"
name: "Bun 脚本检查"
type: cmd
cmd:
exec: "bun"
@@ -91,144 +120,147 @@ targets:
exitCode: [0]
stdout:
- contains: "ok"
- id: "sqlite-active-users"
name: "SQLite 数据库检查"
type: db
db:
url: "${sqlite_url}"
query: "SELECT COUNT(*) as cnt FROM users WHERE status = 'active'"
expect:
maxDurationMs: 5000
rowCount: { gte: 1 }
rows:
- cnt: { gte: 0 }
```
### 配置说明
- **server**: 服务配置(均可省略,使用默认值)
- `host`: 监听地址,默认 `127.0.0.1`
- `port`: 监听端口,默认 `3000`
- `dataDir`: 数据目录,默认 `./data`,相对路径基于配置文件所在目录解析
- **runtime**: 运行时配置
- `maxConcurrentChecks`: 最大并发拨测数,默认 `20`
- `retention`: 历史数据保留时长,默认 `7d`,支持 `ms`/`s`/`m`/`h`/`d` 单位
- **defaults**: 全局默认值(均可省略)
- `interval`: 拨测间隔,默认 `30s`
- `timeout`: 超时时间,默认 `10s`
- `http`: HTTP 类型默认值
- `method`: HTTP 方法,默认 `GET`,必须使用大写枚举值,支持 `GET``HEAD``POST``PUT``PATCH``DELETE``OPTIONS`
- `maxBodyBytes`: 响应体最大字节数,默认 `100MB`
- `headers`: 默认请求头target 中的 headers 会合并覆盖 defaults 中的同名头)
- `cmd`: Cmd 类型默认值
- `maxOutputBytes`: 输出最大字节数,默认 `100MB`
- `cwd`: 默认工作目录(相对于配置文件所在目录解析,默认 `.`
- **targets**: 拨测目标列表(必填)
- `name`: 目标名称(必填,唯一)
- `type`: 目标类型,`http``cmd`(必填)
- `group`: 分组名称(可选,默认 `"default"`
- `http`: HTTP 拨测配置type 为 http 时必填)
- `url`: 目标 URL
- `method``headers``body`: 请求参数(`headers` 会与 `defaults.http.headers` 合并target 优先)
- `ignoreSSL`: 是否忽略 HTTPS 证书校验,默认 `false`,用于自签名或私有证书服务
- `maxRedirects`: 最大重定向跟随次数,默认 `0`(不跟随重定向)
- `cmd`: 命令行拨测配置type 为 cmd 时必填)
- `exec`: 可执行文件名或路径
- `args`: 命令行参数列表
- `env`: 环境变量覆盖(可选,继承进程环境变量并合并覆盖)
- `cwd`: 工作目录(可选,相对于配置文件所在目录解析,默认 `.`
- `interval``timeout`: 覆盖全局默认值
- `expect`: 期望校验
- `status`: 可接受的状态码列表HTTP支持精确状态码和范围模式`"2xx"`)混合配置;未指定时默认 `[200]`
- `exitCode`: 可接受的退出码列表Cmd未指定时不校验退出码
- `headers`: 响应头校验HTTP支持字符串精确匹配或操作符对象
- `maxDurationMs`: 最大耗时阈值(毫秒)
- HTTP覆盖完整执行含重定向、响应体读取和 expect 校验)
- Cmd覆盖命令执行耗时含 stdout/stderr 读取)
- `body`: HTTP 响应体校验(数组,可组合使用)
- `contains`: 响应体包含的文本
- `regex`: 响应体匹配的正则表达式(启动期会拒绝嵌套量词等存在 ReDoS 风险的模式)
- `json`: JSONPath 提取值比较
- `path`: JSONPath 表达式(必填,如 `$.slideshow.title`
- 比较操作符(可选,无操作符时仅检查路径对应值是否存在)
- `css`: CSS 选择器提取 HTML 元素比较
- `selector`: CSS 选择器(必填)
- `attr`: 提取元素属性值而非文本内容(可选,如 `href``class`
- 比较操作符(可选,无操作符时仅检查元素是否存在)
- `xpath`: XPath 提取 XML/HTML 节点比较
- `path`: XPath 表达式(必填,如 `/html/body/h1/text()`
- 比较操作符(可选,无操作符时仅检查节点是否存在)
- `stdout` / `stderr`: Cmd 输出校验(数组,每项为一个操作符对象)
- 比较操作符:`equals`(默认)、`contains``match`(正则,启动期会拒绝存在 ReDoS 风险的模式)、`empty``exists``gte``lte``gt``lt`
#### server 服务配置(均可省略,使用默认值)
大小说明:`maxBodyBytes``maxOutputBytes` 支持单位 `KB``MB``GB`,也可直接使用数字(非负安全整数字节数)。
| 字段 | 说明 | 默认值 |
| --------- | ------------------------------------------ | ----------- |
| `host` | 监听地址 | `127.0.0.1` |
| `port` | 监听端口 | `3000` |
| `dataDir` | 数据目录,相对路径基于配置文件所在目录解析 | `./data` |
配置校验:系统启动时会先用 TypeBox 生成的 JSON Schema 契约校验字段类型、必填字段、枚举、数组/对象形状和未知字段,再执行语义 validator 校验 target name 唯一性、URL、正则、JSONPath、XPath、size/duration 解析等规则。非法配置会阻止启动并输出中文错误信息。
#### runtime — 运行时配置
未知字段:除 `http.headers``defaults.http.headers``expect.headers``cmd.env` 等动态键值表外,未知字段会导致启动失败。配置备注请使用 YAML 注释,不要添加 `note``comment` 等未声明字段。
| 字段 | 说明 | 默认值 |
| --------------------- | ------------------------------------------------ | ------ |
| `maxConcurrentChecks` | 最大并发拨测数 | `20` |
| `retention` | 历史数据保留时长,支持 `ms`/`s`/`m`/`h`/`d` 单位 | `7d` |
JSON Schema仓库根目录导出 `probe-config.schema.json`,可在 YAML 文件顶部添加 `# yaml-language-server: $schema=./probe-config.schema.json` 获取编辑器提示和静态校验。该 schema 由运行期契约 fragments 生成,提交前可用 `bun run schema:check` 检查同步。
#### defaults — 全局默认值(均可省略)
时长格式支持:`500ms``30s``5m``2h``7d`
| 字段 | 说明 | 默认值 |
| -------------------- | -------------------------------------------------- | ------- |
| `interval` | 拨测间隔 | `30s` |
| `timeout` | 超时时间 | `10s` |
| `http.maxBodyBytes` | 响应体最大字节数 | `100MB` |
| `http.headers` | 默认请求头target 中的 headers 会合并覆盖同名头) | — |
| `cmd.maxOutputBytes` | 输出最大字节数 | `100MB` |
| `cmd.cwd` | 默认工作目录(相对于配置文件所在目录) | `.` |
## API 端点
#### variables — 配置变量(可省略)
| 端点 | 说明 |
| ----------------------------------------------------------------- | ------------------------------------------------------------ |
| `GET /health` | 健康检查 |
| `GET /api/meta` | 运行时元信息checker 类型列表) |
| `GET /api/dashboard?window=24h&recentLimit=30` | Dashboard 首屏聚合数据summary + targets |
| `GET /api/targets/:id/metrics?from=ISO&to=ISO&bucket=1h` | 指定目标的统计、可靠性指标和按小时趋势 |
| `GET /api/targets/:id/history?from=ISO&to=ISO&page=1&pageSize=20` | 指定目标的拨测记录(时间范围 + 分页,`pageSize` 最大 `200` |
`variables` 是顶层动态键值表key 必须符合 `[a-zA-Z_][a-zA-Z0-9_]*`value 仅支持 string、number、boolean。target 中的字符串值可引用变量:
### 响应字段
- `${key}`:引用 variables 或环境变量
- `${key|default}`:变量和环境变量都不存在时使用默认值,第一个 `|` 后的内容为默认值
- `$${key}`:转义输出字面量 `${key}`
**DashboardResponse**: `summary``targets`
解析优先级为 `variables -> process.env -> 默认值`。字段值完整等于单个变量引用时会保留 number/boolean/string 类型;部分拼接时统一转为字符串。变量替换仅作用于 `targets`,且不会替换 `id``type` 字段。
**DashboardResponse.summary**: `total``up``down``lastCheckTime``incidents``window`
#### targets — 拨测目标列表(必填)
**MetaResponse**: `checkerTypes`(已注册 checker 类型标识符列表)
每个 target 的通用字段:
**TargetStatus**: `id``name``type`checker 类型,如 http/cmd`target`URL 或命令摘要)、`group``interval``latestCheck``stats``currentStreak``recentSamples`
| 字段 | 说明 | 必填 |
| ---------- | ---------------------------------------------------------- | -------------------- |
| `id` | 目标唯一标识,支持字母数字、下划线、连字符,不参与变量替换 | 是 |
| `name` | 展示名称,支持变量替换;省略时使用 `id` | 否 |
| `type` | 目标类型:`http``cmd``db` | 是 |
| `group` | 分组名称 | 否,默认 `"default"` |
| `interval` | 覆盖全局拨测间隔 | 否 |
| `timeout` | 覆盖全局超时时间 | 否 |
**RecentSample**: `timestamp``durationMs``up`
**HTTP 类型** (`type: http`)
**CheckResult**: `timestamp``matched``durationMs``statusDetail``failure`
| 字段 | 说明 |
| ------------------- | --------------------------------------- |
| `http.url` | 目标 URL |
| `http.method` | HTTP 方法(覆盖 defaults |
| `http.headers` | 请求头(与 defaults.http.headers 合并) |
| `http.body` | 请求体 |
| `http.ignoreSSL` | 忽略 HTTPS 证书校验,默认 `false` |
| `http.maxRedirects` | 最大重定向跟随次数,默认 `0`(不跟随) |
**CheckFailure**: `kind`error/mismatch`phase``path``message``expected?`(仅 mismatch`actual?`(仅 mismatch
**Cmd 类型** (`type: cmd`)
**TargetStats**: `totalChecks``upChecks``downChecks``availability`
| 字段 | 说明 |
| ---------- | -------------------------------------- |
| `cmd.exec` | 可执行文件名或路径 |
| `cmd.args` | 命令行参数列表 |
| `cmd.env` | 环境变量覆盖(继承进程环境变量并合并) |
| `cmd.cwd` | 工作目录(相对于配置文件所在目录) |
**CurrentStreak**: `up``count``capped?`
**DB 类型** (`type: db`)
**TargetMetricsResponse**: `targetId``window``stats``trend`
| 字段 | 说明 |
| ---------- | ------------------------------------------------------------- |
| `db.url` | 数据库连接字符串,支持 `postgres://``mysql://``sqlite://` |
| `db.query` | SQL 查询语句(不配置时仅测试连接) |
**TargetMetricsResponse.stats**: `totalChecks``upChecks``downChecks``availability``avgDurationMs``p95DurationMs``p99DurationMs``mttr``longestOutage``incidentCount``currentStreak`
#### expect — 期望校验
**TrendPoint**: `bucketStart``avgDurationMs``minDurationMs``maxDurationMs``availability``totalChecks``upChecks``downChecks`
| 字段 | 适用类型 | 说明 |
| ------------------- | -------- | ---------------------------------------------------------------- |
| `status` | HTTP | 可接受的状态码列表,支持精确码和范围(如 `"2xx"`);默认 `[200]` |
| `exitCode` | Cmd | 可接受的退出码列表;未指定时不校验 |
| `headers` | HTTP | 响应头校验 |
| `maxDurationMs` | 全部 | 最大耗时阈值(毫秒) |
| `body` | HTTP | 响应体校验(数组,可组合使用,见下方) |
| `stdout` / `stderr` | Cmd | 输出校验(数组,每项一个操作符对象) |
| `rowCount` | DB | 查询返回行数校验(操作符对象) |
| `rows` | DB | 查询结果逐行校验(数组,列名→操作符映射) |
**HistoryResponse**: `items`CheckResult[])、`total``page``pageSize`
**body 校验项**(数组中可混合使用):
### 错误响应
- `contains` — 响应体包含指定文本
- `regex` — 正则匹配(启动期会拒绝存在 ReDoS 风险的模式)
- `json` — JSONPath 提取值比较(`path` 必填,如 `$.slideshow.title`
- `css` — CSS 选择器提取 HTML 元素(`selector` 必填,`attr` 可选提取属性)
- `xpath` — XPath 提取 XML/HTML 节点(`path` 必填,如 `/html/body/h1/text()`
API 错误返回 `ApiErrorResponse` 格式:
**比较操作符**`equals`(默认)、`contains``match`(正则)、`empty``exists``gte``lte``gt``lt`
```json
{ "error": "描述信息", "status": 400 }
```
**大小说明**`maxBodyBytes``maxOutputBytes` 支持 `KB``MB``GB` 单位,也可直接使用数字。
| 状态码 | 触发场景 |
| ------ | ------------------------------------------------------------------------------------------ |
| 400 | 参数格式错误(无效 ID、from/to 缺失或格式错误、page/pageSize 非正整数、pageSize 超过 200 |
| 404 | 目标不存在、API 路由未匹配、非 GET 方法请求 API 路由 |
**时长格式**`500ms``30s``5m``2h``7d`
## 运行参数
**JSON Schema**:仓库根目录导出 `probe-config.schema.json`,在 YAML 文件顶部添加 `# yaml-language-server: $schema=./probe-config.schema.json` 即可在编辑器中获得提示和校验。
CLI 只接受一个参数YAML 配置文件路径
```bash
./dist/dial-server ./probes.yaml
```
> **注意:** 配置校验在启动时执行,非法配置会阻止启动并输出错误信息。除动态键值表(`headers`、`env`、`variables`)外,未知字段会导致启动失败,请使用 YAML 注释
## 目标状态判定
单层判定模型,适用于 HTTP 和 Cmd 两种类型
采用单层判定模型:
- **matched**: 是否符合 expect 规则HTTP 未指定 `expect.status` 时默认检查 `[200]`
- **UP** = matched
- **DOWN** = NOT matched
- **UP** = 拨测结果符合 expect 规则
- **DOWN** = 拨测结果不符合 expect 规则
执行失败(网络错误、超时、进程崩溃)和 expect 不匹配都统一为 `matched=false`,通过 `failure.kind` 区分(`"error"` vs `"mismatch"`)。
执行失败(网络错误、超时、进程崩溃)和 expect 不匹配都统一为 DOWN,通过 `failure.kind` 区分原因`"error"` vs `"mismatch"`)。
---
## 开发
> 开发相关文档(项目结构、构建、测试、代码规范等)请参阅 [DEVELOPMENT.md](DEVELOPMENT.md)。
```bash
bun run check # schema:check + typecheck + lint + test
bun run verify # check + build
```
开发相关文档(项目结构、构建、测试、代码规范等)请参阅 [DEVELOPMENT.md](DEVELOPMENT.md)。
## License
MIT

View File

@@ -5,6 +5,7 @@
"": {
"name": "gateway-checker",
"dependencies": {
"@number-flow/react": "^0.6.0",
"@sinclair/typebox": "^0.34.49",
"@tanstack/react-query": "^5.100.10",
"@xmldom/xmldom": "^0.9.10",
@@ -186,6 +187,8 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@number-flow/react": ["@number-flow/react@0.6.0", "https://registry.npmmirror.com/@number-flow/react/-/react-0.6.0.tgz", { "dependencies": { "esm-env": "^1.1.4", "number-flow": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }, "sha512-77Yfc9+zkV2UDSP8phhZzxJGuwxi/Tt1TikmipL+1r3e9GFKEYDZ1XwInj67NoSt3OnOB0KLvvcl3lfPZgBHVQ=="],
"@oxc-project/types": ["@oxc-project/types@0.130.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.130.0.tgz", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="],
"@pkgr/core": ["@pkgr/core@0.2.9", "https://registry.npmmirror.com/@pkgr/core/-/core-0.2.9.tgz", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="],
@@ -584,6 +587,8 @@
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"esm-env": ["esm-env@1.2.2", "https://registry.npmmirror.com/esm-env/-/esm-env-1.2.2.tgz", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"espree": ["espree@11.2.0", "https://registry.npmmirror.com/espree/-/espree-11.2.0.tgz", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
"esquery": ["esquery@1.7.0", "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
@@ -852,6 +857,8 @@
"nth-check": ["nth-check@2.1.1", "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"number-flow": ["number-flow@0.6.0", "https://registry.npmmirror.com/number-flow/-/number-flow-0.6.0.tgz", { "dependencies": { "esm-env": "^1.1.4" } }, "sha512-K8flNq2Wqus53vjp/btVo3qXFkagF8dIdYavreBfE7hlvFFG/b1HMGEH6nZL+mlrJ+4lbLP9OmPv3t2rmRkpSQ=="],
"object-assign": ["object-assign@4.1.1", "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],

View File

@@ -0,0 +1,170 @@
## Purpose
定义配置文件的变量定义、引用、解析和替换机制,支持集中管理共享值和环境变量注入。
## Requirements
### Requirement: variables 段定义
配置文件 SHALL 支持可选的顶层 `variables`用于定义变量键值对。variables 的 key SHALL 符合 `[a-zA-Z_][a-zA-Z0-9_]*` 命名规则。variables 的 value SHALL 仅支持 string、number、boolean 三种类型MUST NOT 支持 null、array、object。variables 段自身 MUST NOT 支持引用其他变量或环境变量(值为纯字面量)。
#### Scenario: 定义字符串变量
- **WHEN** 配置文件包含 `variables: { base_url: "https://api.example.com" }`
- **THEN** 系统 SHALL 解析 base_url 为字符串类型变量,值为 "https://api.example.com"
#### Scenario: 定义数字变量
- **WHEN** 配置文件包含 `variables: { port: 5432 }`
- **THEN** 系统 SHALL 解析 port 为 number 类型变量,值为 5432
#### Scenario: 定义布尔变量
- **WHEN** 配置文件包含 `variables: { ssl_enabled: true }`
- **THEN** 系统 SHALL 解析 ssl_enabled 为 boolean 类型变量,值为 true
#### Scenario: 变量值为 null 报错
- **WHEN** 配置文件包含 `variables: { empty: null }`
- **THEN** 系统 SHALL 以配置错误退出,提示 variables 的值不允许为 null
#### Scenario: 变量值为数组报错
- **WHEN** 配置文件包含 `variables: { list: [1, 2, 3] }`
- **THEN** 系统 SHALL 以配置错误退出,提示 variables 的值不允许为 array
#### Scenario: 变量值为对象报错
- **WHEN** 配置文件包含 `variables: { obj: { a: 1 } }`
- **THEN** 系统 SHALL 以配置错误退出,提示 variables 的值不允许为 object
#### Scenario: 变量 key 不合法报错
- **WHEN** 配置文件包含 `variables: { "123start": "value" }`
- **THEN** 系统 SHALL 以配置错误退出,提示变量名不符合命名规则
#### Scenario: 不定义 variables 段
- **WHEN** 配置文件不包含 variables 段
- **THEN** 系统 SHALL 正常启动targets 中的 `${...}` 引用仅从环境变量查找
### Requirement: 变量引用语法
targets 中的字符串值 SHALL 支持 `${key}` 语法引用变量。系统 SHALL 支持 `${key|default}` 语法设置默认值,其中第一个 `|` 为分隔符,后续 `|` 属于默认值内容。系统 SHALL 支持 `$${...}` 转义语法输出字面量 `${...}`
#### Scenario: 简单变量引用
- **WHEN** target 字段值为 `"${base_url}/health"` 且 variables 中定义 `base_url: "https://api.example.com"`
- **THEN** 系统 SHALL 将该字段替换为 `"https://api.example.com/health"`
#### Scenario: 带默认值的变量引用
- **WHEN** target 字段值为 `"${DB_PORT|5432}"` 且 variables 和环境变量中均不存在 DB_PORT
- **THEN** 系统 SHALL 将该字段替换为使用默认值(类型推断后为 number 5432
#### Scenario: 默认值包含管道符
- **WHEN** target 字段值为 `"${PATTERN|foo|bar}"` 且变量不存在
- **THEN** 系统 SHALL 使用 `"foo|bar"` 作为默认值(第一个 `|` 为分隔符)
#### Scenario: 转义语法
- **WHEN** target 字段值为 `"Hello $${name}"`
- **THEN** 系统 SHALL 输出 `"Hello ${name}"`,不进行变量替换
#### Scenario: 多个变量引用
- **WHEN** target 字段值为 `"${protocol}://${host}:${port}/api"`
- **THEN** 系统 SHALL 逐个解析并替换所有变量引用,结果为拼接后的字符串
#### Scenario: 无变量引用的字符串
- **WHEN** target 字段值为 `"https://example.com"` 且不含 `${...}` 模式
- **THEN** 系统 SHALL 保持原样,不做任何处理
### Requirement: 变量解析优先级
系统 SHALL 按以下优先级解析变量引用variables 定义 → 环境变量 → 默认值。如果三者均不存在,系统 SHALL 以配置错误退出。
#### Scenario: variables 优先于环境变量
- **WHEN** variables 中定义 `port: 5432` 且环境变量 `port=3000` 也存在
- **THEN** 系统 SHALL 使用 variables 中的值 5432
#### Scenario: 环境变量作为 fallback
- **WHEN** variables 中未定义 `DB_HOST` 但环境变量 `DB_HOST=localhost` 存在
- **THEN** 系统 SHALL 使用环境变量的值 "localhost"
#### Scenario: 默认值作为最终 fallback
- **WHEN** variables 和环境变量中均不存在 `CACHE_TTL`,且引用为 `${CACHE_TTL|60}`
- **THEN** 系统 SHALL 使用默认值(类型推断后为 number 60
#### Scenario: 变量未定义且无默认值报错
- **WHEN** target 字段引用 `${MISSING_VAR}` 且 variables、环境变量中均不存在也未设置默认值
- **THEN** 系统 SHALL 以配置错误退出,提示该变量未定义
### Requirement: 完整引用类型保留
当字段值仅包含单个变量引用(完整引用)时,系统 SHALL 保留变量的原始类型。完整引用的判定为:字段值去掉首尾空白后严格匹配单个 `${key}``${key|default}` 模式且无其他字符。
#### Scenario: 完整引用 number 变量
- **WHEN** target 字段值为 `"${port}"` 且 variables 中 `port: 5432`
- **THEN** 系统 SHALL 将该字段替换为 number 类型的 5432
#### Scenario: 完整引用 boolean 变量
- **WHEN** target 字段值为 `"${ssl}"` 且 variables 中 `ssl: true`
- **THEN** 系统 SHALL 将该字段替换为 boolean 类型的 true
#### Scenario: 完整引用 string 变量
- **WHEN** target 字段值为 `"${host}"` 且 variables 中 `host: "example.com"`
- **THEN** 系统 SHALL 将该字段替换为 string 类型的 "example.com"
#### Scenario: 部分引用强制为字符串
- **WHEN** target 字段值为 `"port: ${port}"` 且 variables 中 `port: 5432`
- **THEN** 系统 SHALL 将该字段替换为 string 类型的 "port: 5432"
#### Scenario: 环境变量完整引用类型推断
- **WHEN** target 字段值为 `"${MAX_REDIRECTS}"` 且环境变量 `MAX_REDIRECTS=5`
- **THEN** 系统 SHALL 将该字段替换为 number 类型的 5类型推断
#### Scenario: 默认值完整引用类型推断
- **WHEN** target 字段值为 `"${TIMEOUT|30}"` 且变量不存在
- **THEN** 系统 SHALL 将该字段替换为 number 类型的 30类型推断
#### Scenario: 默认值推断为 boolean
- **WHEN** target 字段值为 `"${IGNORE_SSL|false}"` 且变量不存在
- **THEN** 系统 SHALL 将该字段替换为 boolean 类型的 false
#### Scenario: 默认值无法推断保持字符串
- **WHEN** target 字段值为 `"${HOST|localhost}"` 且变量不存在
- **THEN** 系统 SHALL 将该字段替换为 string 类型的 "localhost"
### Requirement: 替换范围限制
变量替换 SHALL 仅作用于 targets 段。`id``type` 字段 MUST NOT 参与变量替换。`server``runtime``defaults` 段 MUST NOT 参与变量替换。系统 SHALL 递归遍历 target 对象树中所有字符串值进行替换(包括嵌套对象和数组元素中的字符串)。
#### Scenario: target 嵌套对象中的变量替换
- **WHEN** target 配置 `http.headers.Authorization: "${token}"` 且 variables 中定义 `token: "Bearer abc"`
- **THEN** 系统 SHALL 将该 header 值替换为 "Bearer abc"
#### Scenario: target 数组元素中的变量替换
- **WHEN** target 配置 `cmd.args: ["--host", "${host}"]` 且 variables 中定义 `host: "localhost"`
- **THEN** 系统 SHALL 将数组第二个元素替换为 "localhost"
#### Scenario: id 字段不替换
- **WHEN** target 配置 `id: "${my_id}"` 且 variables 中定义 `my_id: "test"`
- **THEN** 系统 SHALL 保持 id 字段值为字面量 `"${my_id}"`,不进行替换
#### Scenario: type 字段不替换
- **WHEN** target 配置 `type: "${checker_type}"` 且 variables 中定义 `checker_type: "http"`
- **THEN** 系统 SHALL 保持 type 字段值为字面量 `"${checker_type}"`,不进行替换
#### Scenario: defaults 段不替换
- **WHEN** defaults 配置 `interval: "${default_interval}"` 且 variables 中定义 `default_interval: "30s"`
- **THEN** 系统 SHALL 保持 defaults.interval 为字面量 `"${default_interval}"`,不进行替换
#### Scenario: server 段不替换
- **WHEN** server 配置 `host: "${server_host}"` 且 variables 中定义 `server_host: "0.0.0.0"`
- **THEN** 系统 SHALL 保持 server.host 为字面量 `"${server_host}"`,不进行替换
### Requirement: 变量替换错误报告
变量替换阶段的错误 SHALL 作为 `ConfigValidationIssue` 输出code 为 `unresolved-variable`。错误信息 SHALL 包含 target 索引、target id、字段路径和变量名。
#### Scenario: 单个变量缺失报错
- **WHEN** targets[0] (id: "api-health") 的 http.url 引用 `${base_url}` 但变量不存在
- **THEN** 系统 SHALL 输出包含 target 索引 0、id "api-health"、路径 "http.url"、变量名 "base_url" 的错误信息
#### Scenario: 多个变量缺失批量报错
- **WHEN** 多个 target 的多个字段引用了不存在的变量
- **THEN** 系统 SHALL 收集所有缺失变量错误后统一输出,而非遇到第一个就退出
### Requirement: 变量替换执行时机
变量替换 SHALL 在 YAML 解析之后、schema 契约校验AJV之前执行。替换完成后的配置对象 SHALL 传入后续校验流程。
#### Scenario: 替换后通过 schema 校验
- **WHEN** target 配置 `http.maxRedirects: "${MAX_REDIRECTS}"` 且环境变量 `MAX_REDIRECTS=5`
- **THEN** 系统 SHALL 先将该字段替换为 number 5再进入 AJV 校验(期望 integer校验通过
#### Scenario: 替换后未通过 schema 校验
- **WHEN** target 配置 `http.maxRedirects: "${MAX_REDIRECTS}"` 且环境变量 `MAX_REDIRECTS=abc`
- **THEN** 系统 SHALL 先将该字段替换为 string "abc",再进入 AJV 校验(期望 integer校验失败并报错

View File

@@ -73,9 +73,13 @@ styles.css SHALL 定义前端组件复用的工具类,包含页面布局相关
- **WHEN** HeadMenu logo 区域渲染品牌名和副标题
- **THEN** 品牌 SHALL 使用 `.dashboard-brand`display: inline-flex; align-items: baseline; gap: var(--td-comp-margin-s)),品牌名 SHALL 使用 `.dashboard-logo`font-size: calc(var(--td-font-size-title-large) + 6px); font-weight: 700副标题 SHALL 使用 `.dashboard-subtitle`font-size: var(--td-font-size-body-medium); color: var(--td-text-color-secondary)
#### Scenario: 刷新控制区域
- **WHEN** HeadMenu operations 区域渲染刷新频率选择器和倒计时/按钮
- **THEN** 容器 SHALL 使用 `.dashboard-refresh-control`display: inline-flex; align-items: center; gap: var(--td-comp-margin-s); margin-right: var(--td-comp-margin-xxl)
#### Scenario: Header 右侧操作区
- **WHEN** HeadMenu operations 区域渲染主题模式选择器、刷新频率选择器和倒计时/按钮
- **THEN** 容器 SHALL 使用 `.dashboard-header-controls`display: inline-flex; align-items: center; gap: var(--td-comp-margin-s); margin-right: var(--td-comp-margin-xxl)
#### Scenario: Header 右侧操作区单行布局
- **WHEN** Header 右侧操作区渲染
- **THEN** `.dashboard-header-controls` SHALL 保持桌面单行水平布局,不为该区域新增窄屏换行或收纳规则
#### Scenario: 倒计时文本类
- **WHEN** 倒计时文本或刷新按钮渲染
@@ -99,7 +103,26 @@ styles.css SHALL 定义前端组件复用的工具类,包含页面布局相关
#### Scenario: 概览统计卡片类
- **WHEN** Drawer 概览统计区渲染
- **THEN** 统计卡片 SHALL 使用 `.overview-stat-card`background: var(--td-bg-color-container-hover)内部项 SHALL 使用 `.overview-stat-item`display: flex; align-items: center; justify-content: space-between数值 SHALL 使用 `.overview-stat-value`font-size: var(--td-font-size-body-medium); text-align: right
- **THEN** 统计卡片 SHALL 使用 `.overview-stat-card`background: var(--td-bg-color-container-hover)并使用 TDesign Statistic 组件自带的上下布局title 在上、value 在下),通过 `.summary-stat-col`text-align: center实现内容居中。系统 SHALL NOT 使用已移除的 `.overview-stat-item``.overview-stat-value` 类。
### Requirement: NumberFlow 倒计时样式类
styles.css SHALL 定义 NumberFlow 倒计时相关样式类,供 Header 倒计时组件使用。样式 SHALL 继承 TDesign 文本颜色或使用 TDesign CSS tokens不得使用组件内联 `style`、硬编码色值、`!important` 或覆盖 TDesign 内部类名。
#### Scenario: 倒计时滚动容器类
- **WHEN** Header 自动刷新倒计时以 NumberFlow 形式渲染
- **THEN** 倒计时 SHALL 使用集中定义的滚动容器类,保持 inline-flex、baseline 对齐、nowrap 和 tabular-nums
#### Scenario: 倒计时数字类
- **WHEN** NumberFlow 数字渲染
- **THEN** 数字 SHALL 使用集中定义的数字类配置 line-height 和 NumberFlow mask CSS 变量,减少滚动边缘突兀感
#### Scenario: 倒计时单位类
- **WHEN** 分钟或秒单位文本渲染
- **THEN** 单位 SHALL 使用集中定义的单位类与数字保持基线对齐,并继承当前 TDesign 文本色
#### Scenario: 不使用内联样式
- **WHEN** RefreshCountdown 组件渲染 NumberFlow 倒计时
- **THEN** 组件 SHALL 通过 `className` 引用 styles.css 中的样式类,不得通过 React `style` prop 设置 NumberFlow 展示样式
### Requirement: 异常行背景类
styles.css SHALL 定义 DOWN 行的背景色和左侧竖线,使用安全选择器且不使用 `!important`

View File

@@ -1,6 +1,6 @@
## Purpose
定义 Dashboard 页面骨架布局:顶部导航栏(含品牌标识刷新频率选择器/倒计时控件)、内容区域居中与最大宽度、页面背景色。
定义 Dashboard 页面骨架布局:顶部导航栏(含品牌标识、主题模式选择器、刷新频率选择器倒计时控件)、内容区域居中与最大宽度、页面背景色。
## Requirements
@@ -13,13 +13,17 @@ Dashboard SHALL 使用 TDesign Layout 组件体系构建页面骨架,包含顶
#### Scenario: 顶部导航栏
- **WHEN** Dashboard 页面渲染
- **THEN** `Layout.Header` SHALL 内嵌 TDesign `HeadMenu` 组件,`logo` prop 渲染品牌名 "DiAL" 和副标题 "统一拨测平台"(水平排列),`operations` prop 渲染刷新频率选择器和倒计时/刷新按钮组合控件
- **THEN** `Layout.Header` SHALL 内嵌 TDesign `HeadMenu` 组件,`logo` prop 渲染品牌名 "DiAL" 和副标题 "统一拨测平台"(水平排列),`operations` prop 渲染主题模式选择器、刷新频率选择器和倒计时/刷新按钮组合控件
#### Scenario: 刷新控制区域
#### Scenario: Header 右侧操作区
- **WHEN** Dashboard 页面渲染
- **THEN** HeadMenu operations 区域 SHALL 包含 RadioGroup 刷新频率选择器和倒计时文本(或手动刷新按钮),者水平排列并垂直居中
- **THEN** HeadMenu operations 区域 SHALL 包含主题模式 RadioGroup刷新频率 RadioGroup 和倒计时文本(或手动刷新按钮),者水平排列并垂直居中
#### Scenario: 刷新控制区域位置
#### Scenario: 主题选择器位置
- **WHEN** HeadMenu operations 区域渲染
- **THEN** 主题模式 RadioGroup SHALL 位于刷新频率 RadioGroup 前面
#### Scenario: Header 右侧操作区位置
- **WHEN** HeadMenu 渲染
- **THEN** operations 区域 SHALL 使用右侧 margin 向内收缩,避免紧贴浏览器右边缘
@@ -29,4 +33,4 @@ Dashboard SHALL 使用 TDesign Layout 组件体系构建页面骨架,包含顶
#### Scenario: 页面背景色
- **WHEN** Dashboard 页面渲染
- **THEN** 页面背景色 SHALL 使用 `var(--td-bg-color-page)`,内容卡片浮于浅灰背景之上
- **THEN** 页面背景色 SHALL 使用 `var(--td-bg-color-page)`,内容卡片浮于当前 TDesign 主题背景之上

View File

@@ -0,0 +1,141 @@
## Purpose
定义 db 类型拨测目标的配置格式、执行逻辑、expect 断言规则和启动期校验。
## Requirements
### Requirement: db target 配置
系统 SHALL 支持 `type: db` 的 target 配置,通过 `db.url` 描述数据库连接字符串(遵循 Bun SQL 支持的格式),通过可选的 `db.query` 描述待执行的 SQL 语句。
#### Scenario: 解析仅连接的 db target
- **WHEN** YAML 中 target 配置 `type: db``db.url: "postgres://user:pass@localhost:5432/mydb"`,未配置 `db.query`
- **THEN** 系统 SHALL 将其解析为 db checker仅执行连通性检测
#### Scenario: 解析带查询的 db target
- **WHEN** YAML 中 target 配置 `type: db``db.url: "mysql://user:pass@host:3306/app"``db.query: "SELECT count(*) as cnt FROM users"`
- **THEN** 系统 SHALL 将其解析为 db checker执行连通性检测后执行指定 SQL 并进入 expect 校验
#### Scenario: db target 缺少 url
- **WHEN** YAML 中 target 配置 `type: db` 但缺少 `db.url`
- **THEN** 系统 SHALL 以配置错误退出,并提示该 target 缺少 db.url 字段
#### Scenario: db.url 为空字符串
- **WHEN** YAML 中 target 配置 `db.url: ""`
- **THEN** 系统 SHALL 以配置错误退出,并提示 db.url 不能为空
#### Scenario: db.query 为空字符串
- **WHEN** YAML 中 target 配置 `db.query: ""`
- **THEN** 系统 SHALL 以配置错误退出,并提示 db.query 不能为空字符串(如不需要查询则不配置该字段)
#### Scenario: db 分组未知字段失败
- **WHEN** YAML 中 db target 的 `db` 分组包含 `timeout: 5` 等未知字段
- **THEN** 系统 SHALL 以配置错误退出,提示 db 分组包含未知字段
#### Scenario: SQLite 连接字符串
- **WHEN** YAML 中 target 配置 `db.url: "sqlite:///data/app.db"`
- **THEN** 系统 SHALL 将其解析为 db checker使用 SQLite 文件数据库
#### Scenario: url 格式由 Bun 运行时校验
- **WHEN** YAML 中 target 配置 `db.url` 为 Bun 不支持的格式
- **THEN** 系统 SHALL 在执行阶段捕获连接错误并作为 phase="connect" 的 failure 返回,而非在启动期校验 URL 格式
### Requirement: db checker 执行
系统 SHALL 按 db target 配置连接数据库并执行查询,每次执行都新建连接并在完成后关闭。连接能力本身作为监控指标。
#### Scenario: 仅连接测试成功
- **WHEN** db target 未配置 `db.query` 且数据库连接成功
- **THEN** 系统 SHALL 内部执行 `SELECT 1` 验证连通性,记录 `matched=true``durationMs`
#### Scenario: 连接失败
- **WHEN** db target 的数据库连接失败(网络不通、认证错误、数据库不存在等)
- **THEN** 系统 SHALL 记录 `matched=false`failure 的 phase 为 `"connect"`message 包含可读错误信息
#### Scenario: 查询执行成功
- **WHEN** db target 配置了 `db.query` 且 SQL 执行成功返回结果集
- **THEN** 系统 SHALL 记录 `durationMs`(从连接开始到查询完成),并进入 expect 校验
#### Scenario: 查询执行失败
- **WHEN** db target 配置了 `db.query` 且 SQL 执行报错(语法错误、权限不足、表不存在等)
- **THEN** 系统 SHALL 记录 `matched=false`failure 的 phase 为 `"query"`message 包含数据库返回的错误信息
#### Scenario: 执行超时
- **WHEN** db target 在 timeout 时间内未完成(连接或查询)
- **THEN** 系统 SHALL 关闭连接,记录 `matched=false`failure 的 phase 为 `"connect"``"query"`取决于超时发生的阶段message 包含超时信息
#### Scenario: 每次执行新建连接
- **WHEN** db target 被引擎调度执行
- **THEN** 系统 SHALL 创建新的 SQL 连接实例max: 1执行完成后立即关闭连接close timeout: 0
#### Scenario: 使用 unsafe 执行用户 SQL
- **WHEN** db target 配置了 `db.query`
- **THEN** 系统 SHALL 使用 `sql.unsafe(query)` 执行用户配置的 SQL 文本,不限制 SQL 类型
#### Scenario: 响应 abort signal
- **WHEN** 引擎注入的 `ctx.signal` 被 abort
- **THEN** 系统 SHALL 立即关闭数据库连接
### Requirement: db expect 校验
系统 SHALL 支持 db 专用 expect包括 `maxDurationMs``rowCount``rows`,按 duration、rowCount、rows 的阶段顺序快速失败。
#### Scenario: maxDurationMs 校验
- **WHEN** db target 配置 `expect.maxDurationMs: 3000` 且实际执行耗时 4000ms
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 phase 为 `"duration"`
#### Scenario: rowCount 校验通过
- **WHEN** db target 配置 `expect.rowCount: { gte: 1 }` 且查询返回 5 行
- **THEN** 系统 SHALL 判定 rowCount 阶段通过,继续后续 expect 阶段
#### Scenario: rowCount 校验失败
- **WHEN** db target 配置 `expect.rowCount: { gte: 1 }` 且查询返回 0 行
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 phase 为 `"rowCount"`path 为 `"rowCount"`expected 为 `{ gte: 1 }`actual 为 0
#### Scenario: rows 按索引匹配列值operator 形式)
- **WHEN** db target 配置 `expect.rows: [{ cnt: { gte: 100 } }]` 且查询首行 cnt 列值为 50
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 phase 为 `"row"`path 为 `"rows[0].cnt"`
#### Scenario: rows 按索引匹配列值(字面量形式)
- **WHEN** db target 配置 `expect.rows: [{ status: "active" }]` 且查询首行 status 列值为 `"active"`
- **THEN** 系统 SHALL 判定该行该列通过(字面量等价于 `{ equals: "active" }`
#### Scenario: rows 只检查声明的列
- **WHEN** db target 配置 `expect.rows: [{ cnt: { gte: 1 } }]` 且查询首行包含 cnt、name、age 三列
- **THEN** 系统 SHALL 仅检查 cnt 列,忽略 name 和 age 列
#### Scenario: rows 结果行数不足
- **WHEN** db target 配置 `expect.rows` 包含 3 个元素但查询仅返回 2 行
- **THEN** 系统 SHALL 返回 `matched=false`failure 的 phase 为 `"row"`message 说明结果行数不足
#### Scenario: 无 query 时 expect 被忽略
- **WHEN** db target 未配置 `db.query` 但配置了 `expect.rowCount`
- **THEN** 系统 SHALL 忽略 expect 中的 rowCount 和 rows 断言(仅 maxDurationMs 生效)
#### Scenario: 快速失败顺序
- **WHEN** db target 同时配置 maxDurationMs、rowCount 和 rows
- **THEN** 系统 SHALL 按 duration → rowCount → rows 顺序校验,任一阶段失败立即返回
### Requirement: db checker 启动期配置校验
系统 SHALL 在启动期对 db checker 的配置契约和语义执行严格校验。Db target 的 `db` 分组 SHALL 只允许 `url``query` 字段。Db expect SHALL 只允许 `maxDurationMs``rowCount``rows` 字段。
#### Scenario: db expect maxDurationMs 非法
- **WHEN** YAML 中 db target 配置 `expect.maxDurationMs` 不是非负有限数字
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.maxDurationMs 格式错误
#### Scenario: db expect rowCount 非法
- **WHEN** YAML 中 db target 配置 `expect.rowCount` 不是合法的 operator 对象
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.rowCount 格式错误
#### Scenario: db expect rows 非法
- **WHEN** YAML 中 db target 配置 `expect.rows` 不是对象数组
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.rows 必须为对象数组
#### Scenario: db expect rows 元素列值非法
- **WHEN** YAML 中 db target 配置 `expect.rows: [{ cnt: { foo: 1 } }]`,其中 foo 不是合法 operator
- **THEN** 系统 SHALL 以配置错误退出,提示 rows 中包含未知 operator
#### Scenario: db expect 未知字段失败
- **WHEN** YAML 中 db target 的 expect 包含 `status: [200]` 或其他非 db expect 字段
- **THEN** 系统 SHALL 以配置错误退出,提示 expect 包含未知字段
#### Scenario: db expect rows 中 match 正则非法
- **WHEN** YAML 中 db target 配置 `expect.rows: [{ name: { match: "[invalid" } }]`
- **THEN** 系统 SHALL 在启动期配置校验失败,而不是延迟到运行期抛错

View File

@@ -1,23 +1,25 @@
## Purpose
定义 HTTP 拨测工具的 YAML 配置文件格式、解析校验规则和 CLI 启动流程。
定义拨测工具的 YAML 配置文件格式、解析校验规则和 CLI 启动流程。
## Requirements
### Requirement: YAML 配置文件格式
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、checker 默认值和 typed target 列表(含可选 group 字段。target MUST 使用 `type` 字段声明 checker 类型,HTTP 领域字段 MUST 放在 `http` 分组cmd 领域字段 MUST 放在 `cmd` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`(非负整数)字段。
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、可选的 variables 段、checker 默认值和 typed target 列表(含可选 group 字段。target MUST 使用 `id` 字段作为唯一标识符MUST 使用 `type` 字段声明 checker 类型SHALL 支持可选的 `name` 字段作为展示名称(缺省 fallback 到 idHTTP 领域字段 MUST 放在 `http` 分组cmd 领域字段 MUST 放在 `cmd` 分组db 领域字段 MUST 放在 `db` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`(非负整数)字段。Db target 的 `db` 分组 SHALL 支持 `url`(必填)和 `query`(可选)字段。
`defaults.http` 分组 SHALL 仅支持 `headers`(可选)和 `maxBodyBytes`(可选)字段。`defaults.http` 分组 MUST NOT 支持 `method` 字段。
#### Scenario: 完整配置文件解析
- **WHEN** 系统启动并读取包含 server、runtime、defaults、targets含 group 字段)的 YAML 配置文件
- **WHEN** 系统启动并读取包含 server、runtime、variables、defaults、targetsid、group 字段)的 YAML 配置文件
- **THEN** 系统 SHALL 正确解析所有字段并用于初始化服务、调度引擎和对应 checker runner
#### Scenario: 最简 HTTP 配置文件解析
- **WHEN** 系统读取只包含一个 `type: http` target 和 `http.url` 的 YAML 配置文件(省略 server、runtime、defaults 和 expect
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段host=127.0.0.1, port=3000, dir=./data, interval=30s, timeout=10s, runtime.maxConcurrentChecks=20, http.method=GET, http.maxBodyBytes=100MB, http.ignoreSSL=false, http.maxRedirects=0, group="default"
- **WHEN** 系统读取只包含一个 `type: http` target(含 `id``http.url`的 YAML 配置文件(省略 server、runtime、variables、defaults 和 expect
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段host=127.0.0.1, port=3000, dir=./data, interval=30s, timeout=10s, runtime.maxConcurrentChecks=20, http.method=GET, http.maxBodyBytes=100MB, http.ignoreSSL=false, http.maxRedirects=0, group="default", name fallback 到 id
#### Scenario: 最简 cmd 配置文件解析
- **WHEN** 系统读取只包含一个 `type: cmd` target 和 `cmd.exec` 的 YAML 配置文件
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, cmd.cwd 为配置文件所在目录, cmd.maxOutputBytes=100MB
- **WHEN** 系统读取只包含一个 `type: cmd` target(含 `id``cmd.exec`的 YAML 配置文件
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, cmd.cwd 为配置文件所在目录, cmd.maxOutputBytes=100MB, name fallback 到 id
#### Scenario: per-target 配置覆盖全局默认值
- **WHEN** 某个 target 指定 interval、timeout 或对应领域分组中的默认字段
@@ -31,6 +33,22 @@
- **WHEN** YAML 配置中 HTTP target 设置 `http.maxRedirects: 5`
- **THEN** 系统 SHALL 解析该字段并在执行时允许最多跟随 5 次重定向
#### Scenario: 最简 db 配置文件解析
- **WHEN** 系统读取只包含一个 `type: db` target`id``db.url`)的 YAML 配置文件
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, group="default", name fallback 到 id
#### Scenario: defaults.http.method 触发校验错误
- **WHEN** 配置文件中出现 `defaults.http.method` 字段
- **THEN** 系统 SHALL 以配置错误退出,提示 defaults.http 中存在未知字段 method
#### Scenario: per-target http.method 仍然有效
- **WHEN** HTTP target 配置 `http.method: POST`
- **THEN** 系统 SHALL 使用 POST 作为该 target 的请求方法
#### Scenario: 未配置 http.method 使用内置默认值
- **WHEN** HTTP target 未配置 `http.method` 且 defaults.http 中无 method 字段
- **THEN** 系统 SHALL 使用内置默认值 GET 作为该 target 的请求方法
### Requirement: CLI 参数
系统 SHALL 通过单一命令行参数接受 YAML 配置文件路径。
@@ -47,16 +65,16 @@
- **THEN** 系统 SHALL 以错误退出并提示文件不存在
### Requirement: 配置校验
系统 SHALL 在启动时对 YAML 配置进行完整校验,校验失败时以非零状态退出并输出清晰的错误信息。系统 SHALL 使用 TypeBox 定义配置契约和 raw config TypeScript 类型,由 Ajv 校验 TypeBox 生成的 JSON Schema再执行启动期语义 validator。配置加载流程 SHALL 明确区分 `RawProbeConfig``ValidatedProbeConfig``ResolvedConfig` 三段生命周期。JSON Schema 契约 SHALL 覆盖业务无关的结构规则,包括字段类型、必填字段、枚举、数组与对象形状、数值范围和未知字段。语义 validator SHALL 覆盖契约不适合表达的业务规则,包括 target name 唯一性、checker type 注册状态、时长和大小解析、HTTP URL、正则可编译、JSONPath 子集和 XPath 可编译。
系统 SHALL 在启动时对 YAML 配置进行完整校验,校验失败时以非零状态退出并输出清晰的错误信息。系统 SHALL 使用 TypeBox 定义配置契约和 raw config TypeScript 类型,由 Ajv 校验 TypeBox 生成的 JSON Schema再执行启动期语义 validator。配置加载流程 SHALL 明确区分 `RawProbeConfig``ValidatedProbeConfig``ResolvedConfig` 三段生命周期,并在 YAML 解析之后、AJV 校验之前执行变量替换阶段。JSON Schema 契约 SHALL 覆盖业务无关的结构规则,包括字段类型、必填字段、枚举、数组与对象形状、数值范围和未知字段。语义 validator SHALL 覆盖契约不适合表达的业务规则,包括 target id 唯一性、id 命名规则校验、checker type 注册状态、时长和大小解析、HTTP URL、正则可编译、JSONPath 子集和 XPath 可编译。
契约校验和语义 validator SHALL 统一产出 `ConfigValidationIssue`,最终由配置加载流程统一渲染为中文错误信息。
系统 SHALL 导出完整 `probe-config.schema.json`,该文件 SHALL 与运行期 TypeBox fragments 生成的 JSON Schema 保持一致,用于用户配置引用和编辑器提示。
`headers``env` 等明确声明为动态键值表的对象外,配置中的未知字段 SHALL 导致启动期配置错误。系统 MUST NOT 静默忽略未知字段。
`headers``env``variables` 等明确声明为动态键值表的对象外,配置中的未知字段 SHALL 导致启动期配置错误。系统 MUST NOT 静默忽略未知字段。
#### Scenario: target 缺少必填字段
- **WHEN** YAML 中某个 target 缺少 name 或 type 字段
- **WHEN** YAML 中某个 target 缺少 id 或 type 字段
- **THEN** 系统 SHALL 以错误退出,提示哪个 target 缺少哪个字段
#### Scenario: HTTP target 缺少 url
@@ -71,9 +89,13 @@
- **WHEN** YAML 中某个 target 的 type 不是已注册 checker 类型
- **THEN** 系统 SHALL 以错误退出,提示不支持的 target type 和当前支持的 type 列表
#### Scenario: target name 重复
- **WHEN** YAML 中存在两个 name 相同的 target
- **THEN** 系统 SHALL 以错误退出,提示重复的 name
#### Scenario: target id 重复
- **WHEN** YAML 中存在两个 id 相同的 target
- **THEN** 系统 SHALL 以错误退出,提示重复的 id
#### Scenario: target id 不合法
- **WHEN** YAML 中某个 target 的 id 不符合 `[a-zA-Z0-9][a-zA-Z0-9_-]*` 规则
- **THEN** 系统 SHALL 以错误退出,提示 id 命名不合法
#### Scenario: group 字段类型校验
- **WHEN** YAML 中某个 target 的 `group` 字段不是字符串
@@ -205,15 +227,15 @@
#### Scenario: 配置生命周期分离
- **WHEN** 系统加载配置文件
- **THEN** 系统 SHALL 按 `unknown -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig` 的顺序执行契约校验、语义校验和运行期配置解析
- **THEN** 系统 SHALL 按 `unknown -> 变量替换 -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig` 的顺序执行变量替换、契约校验、语义校验和运行期配置解析
#### Scenario: 结构化校验 issue
- **WHEN** 契约校验语义 validator 发现非法配置
- **THEN** 系统 SHALL 先生成包含 code、path、message 和可选 targetName 的结构化 `ConfigValidationIssue`,再统一渲染为中文错误
- **WHEN** 契约校验语义 validator 或变量替换阶段发现非法配置
- **THEN** 系统 SHALL 先生成包含 code、path、message 和可选 targetName 的结构化 `ConfigValidationIssue`,再统一渲染为中文错误信息
#### Scenario: 导出配置 JSON Schema
- **WHEN** 仓库生成或检查配置契约
- **THEN** 根目录 SHALL 存在 draft-07 `probe-config.schema.json`,且其内容 SHALL 与当前公共 fragments 和已注册 checker fragments 组装出的完整 schema 一致
- **THEN** 根目录 SHALL 存在 draft-07 `probe-config.schema.json`,且其内容 SHALL 与当前公共 fragments 和已注册 checker fragments 组装出的完整 schema 一致(包含 variables 段和 target 的 id/name 字段)
系统 SHALL 支持使用单位字符串配置读取上限,单位包括 `B``KB``MB``GB`
#### Scenario: 解析 MB

View File

@@ -34,7 +34,7 @@
#### Scenario: 配置变更后重新同步
- **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启
- **THEN** 系统 SHALL 根据 name 字段匹配:新增的插入、删除的移除、修改的更新(含 grp 字段)
- **THEN** 系统 SHALL 根据 id 字段匹配:新增的插入、删除的移除、修改的更新(含 grp 字段)
### Requirement: check_results 表追加写入
系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。

View File

@@ -20,7 +20,7 @@ HeadMenu operations 区域 SHALL 提供 RadioGroup 组件供用户选择刷新
- **THEN** 系统 SHALL 立即触发一次数据刷新,然后应用新的刷新间隔
### Requirement: 倒计时显示
RadioGroup 右侧 SHALL 显示距下次自动刷新的倒计时文本。倒计时逻辑 SHALL 封装在独立的 `RefreshCountdown` 组件中App 组件 SHALL NOT 持有每秒更新的 `now` state。
RadioGroup 右侧 SHALL 显示距下次自动刷新的倒计时。倒计时逻辑 SHALL 封装在独立的 `RefreshCountdown` 组件中App 组件 SHALL NOT 持有每秒更新的 `now` state。自动倒计时数字 SHALL 使用 `@number-flow/react` 提供滚动过渡,非倒计时状态 SHALL 保持普通文本或按钮语义。
#### Scenario: RefreshCountdown 组件封装
- **WHEN** Dashboard 页面渲染
@@ -30,22 +30,38 @@ RadioGroup 右侧 SHALL 显示距下次自动刷新的倒计时文本。倒计
- **WHEN** RefreshCountdown 组件渲染
- **THEN** 组件 SHALL 接收 `dashboardUpdatedAt: number``refreshInterval: number``isFetching: boolean``isManualRefresh: boolean``onRefresh: () => void` 作为 props
#### Scenario: 短时间格式
- **WHEN** 距下次刷新剩余时间小于 60 秒
#### Scenario: NumberFlow 数字滚动
- **WHEN** 自动刷新模式下已完成首次刷新且当前未处于刷新中状态
- **THEN** 倒计时数字 SHALL 使用 `@number-flow/react``NumberFlow` 渲染,并使用向下滚动趋势表达倒计时递减
#### Scenario: 秒级间隔格式
- **WHEN** 自动刷新间隔小于 60 秒
- **THEN** 倒计时 SHALL 显示为"xx秒"格式(如"26秒"
#### Scenario: 长时间格式
- **WHEN** 距下次刷新剩余时间大于等于 60 秒
- **THEN** 倒计时 SHALL 显示为"x分x秒"格式(如"4分30秒"
#### Scenario: 分钟级稳定格式
- **WHEN** 自动刷新间隔大于等于 60 秒
- **THEN** 倒计时 SHALL 显示为"x分xx秒"格式,秒数 SHALL 固定为两位(如"4分30秒"、"0分09秒"
#### Scenario: 时间数字边界
- **WHEN** 分钟级倒计时中的秒数在 59 到 00 边界变化
- **THEN** 秒数十位 SHALL 按时间显示规则限制在 0 到 5 之间滚动
#### Scenario: 无前缀
- **WHEN** 倒计时显示
- **THEN** 倒计时文本 SHALL 不包含任何前缀(如"下一次刷新:"),直接显示时间
- **THEN** 可见倒计时文本 SHALL 不包含任何前缀(如"下一次刷新:"),直接显示时间
#### Scenario: 可访问文本
- **WHEN** NumberFlow 倒计时渲染
- **THEN** 倒计时容器 SHALL 暴露与当前倒计时等价的可访问文本,供测试和辅助技术读取
#### Scenario: 刷新中状态
- **WHEN** 数据正在刷新isFetching=true 且 isLoading=false
- **THEN** 倒计时文本 SHALL 显示为"刷新中..."
#### Scenario: 等待首次刷新状态
- **WHEN** 自动刷新模式下尚未完成首次刷新
- **THEN** 倒计时文本 SHALL 显示为"等待首次刷新"
### Requirement: App 组件渲染隔离
App 组件 SHALL NOT 持有任何高频更新的 state如每秒更新的时钟确保 App 的重渲染频率与数据刷新频率一致(默认 30 秒一次)。
@@ -73,12 +89,16 @@ App 组件 SHALL NOT 持有任何高频更新的 state如每秒更新的时
- **THEN** 刷新按钮 SHALL 显示 loading 状态且 disabled防止连续点击
### Requirement: 布局稳定性
倒计时/按钮容器 SHALL 保持布局稳定,避免内容变化导致的抖动。
倒计时/按钮容器 SHALL 保持布局稳定,避免内容变化导致的抖动。NumberFlow 倒计时 SHALL 通过分组同步和等宽数字样式降低位数、单位和动画变化带来的布局偏移。
#### Scenario: 数字等宽
- **WHEN** 倒计时数字变化
- **THEN** 容器 SHALL 使用 tabular-nums 字体特性,确保数字等宽不抖动
- **THEN** 容器和 NumberFlow 倒计时 SHALL 使用 tabular-nums 字体特性,确保数字等宽不抖动
#### Scenario: NumberFlow 分组同步
- **WHEN** 分钟级倒计时同时渲染分钟和秒数
- **THEN** 分钟和秒数 SHALL 使用 `NumberFlowGroup` 同步布局变化
#### Scenario: 格式切换不抖动
- **WHEN** 倒计时在"秒"和"分秒"格式间切换
- **WHEN** 倒计时在按钮、秒级文本和分钟级文本之间切换
- **THEN** 容器 SHALL 使用 min-width 确保最小宽度,避免 RadioGroup 位移

View File

@@ -9,7 +9,7 @@ Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer展示
#### Scenario: 打开 Drawer
- **WHEN** 用户点击某个目标表格行
- **THEN** 系统 SHALL 从右侧滑出 Drawerplacement="right"),宽度为 52%,并将当前 Tab 重置为"概览"
- **THEN** 系统 SHALL 从右侧滑出 Drawerplacement="right"使用响应式默认宽度,并将当前 Tab 重置为"概览"
#### Scenario: Drawer 标题栏
- **WHEN** Drawer 渲染
@@ -36,11 +36,11 @@ Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer展示
- **THEN** 时间选择器、Tabs 等区块之间的间距 SHALL 通过 TDesign Space 组件direction="vertical", size={16})统一管理,不使用内联 style 的 marginBottom
### Requirement: 概览面板组件化
概览 Tab SHALL 作为独立组件 `OverviewTab` 实现,展示基本信息、多维度统计(左右布局卡片)和趋势图。不再包含状态分布环形图。
概览 Tab SHALL 作为独立组件 `OverviewTab` 实现,展示基本信息、多维度统计(上下布局卡片)和趋势图。不再包含状态分布环形图。
#### Scenario: OverviewTab 组件职责
- **WHEN** 概览 Tab 渲染
- **THEN** `OverviewTab` 组件 SHALL 负责基本信息(直接展示 Descriptions、多维度统计卡片4×2 左右布局)和趋势图的渲染
- **THEN** `OverviewTab` 组件 SHALL 负责基本信息(直接展示 Descriptions、多维度统计卡片4×2 上下布局)和趋势图的渲染
#### Scenario: OverviewTab props
- **WHEN** OverviewTab 渲染
@@ -217,9 +217,9 @@ Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。Tabs
- **WHEN** 概览面板渲染
- **THEN** Descriptions SHALL 展示:目标地址、检查间隔、最新检查时间、状态详情
#### Scenario: 统计区左右布局卡片
#### Scenario: 统计区上下布局卡片
- **WHEN** 概览面板渲染且有统计数据
- **THEN** 面板 SHALL 在"统计"区域使用 4 列 × 2 行的 Row/Col 布局,每个统计项使用 `<div className="overview-stat-card">` 包裹,通过 CSS 类实现背景色和内边距视觉效果
- **THEN** 面板 SHALL 在"统计"区域使用 4 列 × 2 行的 Row/Col 布局,每个统计项使用 `overview-stat-card` 包裹,内部使用 TDesign Statistic 组件自带的上下布局title 在上、value 在下),通过 `summary-stat-col` 类居中。系统 SHALL NOT 使用已移除的 `overview-stat-item` 左右 flex 布局
#### Scenario: 统计区内容
- **WHEN** 概览面板渲染
@@ -261,11 +261,27 @@ Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。Tabs
- **THEN** 系统 SHALL 依赖 Drawer 内容区域作为唯一纵向滚动容器HistoryTab 中的 PrimaryTable SHALL 不配置 `height``maxHeight` 或纵向 `scroll` 来创建第二个纵向滚动区域
### Requirement: Drawer 宽度
Drawer 宽度 SHALL 设置为 52%
Drawer 宽度 SHALL 根据视口宽度设置响应式默认值,并 SHALL 支持用户通过鼠标拖拽边缘在当前页面生命周期内调整宽度。系统 MUST NOT 将拖拽后的宽度持久化到 `localStorage`、后端、URL 或其他跨刷新存储
#### Scenario: Drawer 宽度
- **WHEN** Drawer 打开
- **THEN** Drawer size SHALL 为 "52%"
#### Scenario: Drawer 响应式默认宽度
- **WHEN** Drawer 打开且用户尚未在当前页面生命周期内拖拽调整宽度
- **THEN** Drawer size SHALL 使用响应式默认宽度,宽屏时占视口比例 SHALL 小于窄屏时占视口比例,且窄屏下 SHALL 不超过视口安全宽度
#### Scenario: Drawer 边缘拖拽宽度
- **WHEN** 用户使用鼠标拖动右侧 Drawer 的左边缘
- **THEN** Drawer SHALL 通过 TDesign Drawer 原生拖拽能力调整宽度,不通过自定义全局鼠标事件实现拖拽
#### Scenario: Drawer 拖拽边界
- **WHEN** 用户拖拽调整 Drawer 宽度
- **THEN** Drawer 宽度 SHALL 被限制在最小可读宽度和视口安全最大宽度之间,避免内容不可读或横向溢出
#### Scenario: Drawer 当前页面生命周期内保留拖拽宽度
- **WHEN** 用户拖拽调整 Drawer 宽度后关闭并再次打开 Drawer且页面未刷新、组件未重新挂载
- **THEN** Drawer SHALL 保留当前页面生命周期内的拖拽后宽度
#### Scenario: Drawer 拖拽宽度不持久化
- **WHEN** 页面刷新后用户再次打开 Drawer
- **THEN** Drawer SHALL 恢复响应式默认宽度,且 MUST NOT 从 `localStorage`、后端、URL 或其他跨刷新存储恢复拖拽宽度
### Requirement: 时间选择器单行布局
Drawer 顶部的时间范围快捷按钮和日期范围选择器 SHALL 在同一行展示。

View File

@@ -0,0 +1,51 @@
## Purpose
定义 target 的 id/name 双字段标识体系id 作为唯一标识符name 作为可选展示名称。
## Requirements
### Requirement: target id 字段
每个 target SHALL 包含必填的 `id` 字段作为唯一标识符。`id` SHALL 符合 `[a-zA-Z0-9][a-zA-Z0-9_-]*` 命名规则。`id` MUST 在所有 targets 中全局唯一。`id` MUST NOT 参与变量替换。
#### Scenario: 合法 id
- **WHEN** target 配置 `id: "api-health"`
- **THEN** 系统 SHALL 使用该 id 作为 target 的唯一标识符
#### Scenario: id 包含下划线和连字符
- **WHEN** target 配置 `id: "db_check-01"`
- **THEN** 系统 SHALL 使用该 id 作为 target 的唯一标识符
#### Scenario: id 缺失报错
- **WHEN** target 未配置 `id` 字段
- **THEN** 系统 SHALL 以配置错误退出,提示该 target 缺少 id 字段
#### Scenario: id 为空字符串报错
- **WHEN** target 配置 `id: ""`
- **THEN** 系统 SHALL 以配置错误退出,提示 id 不能为空
#### Scenario: id 不合法报错
- **WHEN** target 配置 `id: "_invalid"``id: "-start"``id: "has space"`
- **THEN** 系统 SHALL 以配置错误退出,提示 id 不符合命名规则
#### Scenario: id 重复报错
- **WHEN** 两个 target 配置相同的 `id: "api-health"`
- **THEN** 系统 SHALL 以配置错误退出,提示 id 重复
### Requirement: target name 字段
每个 target SHALL 支持可选的 `name` 字段作为展示名称。`name` 缺省时 SHALL fallback 到 `id` 的值作为展示名称。`name` SHALL 支持变量替换。`name` MUST NOT 要求全局唯一。
#### Scenario: 配置 name
- **WHEN** target 配置 `id: "api-health"``name: "API 健康检查"`
- **THEN** 系统 SHALL 使用 "API 健康检查" 作为展示名称
#### Scenario: name 使用变量
- **WHEN** target 配置 `name: "${env} API 健康检查"` 且 variables 中 `env: "生产"`
- **THEN** 系统 SHALL 将展示名称解析为 "生产 API 健康检查"
#### Scenario: name 缺省 fallback 到 id
- **WHEN** target 配置 `id: "api-health"` 但未配置 `name`
- **THEN** 系统 SHALL 使用 "api-health" 作为展示名称
#### Scenario: 多个 target 使用相同 name
- **WHEN** 两个 target 配置不同 id 但相同 `name: "健康检查"`
- **THEN** 系统 SHALL 接受该配置不报错name 不要求全局唯一)

View File

@@ -0,0 +1,73 @@
## Purpose
定义 Dashboard 主题模式选择、系统主题跟随、浏览器本地持久化和 TDesign 主题变量应用行为。
## Requirements
### Requirement: 主题模式选择器
Dashboard SHALL 在 Header 右侧提供主题模式 RadioGroup允许用户选择"系统""明亮""黑暗"三种模式。
#### Scenario: 主题模式选项渲染
- **WHEN** Dashboard 页面渲染
- **THEN** HeadMenu operations 区域 SHALL 在刷新频率选择器前显示 RadioGrouptheme="button", variant="default-filled"),选项为:系统、明亮、黑暗
#### Scenario: 默认选择系统
- **WHEN** 当前浏览器没有已保存的有效主题偏好
- **THEN** 主题模式 RadioGroup SHALL 默认选中"系统"
#### Scenario: 用户切换主题模式
- **WHEN** 用户点击"系统""明亮"或"黑暗"任一主题模式选项
- **THEN** RadioGroup SHALL 选中该选项,并触发对应主题模式生效
### Requirement: 主题模式生效
系统 SHALL 根据用户主题偏好计算有效主题,并通过 `<html>` 元素的 `theme-mode` 属性应用 TDesign 主题变量。
#### Scenario: 系统模式跟随暗色系统
- **WHEN** 用户主题偏好为"系统"且 `prefers-color-scheme: dark` 匹配
- **THEN** 系统 SHALL 设置 `document.documentElement``theme-mode` 属性为 `dark`
#### Scenario: 系统模式跟随亮色系统
- **WHEN** 用户主题偏好为"系统"且 `prefers-color-scheme: dark` 不匹配
- **THEN** 系统 SHALL 设置 `document.documentElement``theme-mode` 属性为 `light`
#### Scenario: 系统主题变化自动更新
- **WHEN** 用户主题偏好为"系统"且浏览器系统主题在明亮和黑暗之间变化
- **THEN** 系统 SHALL 自动更新 `theme-mode` 属性为新的有效主题
#### Scenario: 明亮模式固定主题
- **WHEN** 用户主题偏好为"明亮"
- **THEN** 系统 SHALL 设置 `theme-mode` 属性为 `light`,且系统主题变化 SHALL NOT 改变该属性
#### Scenario: 黑暗模式固定主题
- **WHEN** 用户主题偏好为"黑暗"
- **THEN** 系统 SHALL 设置 `theme-mode` 属性为 `dark`,且系统主题变化 SHALL NOT 改变该属性
### Requirement: 主题偏好本地持久化
系统 SHALL 将用户选择的主题偏好保存到当前浏览器本地存储,并在后续页面加载时恢复。
#### Scenario: 保存用户选择
- **WHEN** 用户切换主题模式
- **THEN** 系统 SHALL 将对应偏好值写入 `localStorage``dial.theme.preference`
#### Scenario: 恢复已保存偏好
- **WHEN** 页面加载且 `localStorage``dial.theme.preference` 键保存了有效偏好值
- **THEN** 系统 SHALL 使用该偏好初始化主题模式 RadioGroup 和有效主题
#### Scenario: 非法本地偏好回退
- **WHEN** 页面加载且 `dial.theme.preference` 保存了非 `system``light``dark` 的值
- **THEN** 系统 SHALL 忽略该值并按"系统"模式初始化
#### Scenario: 本地存储不可用
- **WHEN** 浏览器读取或写入 `localStorage` 抛出异常
- **THEN** Dashboard SHALL 继续正常渲染,并按内存中的主题偏好应用主题
### Requirement: 启动期主题恢复
系统 SHALL 在 React App 首次渲染前尽早应用一次有效主题,降低暗色环境下的亮色闪烁。
#### Scenario: 渲染前应用已保存偏好
- **WHEN** 前端入口初始化且浏览器已保存有效主题偏好
- **THEN** 系统 SHALL 在创建 React root 前根据该偏好设置 `<html>``theme-mode` 属性
#### Scenario: 渲染前应用系统偏好
- **WHEN** 前端入口初始化且浏览器没有有效主题偏好
- **THEN** 系统 SHALL 在创建 React root 前根据 `prefers-color-scheme: dark` 设置 `<html>``theme-mode` 属性

View File

@@ -46,6 +46,7 @@
"vite": "^8.0.13"
},
"dependencies": {
"@number-flow/react": "^0.6.0",
"@sinclair/typebox": "^0.34.49",
"@tanstack/react-query": "^5.100.10",
"@xmldom/xmldom": "^0.9.10",

View File

@@ -35,38 +35,6 @@
"type": "integer"
}
]
},
"method": {
"anyOf": [
{
"const": "DELETE",
"type": "string"
},
{
"const": "GET",
"type": "string"
},
{
"const": "HEAD",
"type": "string"
},
{
"const": "OPTIONS",
"type": "string"
},
{
"const": "PATCH",
"type": "string"
},
{
"const": "POST",
"type": "string"
},
{
"const": "PUT",
"type": "string"
}
]
}
}
},
@@ -89,6 +57,11 @@
]
}
}
},
"db": {
"additionalProperties": false,
"type": "object",
"properties": {}
}
}
},
@@ -131,7 +104,7 @@
"additionalProperties": false,
"type": "object",
"required": [
"name",
"id",
"type",
"http"
],
@@ -430,6 +403,10 @@
"group": {
"type": "string"
},
"id": {
"minLength": 1,
"type": "string"
},
"interval": {
"type": "string"
},
@@ -522,7 +499,7 @@
"additionalProperties": false,
"type": "object",
"required": [
"name",
"id",
"type",
"cmd"
],
@@ -662,6 +639,10 @@
"group": {
"type": "string"
},
"id": {
"minLength": 1,
"type": "string"
},
"interval": {
"type": "string"
},
@@ -716,9 +697,234 @@
}
}
}
},
{
"additionalProperties": false,
"type": "object",
"required": [
"id",
"type",
"db"
],
"properties": {
"expect": {
"additionalProperties": false,
"type": "object",
"properties": {
"maxDurationMs": {
"minimum": 0,
"type": "number"
},
"rowCount": {
"additionalProperties": false,
"minProperties": 1,
"type": "object",
"properties": {
"contains": {
"type": "string"
},
"empty": {
"type": "boolean"
},
"equals": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {},
"type": "array"
},
{
"additionalProperties": {},
"type": "object"
}
]
},
"exists": {
"type": "boolean"
},
"gt": {
"type": "number"
},
"gte": {
"type": "number"
},
"lt": {
"type": "number"
},
"lte": {
"type": "number"
},
"match": {
"type": "string"
}
}
},
"rows": {
"type": "array",
"items": {
"additionalProperties": false,
"minProperties": 1,
"type": "object",
"patternProperties": {
"^(.*)$": {
"anyOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {},
"type": "array"
},
{
"additionalProperties": {},
"type": "object"
}
]
},
{
"additionalProperties": false,
"minProperties": 1,
"type": "object",
"properties": {
"contains": {
"type": "string"
},
"empty": {
"type": "boolean"
},
"equals": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"items": {},
"type": "array"
},
{
"additionalProperties": {},
"type": "object"
}
]
},
"exists": {
"type": "boolean"
},
"gt": {
"type": "number"
},
"gte": {
"type": "number"
},
"lt": {
"type": "number"
},
"lte": {
"type": "number"
},
"match": {
"type": "string"
}
}
}
]
}
}
}
}
}
},
"group": {
"type": "string"
},
"id": {
"minLength": 1,
"type": "string"
},
"interval": {
"type": "string"
},
"name": {
"minLength": 1,
"type": "string"
},
"timeout": {
"type": "string"
},
"type": {
"const": "db",
"type": "string"
},
"db": {
"additionalProperties": false,
"type": "object",
"required": [
"url"
],
"properties": {
"query": {
"minLength": 1,
"type": "string"
},
"url": {
"minLength": 1,
"type": "string"
}
}
}
}
}
]
}
},
"variables": {
"type": "object",
"patternProperties": {
"^[a-zA-Z_][a-zA-Z0-9_]*$": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
}
]
}
}
}
},
"$id": "https://dial.local/probe-config.schema.json",

View File

@@ -1,3 +1,5 @@
# yaml-language-server: $schema=./probe-config.schema.json
server:
host: "127.0.0.1"
port: 3000
@@ -10,15 +12,21 @@ defaults:
interval: "30s"
timeout: "10s"
http:
method: GET
maxBodyBytes: "10MB"
cmd:
maxOutputBytes: "1MB"
variables:
env_name: "演示"
httpbin_base: "https://httpbin.org"
api_token: "Bearer demo-token"
sqlite_url: "sqlite://:memory:"
targets:
# ========== HTTP targets ==========
- name: "Baidu 首页可用"
- id: "baidu-home"
name: "Baidu 首页可用"
type: http
group: "搜索引擎"
http:
@@ -27,15 +35,17 @@ targets:
status: [200]
maxDurationMs: 5000
- name: "JSON API — 完整流水线"
- id: "httpbin-json"
name: "${env_name} JSON API — 完整流水线"
type: http
group: "后端服务"
interval: "1m"
timeout: "15s"
http:
url: "https://httpbin.org/json"
url: "${httpbin_base}/json"
headers:
Accept: "application/json"
Authorization: "${api_token|Bearer fallback-token}"
expect:
headers:
Content-Type:
@@ -48,38 +58,13 @@ targets:
- json:
path: "$.slideshow.slides[0].title"
contains: "Wake"
- json:
path: "$.slideshow.slides[0].type"
equals: "all"
- regex: '"title"'
- name: "HTML 页面 — CSS 选择器"
- id: "httpbin-post"
name: "POST 接口测试"
type: http
http:
url: "https://httpbin.org/html"
expect:
body:
- css:
selector: "h1"
contains: "Moby-Dick"
- css:
selector: "body"
exists: true
- name: "HTML 页面 — XPath 提取节点文本"
type: http
http:
url: "https://httpbin.org/html"
expect:
body:
- xpath:
path: "/html/body/h1/text()"
contains: "Melville"
- name: "POST 接口测试"
type: http
http:
url: "https://httpbin.org/post"
url: "${httpbin_base}/post"
method: POST
headers:
Content-Type: "application/json"
@@ -94,64 +79,10 @@ targets:
path: "$.json.version"
gte: 1
- name: "请求头验证"
type: http
http:
url: "https://httpbin.org/headers"
headers:
X-Custom-Header: "dial-server"
expect:
status: [200]
body:
- json:
path: "$.headers.X-Custom-Header"
equals: "dial-server"
- name: "响应头自定义校验"
type: http
http:
url: "https://httpbin.org/response-headers"
headers:
accept: "application/json"
expect:
body:
- json:
path: "$.Content-Type"
equals: "application/json"
- name: "多状态码允许"
type: http
http:
url: "https://httpbin.org/status/200"
expect:
status: [200, 201, 204]
- name: "状态码范围匹配"
type: http
http:
url: "https://httpbin.org/status/204"
expect:
status: ["2xx"]
- name: "自签名证书跳过 SSL"
type: http
http:
url: "https://internal.local/health"
ignoreSSL: true
expect:
status: ["2xx"]
- name: "跟随重定向"
type: http
http:
url: "https://httpbin.org/redirect/1"
maxRedirects: 5
expect:
status: [200]
# ========== Cmd targets ==========
- name: "Bun 版本输出匹配"
- id: "bun-version"
name: "Bun 版本输出匹配"
type: cmd
group: "系统检查"
cmd:
@@ -162,56 +93,8 @@ targets:
stdout:
- match: "^\\d+\\.\\d+\\.\\d+"
- name: "自定义文本输出"
type: cmd
cmd:
exec: "bun"
args: ["-e", "console.log('check ok')"]
expect:
stdout:
- equals: "check ok\n"
maxDurationMs: 3000
- name: "脚本执行无 stderr"
type: cmd
cmd:
exec: "bun"
args: ["-e", "process.stdout.write('ok')"]
expect:
exitCode: [0]
stderr:
- empty: true
- name: "日期脚本输出包含年份"
type: cmd
cmd:
exec: "bun"
args: ["-e", "console.log(new Date().getFullYear())"]
expect:
stdout:
- match: "^20\\d{2}\n?$"
- name: "环境变量覆盖"
type: cmd
cmd:
exec: "bun"
args: ["-e", "console.log(process.env.LANG ?? '')"]
env:
LANG: "C"
expect:
stdout:
- contains: "C"
- name: "运行平台非空输出"
type: cmd
cmd:
exec: "bun"
args: ["-e", "console.log(process.platform)"]
expect:
stdout:
- match: ".+"
- name: "多规则 stdout 顺序校验"
- id: "bun-stdout-rules"
name: "多规则 stdout 顺序校验"
type: cmd
interval: "5m"
cmd:
@@ -223,7 +106,8 @@ targets:
- match: "\\d+\\.\\d+\\.\\d+"
- contains: "healthy"
- name: "stderr 内容检查"
- id: "bun-stderr"
name: "stderr 内容检查"
type: cmd
cmd:
exec: "bun"
@@ -232,3 +116,31 @@ targets:
exitCode: [1]
stderr:
- contains: "simulated error"
# ========== DB targets ==========
- id: "sqlite-connect"
name: "SQLite 内存数据库连接测试"
type: db
group: "数据库"
db:
url: "${sqlite_url}"
expect:
maxDurationMs: 1000
- id: "sqlite-query"
name: "SQLite 内存数据库多列结果校验"
type: db
db:
url: "${sqlite_url}"
query: "SELECT 1 as id, 'Alice' as name, 'engineer' as role"
expect:
rowCount:
equals: 1
rows:
- id:
gte: 1
name:
exists: true
role:
contains: "engineer"

View File

@@ -1,5 +1,5 @@
import { readdir, rm, writeFile } from "node:fs/promises";
import { join, relative } from "node:path";
import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
const projectRoot = fileURLToPath(new URL("..", import.meta.url));
@@ -68,7 +68,7 @@ async function codeGeneration() {
for (let i = 0; i < allFiles.length; i++) {
const urlPath = allFiles[i]!;
const varName = `f${i}`;
const filePath = relative(buildDir, join(distWebDir, urlPath.slice(1)));
const filePath = toImportSpecifier(buildDir, join(distWebDir, urlPath.slice(1)));
importLines.push(`import ${varName} from "./${filePath}" with { type: "file" };`);
if (urlPath === "/index.html") {
@@ -134,6 +134,10 @@ async function scanDir(dir: string, prefix: string): Promise<string[]> {
return paths;
}
function toImportSpecifier(fromDir: string, targetPath: string) {
return relative(fromDir, targetPath).split(sep).join("/");
}
async function viteBuild() {
console.log("Step 1/3: Vite build...");
const proc = Bun.spawn(["bunx", "--bun", "vite", "build"], {

View File

@@ -1,3 +1,5 @@
import { isNumber, isPlainObject, isString } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import { dirname, resolve } from "node:path";
import type { ConfigValidationIssue } from "./schema/issues";
@@ -8,6 +10,7 @@ import { issue, throwConfigIssues } from "./schema/issues";
import { asValidatedConfig, type RawProbeConfig } from "./schema/types";
import { validateProbeConfigContract } from "./schema/validate";
import { parseDuration } from "./utils";
import { resolveVariables } from "./variables";
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 3000;
@@ -41,11 +44,17 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
throw new Error("配置文件内容为空或格式无效");
}
const contractResult = validateProbeConfigContract(parsed, checkerRegistry);
if (contractResult.config === null && !canRunSemanticValidation(parsed)) {
const variableResult = resolveVariables(parsed);
if (variableResult.issues.length > 0) {
throwConfigIssues(dedupeIssues(variableResult.issues));
}
const resolvedVariablesConfig = variableResult.config;
const contractResult = validateProbeConfigContract(resolvedVariablesConfig, checkerRegistry);
if (contractResult.config === null && !canRunSemanticValidation(resolvedVariablesConfig)) {
throwConfigIssues(contractResult.issues);
}
const semanticInput = (contractResult.config ?? parsed) as RawProbeConfig;
const semanticInput = (contractResult.config ?? resolvedVariablesConfig) as RawProbeConfig;
const validationIssues = validateConfig(semanticInput);
const allIssues = [...contractResult.issues, ...validationIssues];
@@ -88,14 +97,14 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
}
function canRunSemanticValidation(value: unknown): boolean {
return typeof value === "object" && value !== null;
return isPlainObject(value);
}
function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[] {
const seen = new Set<string>();
const result: ConfigValidationIssue[] = [];
for (const item of issues) {
const key = `${item.code}:${item.path}:${item.message}:${item.targetName ?? ""}`;
const key = `${item.code}:${item.path}:${item.message}:${item.targetName ?? ""}:${item.targetId ?? ""}`;
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
@@ -103,16 +112,12 @@ function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[]
return result;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export { parseDuration } from "./utils";
function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number {
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
if (
typeof runtime.maxConcurrentChecks !== "number" ||
!isNumber(runtime.maxConcurrentChecks) ||
!Number.isInteger(runtime.maxConcurrentChecks) ||
runtime.maxConcurrentChecks <= 0
)
@@ -145,29 +150,36 @@ function resolveTarget(
function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
if (!Array.isArray(config.targets) || config.targets.length === 0) {
if (!isArray(config.targets) || config.targets.length === 0) {
issues.push(issue("required", "targets", "配置文件必须包含至少一个 target"));
return issues;
}
const names = new Set<string>();
const ids = new Set<string>();
const supportedTypes = checkerRegistry.supportedTypes;
for (let i = 0; i < config.targets.length; i++) {
const rawTarget = config.targets[i] as unknown;
if (!isRecord(rawTarget)) {
if (!isPlainObject(rawTarget)) {
issues.push(issue("invalid-type", `targets[${i}]`, "必须为对象"));
continue;
}
const raw = rawTarget;
const raw = rawTarget as Record<string, unknown>;
const name = raw["name"];
if (!name || typeof name !== "string" || name.trim() === "") {
issues.push(issue("required", `targets[${i}].name`, "缺少 name 字段"));
const id: unknown = raw["id"];
if (!isString(id) || id.trim() === "") {
issues.push(issue("required", `targets[${i}].id`, "缺少 id 字段"));
continue;
}
const type = raw["type"];
if (!type || typeof type !== "string") {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(id)) {
issues.push(issue("invalid-format", `targets[${i}].id`, "id 不符合命名规则", id));
}
const nameValue: unknown = raw["name"];
const name = isString(nameValue) ? nameValue : id;
const type: unknown = raw["type"];
if (!isString(type)) {
issues.push(issue("required", `targets[${i}].type`, "缺少 type 字段", name));
continue;
}
@@ -183,16 +195,16 @@ function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
);
}
const group = raw["group"];
if (group !== undefined && typeof group !== "string") {
const group: unknown = raw["group"];
if (group !== undefined && !isString(group)) {
issues.push(issue("invalid-type", `targets[${i}].group`, "必须为字符串", name));
}
if (names.has(name)) {
issues.push(issue("duplicate-name", `targets[${i}].name`, `target name 重复: "${name}"`, name));
if (ids.has(id)) {
issues.push(issue("duplicate-id", `targets[${i}].id`, `target id 重复: "${id}"`, name));
}
names.add(name);
ids.add(id);
}
for (const checker of checkerRegistry.definitions) {
@@ -202,22 +214,29 @@ function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
validateDurationValue(config.defaults?.interval, "defaults.interval", issues);
validateDurationValue(config.defaults?.timeout, "defaults.timeout", issues);
validateDurationValue(
typeof config.runtime?.retention === "string" ? config.runtime.retention : undefined,
isString(config.runtime?.retention) ? config.runtime.retention : undefined,
"runtime.retention",
issues,
);
for (let i = 0; i < config.targets.length; i++) {
const target = config.targets[i] as unknown;
if (!isRecord(target)) continue;
const targetName = typeof target["name"] === "string" ? target["name"] : undefined;
if (!isPlainObject(target)) continue;
const targetRecord = target as Record<string, unknown>;
const targetNameValue: unknown = targetRecord["name"];
const targetIdValue: unknown = targetRecord["id"];
const targetName = isString(targetNameValue)
? targetNameValue
: isString(targetIdValue)
? targetIdValue
: undefined;
validateDurationValue(
typeof target["interval"] === "string" ? target["interval"] : undefined,
isString(targetRecord["interval"]) ? targetRecord["interval"] : undefined,
`targets[${i}].interval`,
issues,
targetName,
);
validateDurationValue(
typeof target["timeout"] === "string" ? target["timeout"] : undefined,
isString(targetRecord["timeout"]) ? targetRecord["timeout"] : undefined,
`targets[${i}].timeout`,
issues,
targetName,

View File

@@ -12,7 +12,7 @@ export class ProbeEngine {
private retentionMs: number;
private semaphore: Semaphore;
private store: ProbeStore;
private targetNameToId = new Map<string, number>();
private targetIds = new Set<string>();
private targets: ResolvedTargetBase[];
private timers: Array<ReturnType<typeof setInterval>> = [];
@@ -77,7 +77,7 @@ export class ProbeEngine {
failure: errorFailure("internal", "engine", formatReason(result.reason)),
matched: false,
statusDetail: null,
targetName: target.name,
targetId: target.id,
timestamp: new Date().toISOString(),
});
}
@@ -85,9 +85,9 @@ export class ProbeEngine {
}
private refreshCache(): void {
this.targetNameToId.clear();
this.targetIds.clear();
for (const target of this.store.getTargets()) {
this.targetNameToId.set(target.name, target.id);
this.targetIds.add(target.id);
}
}
@@ -104,15 +104,14 @@ export class ProbeEngine {
}
private writeResult(result: CheckResult): void {
const targetId = this.targetNameToId.get(result.targetName);
if (!targetId) return;
if (!this.targetIds.has(result.targetId)) return;
this.store.insertCheckResult({
durationMs: result.durationMs,
failure: result.failure,
matched: result.matched,
statusDetail: result.statusDetail,
targetId,
targetId: result.targetId,
timestamp: result.timestamp,
});
}

View File

@@ -1,3 +1,6 @@
import { isString } from "es-toolkit";
import { isObject } from "es-toolkit/compat";
import type { CheckFailure } from "../types";
export function errorFailure(phase: CheckFailure["phase"], path: string, message: string): CheckFailure {
@@ -29,7 +32,7 @@ export function mismatchFailure(
export function truncateActual(value: unknown, maxLen = 200): unknown {
if (value === undefined || value === null) return value;
const str = typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : undefined;
const str = isString(value) ? value : isObject(value) ? JSON.stringify(value) : undefined;
if (str === undefined) return value;
if (str.length <= maxLen) return value;
return `${str.slice(0, maxLen)}…(共 ${str.length} 字符)`;

View File

@@ -1,4 +1,5 @@
import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { ExpectOperator, ExpectValue } from "../types";
@@ -14,7 +15,7 @@ export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
break;
case "empty": {
const isEmpty =
isNil(actual) || actual === "" || (Array.isArray(actual) && actual.length === 0) || isEmptyObject(actual);
isNil(actual) || actual === "" || (isArray(actual) && actual.length === 0) || isEmptyObject(actual);
if (expected !== isEmpty) return false;
break;
}
@@ -67,7 +68,7 @@ export function evaluateJsonPath(json: unknown, path: string): unknown {
if (bracketMatch) {
current = (current as Record<string, unknown>)?.[bracketMatch[1]!];
const idx = parseInt(bracketMatch[2]!, 10);
if (!Array.isArray(current) || idx >= current.length) return undefined;
if (!isArray(current) || idx >= current.length) return undefined;
current = current[idx];
} else {
if (current === null || current === undefined) return undefined;

View File

@@ -1,3 +1,6 @@
import { isBoolean, isNumber, isPlainObject, isString } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { ConfigValidationIssue } from "../schema/issues";
import type { JsonValue } from "../types";
@@ -9,17 +12,17 @@ const OPERATOR_KEY_SET = new Set<string>(OperatorKeys);
export function isJsonValue(value: unknown): value is JsonValue {
if (value === null) return true;
if (typeof value === "string" || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isFinite(value);
if (Array.isArray(value)) return value.every(isJsonValue);
if (typeof value === "object") {
return Object.values(value as Record<string, unknown>).every(isJsonValue);
if (isString(value) || isBoolean(value)) return true;
if (isNumber(value)) return Number.isFinite(value);
if (isArray(value)) return value.every(isJsonValue);
if (isPlainObject(value)) {
return Object.values(value).every(isJsonValue);
}
return false;
}
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
return isPlainObject(value);
}
export function validateOperatorObject(
@@ -54,21 +57,21 @@ export function validateOperatorValue(
): ConfigValidationIssue[] {
switch (key) {
case "contains":
return typeof value === "string" ? [] : [issue("invalid-type", path, "必须为字符串", targetName)];
return isString(value) ? [] : [issue("invalid-type", path, "必须为字符串", targetName)];
case "empty":
case "exists":
return typeof value === "boolean" ? [] : [issue("invalid-type", path, "必须为布尔值", targetName)];
return isBoolean(value) ? [] : [issue("invalid-type", path, "必须为布尔值", targetName)];
case "equals":
return isJsonValue(value) ? [] : [issue("invalid-type", path, "必须为 JSON value", targetName)];
case "gt":
case "gte":
case "lt":
case "lte":
return typeof value === "number" && Number.isFinite(value)
return isNumber(value) && Number.isFinite(value)
? []
: [issue("invalid-type", path, "必须为有限数字", targetName)];
case "match":
if (typeof value !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
if (!isString(value)) return [issue("invalid-type", path, "必须为字符串", targetName)];
try {
new RegExp(value);
} catch {

View File

@@ -41,7 +41,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)),
matched: false,
statusDetail: null,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -74,7 +74,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: errorFailure("exitCode", "execution", "输出读取失败"),
matched: false,
statusDetail: null,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -90,7 +90,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: errorFailure("exitCode", "output", `输出超过限制 ${t.cmd.maxOutputBytes} 字节`),
matched: false,
statusDetail: `exitCode=${exitCode}`,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -101,7 +101,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: errorFailure("exitCode", "timeout", `命令执行超时 (${t.timeoutMs}ms)`),
matched: false,
statusDetail: null,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -113,7 +113,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: exitCodeResult.failure,
matched: false,
statusDetail: `exitCode=${exitCode}`,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -125,7 +125,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: durationResult.failure,
matched: false,
statusDetail: `exitCode=${exitCode}`,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -138,7 +138,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: stdoutResult.failure,
matched: false,
statusDetail: `exitCode=${exitCode}`,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -152,7 +152,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: stderrResult.failure,
matched: false,
statusDetail: `exitCode=${exitCode}`,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -163,7 +163,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: null,
matched: true,
statusDetail: `exitCode=${exitCode}`,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -189,8 +189,9 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
},
expect: target.expect as CommandExpectConfig | undefined,
group: target.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name,
name: t.name ?? t.id,
timeoutMs: context.defaultTimeoutMs,
type: "cmd",
} satisfies ResolvedCommandTarget;

View File

@@ -1,3 +1,6 @@
import { isNumber, isPlainObject, isString } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
@@ -7,7 +10,8 @@ import { parseSize } from "../../utils";
export function validateCommandConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const defaults = isRecord(input.defaults) && isRecord(input.defaults["cmd"]) ? input.defaults["cmd"] : undefined;
const defaults =
isPlainObject(input.defaults) && isPlainObject(input.defaults["cmd"]) ? input.defaults["cmd"] : undefined;
if (isSizeInput(defaults?.["maxOutputBytes"])) {
issues.push(...validateSizeValue(defaults["maxOutputBytes"], "defaults.cmd.maxOutputBytes"));
@@ -15,7 +19,7 @@ export function validateCommandConfig(input: CheckerValidationInput): ConfigVali
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isRecord(target)) continue;
if (!isPlainObject(target)) continue;
if (target["type"] !== "cmd") continue;
issues.push(...validateCommandTarget(target, `targets[${i}]`));
}
@@ -24,25 +28,22 @@ export function validateCommandConfig(input: CheckerValidationInput): ConfigVali
}
function getTargetName(target: Record<string, unknown>): string | undefined {
return typeof target["name"] === "string" ? target["name"] : undefined;
if (isString(target["name"])) return target["name"];
return isString(target["id"]) ? target["id"] : undefined;
}
function isNonNegativeFiniteNumber(value: unknown): boolean {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
return isNumber(value) && Number.isFinite(value) && value >= 0;
}
function isSizeInput(value: unknown): value is number | string {
return typeof value === "number" || typeof value === "string";
return isNumber(value) || isString(value);
}
function validateCommandExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const targetName = getTargetName(target);
const expect = target["expect"];
if (expect === undefined || expect === null || !isRecord(expect)) return [];
if (expect === undefined || expect === null || !isPlainObject(expect)) return [];
const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect");
if (expect["stdout"] !== undefined) {
@@ -61,12 +62,12 @@ function validateCommandTarget(target: Record<string, unknown>, path: string): C
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const cmd = target["cmd"];
if (!isRecord(cmd)) {
if (!isPlainObject(cmd)) {
issues.push(issue("required", joinPath(path, "cmd"), "缺少 cmd.exec 字段", targetName));
issues.push(...validateCommandExpect(target, path));
return issues;
}
if (typeof cmd["exec"] !== "string" || cmd["exec"].trim() === "") {
if (!isString(cmd["exec"]) || cmd["exec"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "cmd"), "exec"), "缺少 cmd.exec 字段", targetName));
}
if (isSizeInput(cmd["maxOutputBytes"])) {
@@ -88,6 +89,6 @@ function validateSizeValue(value: number | string, path: string, targetName?: st
}
function validateTextRules(rules: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!Array.isArray(rules)) return [issue("invalid-type", path, "必须为数组", targetName)];
if (!isArray(rules)) return [issue("invalid-type", path, "必须为数组", targetName)];
return rules.flatMap((rule, index) => validateOperatorObject(rule, `${path}[${index}]`, targetName));
}

View File

@@ -0,0 +1,208 @@
import { SQL } from "bun";
import { isError } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { DbExpectConfig, DbTargetConfig, ResolvedDbTarget } from "./types";
import { checkDuration } from "../../expect/duration";
import { errorFailure } from "../../expect/failure";
import { checkRowCount, checkRows } from "./expect";
import { dbCheckerSchemas } from "./schema";
import { validateDbConfig } from "./validate";
const PROBE_QUERY = "SELECT 1";
export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
readonly configKey = "db";
readonly schemas = dbCheckerSchemas;
readonly type = "db";
async execute(t: ResolvedDbTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
let db: SQL | undefined;
try {
// 创建连接SQLite 不需要 max 选项)
db = new SQL(t.db.url);
// 监听 abort signal
ctx.signal.addEventListener(
"abort",
() => {
void db?.close({ timeout: 0 }).catch(() => {
/* best-effort close */
});
},
{ once: true },
);
// 连接测试Bun SQL 是 lazy 的,首次查询才真正连接)
try {
await db.unsafe(PROBE_QUERY);
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: errorFailure("connect", "connect", isError(error) ? error.message : String(error)),
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
// 无 query 时仅测试连接
if (!t.db.query) {
const durationMs = Math.round(performance.now() - start);
const durationResult = checkDuration(durationMs, t.expect?.maxDurationMs);
if (!durationResult.matched) {
return {
durationMs,
failure: durationResult.failure,
matched: false,
statusDetail: "connected",
targetId: t.id,
timestamp,
};
}
return {
durationMs,
failure: null,
matched: true,
statusDetail: "connected",
targetId: t.id,
timestamp,
};
}
// 执行用户 SQL
let rows: unknown[];
try {
rows = await db.unsafe(t.db.query);
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: errorFailure("query", "query", isError(error) ? error.message : String(error)),
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
const durationMs = Math.round(performance.now() - start);
// 检查是否超时
if (ctx.signal.aborted) {
return {
durationMs,
failure: errorFailure("query", "timeout", `查询超时 (${t.timeoutMs}ms)`),
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
// duration 断言
const durationResult = checkDuration(durationMs, t.expect?.maxDurationMs);
if (!durationResult.matched) {
return {
durationMs,
failure: durationResult.failure,
matched: false,
statusDetail: `${isArray(rows) ? rows.length : 0} rows`,
targetId: t.id,
timestamp,
};
}
// rowCount 断言
if (t.expect?.rowCount) {
const rowCountResult = checkRowCount(rows, t.expect.rowCount);
if (!rowCountResult.matched) {
return {
durationMs,
failure: rowCountResult.failure,
matched: false,
statusDetail: `${isArray(rows) ? rows.length : 0} rows`,
targetId: t.id,
timestamp,
};
}
}
// rows 断言
if (t.expect?.rows && t.expect.rows.length > 0) {
const rowsResult = checkRows(rows, t.expect.rows);
if (!rowsResult.matched) {
return {
durationMs,
failure: rowsResult.failure,
matched: false,
statusDetail: `${isArray(rows) ? rows.length : 0} rows`,
targetId: t.id,
timestamp,
};
}
}
return {
durationMs,
failure: null,
matched: true,
statusDetail: `${isArray(rows) ? rows.length : 0} rows`,
targetId: t.id,
timestamp,
};
} finally {
if (db) {
try {
await db.close({ timeout: 0 });
} catch {
/* best-effort close */
}
}
}
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedDbTarget {
const t = target as RawTargetConfig & { db: DbTargetConfig; type: "db" };
return {
db: {
query: t.db.query,
url: t.db.url,
},
expect: target.expect as DbExpectConfig | undefined,
group: target.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? t.id,
timeoutMs: context.defaultTimeoutMs,
type: "db",
} satisfies ResolvedDbTarget;
}
serialize(t: ResolvedDbTarget): { config: string; target: string } {
// 屏蔽凭据postgres://user:pass@host → postgres://***:***@host
const masked = t.db.url.replace(/:\/\/([^@]+)@/, "://***:***@");
return {
config: JSON.stringify({
query: t.db.query ?? null,
url: masked,
}),
target: masked,
};
}
validate(input: CheckerValidationInput) {
return validateDbConfig(input);
}
}

View File

@@ -0,0 +1,60 @@
import { isPlainObject } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { ExpectResult } from "../../expect/types";
import type { ExpectOperator, ExpectValue } from "../../types";
import { mismatchFailure } from "../../expect/failure";
import { checkExpectValue } from "../../expect/operator";
export function checkRowCount(rows: unknown, op: ExpectOperator): ExpectResult {
const actual = isArray(rows) ? rows.length : 0;
const matched = checkExpectValue(actual, op);
if (!matched) {
return {
failure: mismatchFailure("rowCount", "rowCount", op, actual, `rowCount ${actual} 不满足条件`),
matched: false,
};
}
return { failure: null, matched: true };
}
export function checkRows(rows: unknown, rules: Array<Record<string, ExpectValue>>): ExpectResult {
if (!isArray(rows)) {
return {
failure: mismatchFailure("row", "rows", rules, rows, "查询结果不是数组"),
matched: false,
};
}
for (let i = 0; i < rules.length; i++) {
const rule = rules[i]!;
if (i >= rows.length) {
return {
failure: mismatchFailure("row", `rows[${i}]`, "expected row", undefined, `结果行数不足,需要第 ${i + 1}`),
matched: false,
};
}
const row = rows[i]! as null | Record<string, unknown> | undefined;
if (!isPlainObject(row)) {
return {
failure: mismatchFailure("row", `rows[${i}]`, "object", row, `${i + 1} 行不是对象`),
matched: false,
};
}
for (const [col, expected] of Object.entries(rule)) {
const actual = row[col];
const matched = checkExpectValue(actual, expected);
if (!matched) {
return {
failure: mismatchFailure("row", `rows[${i}].${col}`, expected, actual, `rows[${i}].${col} mismatch`),
matched: false,
};
}
}
}
return { failure: null, matched: true };
}

View File

@@ -0,0 +1 @@
export { DbChecker } from "./execute";

View File

@@ -0,0 +1,52 @@
import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import { createPureOperatorSchema, jsonValueSchema, operatorProperties } from "../../schema/fragments";
// Db expect 允许行对象中的列值为字面量或 operator
const dbRowValueSchema = Type.Union([jsonValueSchema, createPureOperatorSchema()]);
export const dbCheckerSchemas: CheckerSchemas = {
config: Type.Object(
{
query: Type.Optional(
Type.String({
minLength: 1,
}),
),
url: Type.String({ minLength: 1 }),
},
{ additionalProperties: false },
),
defaults: Type.Object({}, { additionalProperties: false }),
expect: Type.Object(
{
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
rowCount: Type.Optional(createPureOperatorSchema()),
rows: Type.Optional(
Type.Array(
Type.Record(Type.String(), dbRowValueSchema, {
additionalProperties: false,
minProperties: 1,
}),
),
),
},
{ additionalProperties: false },
),
};
// 导出用于 validate 的辅助类型
export const DbOperatorKeys = new Set<string>([
...Object.keys(operatorProperties()),
"contains",
"empty",
"equals",
"exists",
"gt",
"gte",
"lt",
"lte",
"match",
]);

View File

@@ -0,0 +1,27 @@
import type { ExpectOperator, ExpectValue, ResolvedTargetBase } from "../../types";
export interface DbExpectConfig {
maxDurationMs?: number;
rowCount?: ExpectOperator;
rows?: Array<Record<string, ExpectValue>>;
}
export interface DbTargetConfig {
query?: string;
url: string;
}
export interface ResolvedDbConfig {
query?: string;
url: string;
}
export interface ResolvedDbTarget extends ResolvedTargetBase {
db: ResolvedDbConfig;
expect?: DbExpectConfig;
group: string;
intervalMs: number;
name: string;
timeoutMs: number;
type: "db";
}

View File

@@ -0,0 +1,143 @@
import { isNumber, isPlainObject, isString } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { isUnsafeRegex } from "../../expect/redos";
import { validateOperatorObject } from "../../expect/validate-operator";
import { issue, joinPath } from "../../schema/issues";
export function validateDbConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isPlainObject(target)) continue;
if (target["type"] !== "db") continue;
issues.push(...validateDbTarget(target, `targets[${i}]`));
}
return issues;
}
function collectRowOperators(rows: unknown[], path: string, targetName?: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
if (!isPlainObject(row)) {
issues.push(issue("invalid-type", `${path}[${i}]`, "必须为对象", targetName));
continue;
}
for (const [col, value] of Object.entries(row)) {
const colPath = `${path}[${i}].${col}`;
if (isPlainObject(value) && Object.keys(value).some((k) => k === "match")) {
// 检查 match 正则
const valueRecord = value as Record<string, unknown>;
const match: unknown = valueRecord["match"];
if (isString(match)) {
try {
new RegExp(match);
} catch {
issues.push(issue("invalid-regex", colPath, "正则不合法", targetName));
}
if (isUnsafeRegex(match)) {
issues.push(issue("unsafe-regex", colPath, "正则存在 ReDoS 风险", targetName));
}
}
}
// 校验 operator 对象
if (isPlainObject(value)) {
issues.push(...validateOperatorObject(value, colPath, targetName, { requireAtLeastOne: false }));
}
}
}
return issues;
}
function getTargetName(target: Record<string, unknown>): string | undefined {
if (isString(target["name"])) return target["name"];
return isString(target["id"]) ? target["id"] : undefined;
}
function isNonNegativeFiniteNumber(value: unknown): boolean {
return isNumber(value) && Number.isFinite(value) && value >= 0;
}
function validateDbExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const targetName = getTargetName(target);
const expect = target["expect"];
if (expect === undefined || expect === null || !isPlainObject(expect)) return [];
const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect");
if (expect["maxDurationMs"] !== undefined && !isNonNegativeFiniteNumber(expect["maxDurationMs"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "maxDurationMs"), "必须为非负有限数字", targetName));
}
if (expect["rowCount"] !== undefined) {
issues.push(...validateOperatorObject(expect["rowCount"], joinPath(expectPath, "rowCount"), targetName));
}
if (expect["rows"] !== undefined) {
if (!isArray(expect["rows"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "rows"), "必须为数组", targetName));
} else {
issues.push(...collectRowOperators(expect["rows"], joinPath(expectPath, "rows"), targetName));
}
}
// 检查未知字段
const allowedKeys = new Set(["maxDurationMs", "rowCount", "rows"]);
for (const key of Object.keys(expect)) {
if (!allowedKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
}
}
return issues;
}
function validateDbTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const db = target["db"];
if (!isPlainObject(db)) {
issues.push(issue("required", joinPath(path, "db"), "缺少 db.url 字段", targetName));
issues.push(...validateDbExpect(target, path));
return issues;
}
// url 必填
if (!isString(db["url"]) || db["url"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "db"), "url"), "缺少 db.url 字段", targetName));
}
// query 可选但不能为空字符串
if (db["query"] !== undefined) {
if (!isString(db["query"])) {
issues.push(issue("invalid-type", joinPath(joinPath(path, "db"), "query"), "必须为字符串", targetName));
} else if (db["query"].trim() === "") {
issues.push(
issue(
"invalid-value",
joinPath(joinPath(path, "db"), "query"),
"不能为空字符串(如不需要查询则不配置该字段)",
targetName,
),
);
}
}
// 检查未知字段
const allowedDbKeys = new Set(["query", "url"]);
for (const key of Object.keys(db)) {
if (!allowedDbKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(joinPath(path, "db"), key), "是未知字段", targetName));
}
}
issues.push(...validateDbExpect(target, path));
return issues;
}

View File

@@ -1,5 +1,6 @@
import { DOMParser } from "@xmldom/xmldom";
import * as cheerio from "cheerio";
import { isArray } from "es-toolkit/compat";
import * as xpath from "xpath";
import type { ExpectResult } from "../../expect/types";
@@ -177,7 +178,7 @@ function checkXpathRule(body: string, rule: XpathRule, rulePath: string): Expect
}
const nodes = xpath.select(path, doc as unknown as Node);
if (!nodes || !Array.isArray(nodes) || nodes.length === 0) {
if (!nodes || !isArray(nodes) || nodes.length === 0) {
return {
failure: mismatchFailure("body", fullPath, "node found", "no match", `xpath ${path} not found`),
matched: false,

View File

@@ -1,4 +1,5 @@
import { isError } from "es-toolkit";
import { isObject } from "es-toolkit/compat";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
@@ -95,7 +96,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
),
matched: false,
statusDetail: null,
targetName: t.name,
targetId: t.id,
timestamp,
};
}
@@ -105,9 +106,9 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
const t = target as RawTargetConfig & { http: HttpTargetConfig; type: "http" };
const httpDefaults = context.defaults["http"] as
| undefined
| { headers?: Record<string, string>; maxBodyBytes?: string; method?: string };
| { headers?: Record<string, string>; maxBodyBytes?: string };
const method = t.http.method ?? httpDefaults?.method ?? "GET";
const method = t.http.method ?? "GET";
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
return {
@@ -122,8 +123,9 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
method,
url: t.http.url,
},
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name,
name: t.name ?? t.id,
timeoutMs: context.defaultTimeoutMs,
type: "http",
} satisfies ResolvedHttpTarget;
@@ -154,10 +156,7 @@ function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: strin
const method = init.method?.toUpperCase();
if (statusCode === 303 || ((statusCode === 301 || statusCode === 302) && method === "POST")) {
const headers =
typeof init.headers === "object" && init.headers !== null
? { ...(init.headers as Record<string, string>) }
: undefined;
const headers = isObject(init.headers) ? { ...(init.headers as Record<string, string>) } : undefined;
if (headers) {
for (const key of Object.keys(headers)) {
const lower = key.toLowerCase();
@@ -172,7 +171,7 @@ function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: strin
try {
const fromOrigin = new URL(fromUrl).origin;
const toOrigin = new URL(toUrl).origin;
if (fromOrigin !== toOrigin && newInit.headers && typeof newInit.headers === "object") {
if (fromOrigin !== toOrigin && isObject(newInit.headers)) {
const headers = { ...(newInit.headers as Record<string, string>) };
for (const key of Object.keys(headers)) {
if (SENSITIVE_HEADERS.has(key.toLowerCase())) {
@@ -264,7 +263,7 @@ function makeResult(
failure,
matched: failure === null,
statusDetail: `HTTP ${statusCode}`,
targetName: t.name,
targetId: t.id,
timestamp,
};
}

View File

@@ -1,3 +1,5 @@
import { isNumber, isString } from "es-toolkit";
import type { ExpectResult } from "../../expect/types";
import type { HeaderExpect } from "./types";
@@ -14,7 +16,7 @@ export function checkHeaders(
const actualValue = headers[key.toLowerCase()];
const path = `headers.${key}`;
if (typeof expected === "string") {
if (isString(expected)) {
if (actualValue !== expected) {
return {
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
@@ -45,7 +47,7 @@ export function checkHeaders(
export function checkStatus(statusCode: number, allowed: Array<number | string>): ExpectResult {
const matched = allowed.some((pattern) => {
if (typeof pattern === "number") return statusCode === pattern;
if (isNumber(pattern)) return statusCode === pattern;
const base = parseInt(pattern[0]!, 10) * 100;
return statusCode >= base && statusCode < base + 100;
});

View File

@@ -28,7 +28,6 @@ export const httpCheckerSchemas: CheckerSchemas = {
{
headers: Type.Optional(stringMapSchema),
maxBodyBytes: Type.Optional(sizeSchema),
method: Type.Optional(httpMethodSchema),
},
{ additionalProperties: false },
),

View File

@@ -1,4 +1,6 @@
import { DOMParser } from "@xmldom/xmldom";
import { isNumber, isString } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import * as xpath from "xpath";
import type { ConfigValidationIssue } from "../../schema/issues";
@@ -14,7 +16,7 @@ const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
const OPERATOR_KEY_SET = new Set<string>(OperatorKeys);
export function validateBodyRules(body: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!Array.isArray(body)) return [issue("invalid-type", path, "必须为数组", targetName)];
if (!isArray(body)) return [issue("invalid-type", path, "必须为数组", targetName)];
return body.flatMap((rule, index) => validateSingleBodyRule(rule, `${path}[${index}]`, targetName));
}
@@ -75,24 +77,25 @@ function collectOperatorObject(
}
function getTargetName(target: Record<string, unknown>): string | undefined {
return typeof target["name"] === "string" ? target["name"] : undefined;
if (isString(target["name"])) return target["name"];
return isString(target["id"]) ? target["id"] : undefined;
}
function isNonNegativeFiniteNumber(value: unknown): boolean {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
return isNumber(value) && Number.isFinite(value) && value >= 0;
}
function isSizeInput(value: unknown): value is number | string {
return typeof value === "number" || typeof value === "string";
return isNumber(value) || isString(value);
}
function validateCssRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (typeof rule["selector"] !== "string" || rule["selector"].trim() === "") {
if (!isString(rule["selector"]) || rule["selector"].trim() === "") {
issues.push(issue("invalid-type", joinPath(path, "selector"), "必须为非空字符串", targetName));
}
if ("attr" in rule && typeof rule["attr"] !== "string") {
if ("attr" in rule && !isString(rule["attr"])) {
issues.push(issue("invalid-type", joinPath(path, "attr"), "必须为字符串", targetName));
}
const result = collectOperatorObject(rule, new Set(["attr", "selector"]), path, targetName);
@@ -112,7 +115,7 @@ function validateHttpExpect(target: Record<string, unknown>, path: string): Conf
if (isPlainRecord(expect["headers"])) {
for (const [key, value] of Object.entries(expect["headers"])) {
if (typeof value === "string") continue;
if (isString(value)) continue;
issues.push(...validateOperatorObject(value, joinPath(joinPath(expectPath, "headers"), key), targetName));
}
}
@@ -121,7 +124,7 @@ function validateHttpExpect(target: Record<string, unknown>, path: string): Conf
issues.push(...validateBodyRules(expect["body"], joinPath(expectPath, "body"), targetName));
}
if (Array.isArray(expect["status"])) {
if (isArray(expect["status"])) {
issues.push(...validateStatusValues(expect["status"], joinPath(expectPath, "status"), targetName));
}
@@ -141,7 +144,7 @@ function validateHttpTarget(target: Record<string, unknown>, path: string): Conf
issues.push(...validateHttpExpect(target, path));
return issues;
}
if (typeof http["url"] !== "string" || http["url"].trim() === "") {
if (!isString(http["url"]) || http["url"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "http"), "url"), "缺少 http.url 字段", targetName));
} else {
try {
@@ -172,7 +175,7 @@ function validateHttpTarget(target: Record<string, unknown>, path: string): Conf
function validateJsonRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (typeof rule["path"] !== "string") {
if (!isString(rule["path"])) {
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为字符串", targetName));
} else {
issues.push(...validateJsonPath(rule["path"], path, targetName));
@@ -186,7 +189,7 @@ function validateJsonRule(rule: unknown, path: string, targetName?: string): Con
}
function validateRegexRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (typeof rule !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
if (!isString(rule)) return [issue("invalid-type", path, "必须为字符串", targetName)];
try {
new RegExp(rule);
} catch {
@@ -210,7 +213,7 @@ function validateSingleBodyRule(rule: unknown, path: string, targetName?: string
switch (ruleType) {
case "contains":
return typeof rule["contains"] === "string"
return isString(rule["contains"])
? []
: [issue("invalid-type", joinPath(path, "contains"), "必须为字符串", targetName)];
case "css":
@@ -238,13 +241,13 @@ function validateStatusValues(values: unknown[], path: string, targetName?: stri
for (let i = 0; i < values.length; i++) {
const value = values[i];
const itemPath = `${path}[${i}]`;
if (typeof value === "number") {
if (isNumber(value)) {
if (!Number.isInteger(value) || value < 100 || value > 599) {
issues.push(issue("invalid-status", itemPath, "status 数字必须为 100-599 之间的整数", targetName));
}
continue;
}
if (typeof value === "string") {
if (isString(value)) {
if (!/^[1-5]xx$/.test(value)) {
issues.push(issue("invalid-status", itemPath, "status 模式必须为 1xx 到 5xx", targetName));
}
@@ -258,7 +261,7 @@ function validateStatusValues(values: unknown[], path: string, targetName?: stri
function validateXpathRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = [];
if (typeof rule["path"] !== "string" || rule["path"].trim() === "") {
if (!isString(rule["path"]) || rule["path"].trim() === "") {
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为非空字符串", targetName));
} else {
try {

View File

@@ -1,8 +1,9 @@
import { CommandChecker } from "./cmd";
import { DbChecker } from "./db";
import { HttpChecker } from "./http";
import { CheckerRegistry } from "./registry";
const checkers = [new HttpChecker(), new CommandChecker()];
const checkers = [new HttpChecker(), new CommandChecker(), new DbChecker()];
export function createDefaultCheckerRegistry(): CheckerRegistry {
const registry = new CheckerRegistry();

View File

@@ -4,7 +4,7 @@ import { Type } from "@sinclair/typebox";
import type { CheckerDefinition } from "../runner/types";
import { durationSchema } from "./fragments";
import { durationSchema, variableValueSchema } from "./fragments";
export function createExternalProbeConfigSchema(checkers: CheckerDefinition[]): Record<string, unknown> {
return {
@@ -41,6 +41,7 @@ export function createProbeConfigSchema(checkers: CheckerDefinition[], external
targets: Type.Array(external ? createExternalTargetSchema(checkers) : createBaseTargetSchema(checkers), {
minItems: 1,
}),
variables: Type.Optional(Type.Record(Type.String({ pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$" }), variableValueSchema)),
},
{ additionalProperties: false },
);
@@ -50,8 +51,9 @@ export function createTargetSchema(checker: CheckerDefinition): TSchema {
const properties: Record<string, TSchema> = {
expect: Type.Optional(checker.schemas.expect),
group: Type.Optional(Type.String()),
id: Type.String({ minLength: 1 }),
interval: Type.Optional(durationSchema),
name: Type.String({ minLength: 1 }),
name: Type.Optional(Type.String({ minLength: 1 })),
timeout: Type.Optional(durationSchema),
type: Type.Literal(checker.type),
};
@@ -67,8 +69,9 @@ function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema {
return Type.Object(
{
group: Type.Optional(Type.String()),
id: Type.String({ minLength: 1 }),
interval: Type.Optional(durationSchema),
name: Type.String({ minLength: 1 }),
name: Type.Optional(Type.String({ minLength: 1 })),
timeout: Type.Optional(durationSchema),
type: Type.Union(checkers.map((checker) => Type.Literal(checker.type)) as unknown as [TSchema, ...TSchema[]]),
},

View File

@@ -29,6 +29,8 @@ export const jsonValueSchema = Type.Unsafe<JsonValue>({
export const sizeSchema = Type.Union([Type.String(), Type.Integer({ minimum: 0 })]);
export const variableValueSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean()]);
export const statusCodePatternSchema = Type.Union([
Type.Integer({ maximum: 599, minimum: 100 }),
Type.String({ pattern: "^[1-5]xx$" }),

View File

@@ -2,6 +2,7 @@ export interface ConfigValidationIssue {
code: string;
message: string;
path: string;
targetId?: string;
targetName?: string;
}
@@ -9,8 +10,20 @@ export function formatConfigIssues(issues: ConfigValidationIssue[]): string {
return issues.map(formatConfigIssue).join("\n");
}
export function issue(code: string, path: string, message: string, targetName?: string): ConfigValidationIssue {
return targetName === undefined ? { code, message, path } : { code, message, path, targetName };
export function issue(
code: string,
path: string,
message: string,
targetName?: string,
targetId?: string,
): ConfigValidationIssue {
return {
code,
message,
path,
...(targetName === undefined ? {} : { targetName }),
...(targetId === undefined ? {} : { targetId }),
};
}
export function joinPath(base: string, key: string): string {
@@ -28,6 +41,15 @@ export function throwConfigIssues(issues: ConfigValidationIssue[]): never {
}
function formatConfigIssue(issue: ConfigValidationIssue): string {
if (issue.targetId) {
const path = issue.path.replace(/^targets\[\d+\]\.?/, "");
const renderedPath = path === "" ? "配置" : path;
const label =
issue.targetName && issue.targetName !== issue.targetId
? `target "${issue.targetName}" (id: "${issue.targetId}")`
: `target id "${issue.targetId}"`;
return `${label}${renderedPath} ${issue.message}`;
}
if (issue.targetName) {
const path = issue.path.replace(/^targets\[\d+\]\.?/, "");
const renderedPath = path === "" ? "配置" : path;

View File

@@ -1,6 +1,8 @@
import type { ErrorObject } from "ajv";
import Ajv from "ajv";
import { isPlainObject, isString } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { CheckerRegistry } from "../runner/registry";
import type { ConfigValidationIssue } from "./issues";
@@ -29,12 +31,19 @@ export function validateProbeConfigContract(
issues.push(...issuesFromAjvErrors(rootValidate.errors ?? [], config));
}
if (isRecord(config) && isUnknownArray(config["targets"])) {
const targets = config["targets"];
if (isPlainObject(config)) {
const configRecord = config as Record<string, unknown>;
const targetsValue: unknown = configRecord["targets"];
if (!isArray(targetsValue))
return issues.length > 0 ? { config: null, issues } : { config: config as RawProbeConfig, issues: [] };
const targets = targetsValue;
for (let i = 0; i < targets.length; i++) {
const target = targets[i];
if (!isRecord(target) || typeof target["type"] !== "string") continue;
const checker = registry.tryGet(target["type"]);
const target: unknown = targets[i];
if (!isPlainObject(target)) continue;
const targetRecord = target as Record<string, unknown>;
const targetType: unknown = targetRecord["type"];
if (!isString(targetType)) continue;
const checker = registry.tryGet(targetType);
if (!checker) continue;
const targetValidate = ajv.compile(createTargetSchema(checker));
if (!targetValidate(target)) {
@@ -62,13 +71,9 @@ function hasMoreSpecificError(keywords: Set<string>): boolean {
return ["const", "enum", "maximum", "minimum", "minLength", "pattern"].some((keyword) => keywords.has(keyword));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function issueFromAjvError(error: ErrorObject, root: unknown, basePath: string): ConfigValidationIssue {
const path = buildIssuePath(basePath, error);
const targetName = targetNameFromPath(root, path);
const targetName = targetDisplayNameFromPath(root, path);
switch (error.keyword) {
case "additionalProperties":
return issue("unknown-field", path, "是未知字段", targetName);
@@ -91,10 +96,6 @@ function issueFromAjvError(error: ErrorObject, root: unknown, basePath: string):
}
}
function isUnknownArray(value: unknown): value is unknown[] {
return Array.isArray(value);
}
function joinBasePath(basePath: string, path: string): string {
if (basePath === "") return path;
if (path === "") return basePath;
@@ -136,10 +137,17 @@ function normalizeAjvErrors(errors: ErrorObject[], basePath: string): ErrorObjec
});
}
function targetNameFromPath(root: unknown, path: string): string | undefined {
function targetDisplayNameFromPath(root: unknown, path: string): string | undefined {
const match = /^targets\[(\d+)\]/.exec(path);
if (!match || !isRecord(root) || !isUnknownArray(root["targets"])) return undefined;
const target = root["targets"][Number(match[1])];
if (!isRecord(target) || typeof target["name"] !== "string") return undefined;
return target["name"];
if (!match || !isPlainObject(root)) return undefined;
const rootRecord = root as Record<string, unknown>;
const targetsValue: unknown = rootRecord["targets"];
if (!isArray(targetsValue)) return undefined;
const target: unknown = targetsValue[Number(match[1])];
if (!isPlainObject(target)) return undefined;
const targetRecord = target as Record<string, unknown>;
const targetName: unknown = targetRecord["name"];
if (isString(targetName)) return targetName;
const targetId: unknown = targetRecord["id"];
return isString(targetId) ? targetId : undefined;
}

View File

@@ -8,8 +8,8 @@ import { checkerRegistry } from "./runner";
const CREATE_TARGETS_TABLE = `
CREATE TABLE IF NOT EXISTS targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
target TEXT NOT NULL,
config TEXT NOT NULL DEFAULT '{}',
@@ -23,7 +23,7 @@ CREATE TABLE IF NOT EXISTS targets (
const CREATE_CHECK_RESULTS_TABLE = `
CREATE TABLE IF NOT EXISTS check_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_id INTEGER NOT NULL,
target_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
matched INTEGER NOT NULL,
duration_ms REAL,
@@ -59,7 +59,7 @@ export class ProbeStore {
getAllRecentSamples(
limit: number,
): Map<number, Array<{ duration_ms: null | number; matched: number; timestamp: string }>> {
): Map<string, Array<{ duration_ms: null | number; matched: number; timestamp: string }>> {
if (this.closed) return new Map();
const rows = this.db
@@ -80,11 +80,11 @@ export class ProbeStore {
.all(limit) as Array<{
duration_ms: null | number;
matched: number;
target_id: number;
target_id: string;
timestamp: string;
}>;
const result = new Map<number, Array<{ duration_ms: null | number; matched: number; timestamp: string }>>();
const result = new Map<string, Array<{ duration_ms: null | number; matched: number; timestamp: string }>>();
for (const row of rows) {
const samples = result.get(row.target_id) ?? [];
samples.push({ duration_ms: row.duration_ms, matched: row.matched, timestamp: row.timestamp });
@@ -96,7 +96,7 @@ export class ProbeStore {
getAllTargetWindowStats(
from: string,
to: string,
): Map<number, { availability: number; downChecks: number; totalChecks: number; upChecks: number }> {
): Map<string, { availability: number; downChecks: number; totalChecks: number; upChecks: number }> {
if (this.closed) return new Map();
const rows = this.db
@@ -108,10 +108,10 @@ export class ProbeStore {
WHERE timestamp >= ? AND timestamp <= ?
GROUP BY target_id`,
)
.all(from, to) as Array<{ downChecks: number; target_id: number; totalChecks: number; upChecks: number }>;
.all(from, to) as Array<{ downChecks: number; target_id: string; totalChecks: number; upChecks: number }>;
const result = new Map<
number,
string,
{ availability: number; downChecks: number; totalChecks: number; upChecks: number }
>();
for (const row of rows) {
@@ -129,7 +129,7 @@ export class ProbeStore {
getDashboardIncidentStates(
from: string,
to: string,
): Array<{ matched: number; target_id: number; timestamp: string }> {
): Array<{ matched: number; target_id: string; timestamp: string }> {
if (this.closed) return [];
return this.db
@@ -139,11 +139,11 @@ export class ProbeStore {
WHERE timestamp >= ? AND timestamp <= ?
ORDER BY target_id ASC, timestamp ASC`,
)
.all(from, to) as Array<{ matched: number; target_id: number; timestamp: string }>;
.all(from, to) as Array<{ matched: number; target_id: string; timestamp: string }>;
}
getHistory(
targetId: number,
targetId: string,
from: string,
to: string,
page = 1,
@@ -163,13 +163,13 @@ export class ProbeStore {
return { items, page, pageSize, total: countRow.total };
}
getLatestCheck(targetId: number): null | StoredCheckResult {
getLatestCheck(targetId: string): null | StoredCheckResult {
return this.db
.query("SELECT * FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT 1")
.get(targetId) as null | StoredCheckResult;
}
getLatestChecksMap(): Map<number, StoredCheckResult> {
getLatestChecksMap(): Map<string, StoredCheckResult> {
const rows = this.db
.query(
`SELECT cr.* FROM check_results cr
@@ -184,7 +184,7 @@ export class ProbeStore {
}
getRecentSamples(
targetId: number,
targetId: string,
limit: number,
): Array<{ duration_ms: null | number; matched: number; timestamp: string }> {
return this.db
@@ -198,13 +198,13 @@ export class ProbeStore {
}>;
}
getTargetById(id: number): null | StoredTarget {
getTargetById(id: string): null | StoredTarget {
if (this.closed) return null;
return this.db.query("SELECT * FROM targets WHERE id = ?").get(id) as null | StoredTarget;
}
getTargetCheckpoints(
targetId: number,
targetId: string,
from: string,
to: string,
): Array<{ duration_ms: null | number; matched: number; timestamp: string }> {
@@ -220,7 +220,7 @@ export class ProbeStore {
.all(targetId, from, to) as Array<{ duration_ms: null | number; matched: number; timestamp: string }>;
}
getTargetDurations(targetId: number, from: string, to: string): number[] {
getTargetDurations(targetId: string, from: string, to: string): number[] {
if (this.closed) return [];
const rows = this.db
@@ -243,7 +243,7 @@ export class ProbeStore {
}
getTargetWindowStats(
targetId: number,
targetId: string,
from: string,
to: string,
): {
@@ -281,7 +281,7 @@ export class ProbeStore {
failure: CheckFailure | null;
matched: boolean;
statusDetail: null | string;
targetId: number;
targetId: string;
timestamp: string;
}): void {
if (this.closed) return;
@@ -308,18 +308,18 @@ export class ProbeStore {
syncTargets(targets: ResolvedTargetBase[]): void {
if (this.closed) return;
const existingRows = this.db.query("SELECT id, name FROM targets").all() as Array<{
id: number;
const existingRows = this.db.query("SELECT id FROM targets").all() as Array<{
id: string;
name: string;
}>;
const existingMap = new Map(existingRows.map((r) => [r.name, r.id]));
const configNames = new Set(targets.map((t) => t.name));
const existingIds = new Set(existingRows.map((r) => r.id));
const configIds = new Set(targets.map((t) => t.id));
const insertStmt = this.db.prepare(
"INSERT INTO targets (name, type, target, config, interval_ms, timeout_ms, expect, grp) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO targets (id, name, type, target, config, interval_ms, timeout_ms, expect, grp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
);
const updateStmt = this.db.prepare(
"UPDATE targets SET type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ?, grp = ? WHERE id = ?",
"UPDATE targets SET name = ?, type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ?, grp = ? WHERE id = ?",
);
const deleteStmt = this.db.prepare("DELETE FROM targets WHERE id = ?");
@@ -331,15 +331,15 @@ export class ProbeStore {
const config = serialized.config;
const expect = t.expect ? JSON.stringify(t.expect) : null;
if (existingMap.has(t.name)) {
updateStmt.run(type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, existingMap.get(t.name)!);
if (existingIds.has(t.id)) {
updateStmt.run(t.name, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, t.id);
} else {
insertStmt.run(t.name, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group);
insertStmt.run(t.id, t.name, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group);
}
}
for (const [name, id] of existingMap) {
if (!configNames.has(name)) {
for (const id of existingIds) {
if (!configIds.has(id)) {
deleteStmt.run(id);
}
}

View File

@@ -1,7 +1,7 @@
import type { CheckResult as ApiCheckResult, CheckFailure } from "../../shared/api";
export interface CheckResult extends ApiCheckResult {
targetName: string;
targetId: string;
}
export interface DefaultsConfig {
@@ -36,14 +36,16 @@ export interface ProbeConfig {
runtime?: EngineRuntimeConfig;
server?: ServerConfig;
targets: RawTargetConfig[];
variables?: Record<string, VariableValue>;
}
export interface RawTargetConfig {
[configKey: string]: unknown;
expect?: unknown;
group?: string;
id: string;
interval?: string;
name: string;
name?: string;
timeout?: string;
type: string;
}
@@ -52,6 +54,7 @@ export interface ResolvedTargetBase {
[key: string]: unknown;
expect?: unknown;
group: string;
id: string;
intervalMs: number;
name: string;
timeoutMs: number;
@@ -70,7 +73,7 @@ export interface StoredCheckResult {
id: number;
matched: number;
status_detail: null | string;
target_id: number;
target_id: string;
timestamp: string;
}
@@ -78,7 +81,7 @@ export interface StoredTarget {
config: string;
expect: null | string;
grp: string;
id: number;
id: string;
interval_ms: number;
name: string;
target: string;
@@ -86,4 +89,6 @@ export interface StoredTarget {
type: string;
}
export type VariableValue = boolean | number | string;
export type { CheckFailure };

View File

@@ -1,3 +1,5 @@
import { isNumber } from "es-toolkit";
const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/;
const SIZE_REGEX = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/;
@@ -20,7 +22,7 @@ export function parseDuration(value: string): number {
}
export function parseSize(value: number | string): number {
if (typeof value === "number") {
if (isNumber(value)) {
if (!Number.isInteger(value) || value < 0 || !Number.isSafeInteger(value)) {
throw new Error(`无效的 size 数值: ${value},必须为非负安全整数`);
}

View File

@@ -0,0 +1,205 @@
import { isBoolean, isNumber, isPlainObject, isString } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { ConfigValidationIssue } from "./schema/issues";
import type { VariableValue } from "./types";
import { issue, joinPath } from "./schema/issues";
const VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
const VARIABLE_REFERENCE_PATTERN = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?:\|([^}]*))?\}/g;
const COMPLETE_VARIABLE_REFERENCE_PATTERN = /^\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?:\|([^}]*))?\}$/;
const ESCAPED_VARIABLE_PATTERN = /\$\$\{([^}]*)\}/g;
interface VariableReference {
defaultValue?: string;
key: string;
}
interface VariableResolutionIssueContext {
path: string;
targetId?: string;
targetName?: string;
}
export function extractVariables(config: unknown): {
issues: ConfigValidationIssue[];
variables: Map<string, VariableValue>;
} {
const issues: ConfigValidationIssue[] = [];
const variables = new Map<string, VariableValue>();
if (!isPlainObject(config)) {
return { issues, variables };
}
const configRecord = config as Record<string, unknown>;
if (configRecord["variables"] === undefined) {
return { issues, variables };
}
const rawVariables: unknown = configRecord["variables"];
if (!isPlainObject(rawVariables)) {
issues.push(issue("invalid-type", "variables", "必须为对象"));
return { issues, variables };
}
for (const [key, value] of Object.entries(rawVariables as Record<string, unknown>)) {
const path = joinPath("variables", key);
if (!VARIABLE_NAME_PATTERN.test(key)) {
issues.push(issue("invalid-format", path, "变量名不符合命名规则"));
continue;
}
if (!isVariableValue(value)) {
issues.push(issue("invalid-type", path, `变量值不允许为 ${describeInvalidVariableValue(value)}`));
continue;
}
variables.set(key, value);
}
return { issues, variables };
}
export function resolveVariables(config: unknown): { config: unknown; issues: ConfigValidationIssue[] } {
const { issues, variables } = extractVariables(config);
if (!isPlainObject(config)) {
return { config, issues };
}
const configRecord = config as Record<string, unknown>;
const rawTargets: unknown = configRecord["targets"];
if (!isArray(rawTargets)) {
return { config, issues };
}
const targets = rawTargets.map((target, index) => resolveTargetVariables(target, index, variables, issues));
return { config: { ...config, targets }, issues };
}
function describeInvalidVariableValue(value: unknown): string {
if (value === null) return "null";
if (isArray(value)) return "array";
return typeof value;
}
function inferStringValue(value: string): VariableValue {
const numberValue = Number(value);
if (Number.isFinite(numberValue)) return numberValue;
if (value === "true") return true;
if (value === "false") return false;
return value;
}
function isVariableValue(value: unknown): value is VariableValue {
return isString(value) || isNumber(value) || isBoolean(value);
}
function parseVariableReference(match: RegExpExecArray): VariableReference {
return { defaultValue: match[2], key: match[1]! };
}
function replaceStringValue(
value: string,
variables: Map<string, VariableValue>,
issues: ConfigValidationIssue[],
context: VariableResolutionIssueContext,
): string | VariableValue {
const trimmed = value.trim();
const completeMatch = COMPLETE_VARIABLE_REFERENCE_PATTERN.exec(trimmed);
if (completeMatch) {
const resolved = resolveVariableReference(parseVariableReference(completeMatch), variables, issues, context);
return resolved ?? value;
}
const escaped: string[] = [];
const protectedValue = value.replace(ESCAPED_VARIABLE_PATTERN, (_match, body: string) => {
const token = `\u0000${escaped.length}\u0000`;
escaped.push(`\${${body}}`);
return token;
});
const replaced = protectedValue.replace(
VARIABLE_REFERENCE_PATTERN,
(match, key: string, defaultValue: string | undefined) => {
const resolved = resolveVariableReference({ defaultValue, key }, variables, issues, context);
return resolved === undefined ? match : String(resolved);
},
);
return escaped.reduce((result, literal, index) => result.replace(`\u0000${index}\u0000`, literal), replaced);
}
function resolveTargetVariables(
target: unknown,
index: number,
variables: Map<string, VariableValue>,
issues: ConfigValidationIssue[],
): unknown {
if (!isPlainObject(target)) return target;
const targetRecord = target as Record<string, unknown>;
const idValue: unknown = targetRecord["id"];
const nameValue: unknown = targetRecord["name"];
const targetId = isString(idValue) ? idValue : undefined;
const targetName = isString(nameValue) ? nameValue : targetId;
return resolveValue(target, `targets[${index}]`, variables, issues, {
path: `targets[${index}]`,
targetId,
targetName,
});
}
function resolveValue(
value: unknown,
path: string,
variables: Map<string, VariableValue>,
issues: ConfigValidationIssue[],
context: VariableResolutionIssueContext,
): unknown {
if (isString(value)) {
return replaceStringValue(value, variables, issues, { ...context, path });
}
if (isArray(value)) {
return value.map((item, index) =>
resolveValue(item, `${path}[${index}]`, variables, issues, { ...context, path: `${path}[${index}]` }),
);
}
if (!isPlainObject(value)) return value;
const result: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
const itemPath = joinPath(path, key);
result[key] =
key === "id" || key === "type"
? item
: resolveValue(item, itemPath, variables, issues, { ...context, path: itemPath });
}
return result;
}
function resolveVariableReference(
reference: VariableReference,
variables: Map<string, VariableValue>,
issues: ConfigValidationIssue[],
context: VariableResolutionIssueContext,
): undefined | VariableValue {
if (variables.has(reference.key)) {
return variables.get(reference.key);
}
if (Object.prototype.hasOwnProperty.call(process.env, reference.key)) {
return inferStringValue(process.env[reference.key] ?? "");
}
if (reference.defaultValue !== undefined) {
return inferStringValue(reference.defaultValue);
}
issues.push(
issue(
"unresolved-variable",
context.path,
`引用了未定义的变量 "${reference.key}",且环境变量中也不存在,未设置默认值`,
context.targetName,
context.targetId,
),
);
return undefined;
}

View File

@@ -69,12 +69,11 @@ export function validateRecentLimit(limitParam: null | string, mode: RuntimeMode
return { recentLimit };
}
export function validateTargetId(idStr: string, mode: RuntimeMode): Response | { id: number } {
const id = Number(idStr);
if (!Number.isInteger(id) || id <= 0) {
export function validateTargetId(idStr: string, mode: RuntimeMode): Response | { id: string } {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(idStr)) {
return jsonResponse(createApiError("Invalid target ID", 400), { mode, status: 400 });
}
return { id };
return { id: idStr };
}
export function validateTimeRange(

View File

@@ -88,9 +88,9 @@ export function handleDashboard(url: URL, store: ProbeStore, mode: RuntimeMode):
}
function groupDashboardIncidentStates(
states: Array<{ matched: number; target_id: number; timestamp: string }>,
): Map<number, MetricCheckpoint[]> {
const result = new Map<number, MetricCheckpoint[]>();
states: Array<{ matched: number; target_id: string; timestamp: string }>,
): Map<string, MetricCheckpoint[]> {
const result = new Map<string, MetricCheckpoint[]>();
for (const state of states) {
const list = result.get(state.target_id) ?? [];
list.push({ durationMs: null, matched: state.matched === 1, timestamp: state.timestamp });

View File

@@ -81,7 +81,7 @@ export interface TargetMetricsResponse {
totalChecks: number;
upChecks: number;
};
targetId: number;
targetId: string;
trend: TrendPoint[];
window: {
bucket: "1h";
@@ -100,7 +100,7 @@ export interface TargetStats {
export interface TargetStatus {
currentStreak: CurrentStreak | null;
group: string;
id: number;
id: string;
interval: string;
latestCheck: CheckResult | null;
name: string;

View File

@@ -9,6 +9,7 @@ import { TargetBoard } from "./components/TargetBoard";
import { TargetDetailDrawer } from "./components/TargetDetailDrawer";
import { useDashboard } from "./hooks/use-queries";
import { useTargetDetail } from "./hooks/use-target-detail";
import { type ThemePreference, useThemePreference } from "./hooks/use-theme-preference";
const { Content, Header } = Layout;
const DEFAULT_REFRESH_INTERVAL_MS = 30000;
@@ -24,9 +25,15 @@ const REFRESH_OPTIONS = [
{ label: "1分钟", value: 60000 },
{ label: "5分钟", value: 300000 },
] as const;
const THEME_OPTIONS = [
{ label: "系统", value: "system" },
{ label: "明亮", value: "light" },
{ label: "黑暗", value: "dark" },
] as const;
export function App() {
const [refreshInterval, setRefreshInterval] = useState(DEFAULT_REFRESH_INTERVAL_MS);
const { preference: themePreference, setPreference: setThemePreference } = useThemePreference();
const dashboardRefetchInterval = refreshInterval === 0 ? false : refreshInterval;
const {
data: dashboard,
@@ -58,6 +65,10 @@ export function App() {
setRefreshInterval(value);
};
const handleThemeChange = (value: ThemePreference) => {
setThemePreference(value);
};
return (
<Layout className="dashboard">
<Header>
@@ -69,7 +80,14 @@ export function App() {
</span>
}
operations={
<div className="dashboard-refresh-control">
<div className="dashboard-header-controls">
<RadioGroup
onChange={handleThemeChange}
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
theme="button"
value={themePreference}
variant="default-filled"
/>
<RadioGroup
onChange={handleIntervalChange}
options={REFRESH_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { Col, Descriptions, Divider, Row, Skeleton, Space, Statistic, Typography } from "tdesign-react";
import { Col, Descriptions, Divider, Row, Skeleton, Space, Statistic } from "tdesign-react";
import type { TargetMetricsResponse, TargetStatus } from "../../shared/api";
@@ -97,11 +97,8 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab
function OverviewStatItem({ color, suffix, title, value }: OverviewStatItemProps) {
return (
<div className="overview-stat-card">
<div className="overview-stat-item">
<Typography.Text theme="secondary">{title}</Typography.Text>
<Statistic className="overview-stat-value" color={color} suffix={suffix} value={value} />
</div>
<div className="overview-stat-card summary-stat-col">
<Statistic color={color} suffix={suffix} title={title} value={value} />
</div>
);
}

View File

@@ -1,9 +1,8 @@
import NumberFlow from "@number-flow/react";
import { useEffect, useState } from "react";
import { RefreshIcon } from "tdesign-icons-react";
import { Button, Typography } from "tdesign-react";
import { formatCountdown } from "../utils/time";
interface RefreshCountdownProps {
dashboardUpdatedAt: number;
isFetching: boolean;
@@ -45,8 +44,55 @@ export function RefreshCountdown({
);
}
const refreshText =
dashboardUpdatedAt > 0 ? (isFetching ? "刷新中..." : formatCountdown(nextRefreshSeconds ?? 0)) : "等待首次刷新";
if (dashboardUpdatedAt <= 0) {
return <Typography.Text theme="secondary"></Typography.Text>;
}
return <Typography.Text theme="secondary">{refreshText}</Typography.Text>;
if (isFetching) {
return <Typography.Text theme="secondary">...</Typography.Text>;
}
const seconds = nextRefreshSeconds ?? 0;
const isMinuteMode = refreshInterval >= 60000;
if (isMinuteMode) {
const mm = Math.floor(seconds / 60);
const ss = seconds % 60;
return (
<span aria-label={formatAccessibleLabel(seconds, true)} className="refresh-countdown-flow">
<NumberFlow
className="refresh-countdown-flow__number"
format={{ minimumIntegerDigits: 1 }}
trend={-1}
value={mm}
/>
<span className="refresh-countdown-flow__unit"></span>
<NumberFlow
className="refresh-countdown-flow__number"
digits={{ 1: { max: 5 } }}
format={{ minimumIntegerDigits: 2 }}
trend={-1}
value={ss}
/>
<span className="refresh-countdown-flow__unit"></span>
</span>
);
}
return (
<span aria-label={formatAccessibleLabel(seconds, false)} className="refresh-countdown-flow">
<NumberFlow className="refresh-countdown-flow__number" trend={-1} value={seconds} />
<span className="refresh-countdown-flow__unit"></span>
</span>
);
}
function formatAccessibleLabel(seconds: number, isMinuteMode: boolean): string {
if (isMinuteMode) {
const mm = Math.floor(seconds / 60);
const ss = seconds % 60;
return `${mm}${String(ss).padStart(2, "0")}`;
}
return `${seconds}`;
}

View File

@@ -1,6 +1,6 @@
import type { TabValue } from "tdesign-react";
import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { DateRangePicker, Drawer, RadioGroup, Space, Tabs, Tag, Typography } from "tdesign-react";
import type { HistoryResponse, TargetMetricsResponse, TargetStatus } from "../../shared/api";
@@ -72,13 +72,23 @@ export function TargetDetailDrawer({
const isUp = target?.latestCheck?.matched;
const dragLimits = useMemo(() => {
const viewportWidth = window.innerWidth;
const safeGap = 24;
return {
max: Math.min(1200, Math.max(360, viewportWidth - safeGap)),
min: 360,
};
}, []);
return (
<Drawer
attach="body"
className="target-detail-drawer"
footer={false}
header={
target ? (
<Space align="center" size={8}>
<Space align="center" size={12}>
<StatusDot up={!!isUp} />
<Typography.Text strong>{target.name}</Typography.Text>
<Tag size="small" theme="primary" variant="light-outline">
@@ -92,7 +102,8 @@ export function TargetDetailDrawer({
preventScrollThrough
showInAttachedElement={false}
showOverlay
size="55%"
size="var(--target-detail-drawer-width)"
sizeDraggable={dragLimits}
visible={!!target}
>
<Space className="full-width" direction="vertical" size={16}>

View File

@@ -5,7 +5,7 @@ import type { DashboardResponse, MetaResponse, TargetMetricsResponse } from "../
const queryKeys = {
dashboard: () => ["dashboard", "24h", 30] as const,
meta: () => ["meta"] as const,
metrics: (targetId: number, from: string, to: string, bucket: "1h") =>
metrics: (targetId: string, from: string, to: string, bucket: "1h") =>
["metrics", targetId, from, to, bucket] as const,
};
@@ -32,7 +32,7 @@ export function useMeta() {
});
}
export function useTargetMetrics(targetId: null | number, from: string, to: string, bucket: "1h") {
export function useTargetMetrics(targetId: null | string, from: string, to: string, bucket: "1h") {
return useQuery({
enabled: targetId !== null && !!from && !!to,
queryFn: () => {

View File

@@ -7,11 +7,11 @@ import { subtractHours } from "../utils/time";
import { fetchJson, useDashboard, useTargetMetrics } from "./use-queries";
const detailQueryKeys = {
history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
history: (targetId: string, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
};
export function useTargetDetail() {
const [selectedTargetId, setSelectedTargetId] = useState<null | number>(null);
const [selectedTargetId, setSelectedTargetId] = useState<null | string>(null);
const [timeFrom, setTimeFrom] = useState("");
const [timeTo, setTimeTo] = useState("");
const [historyPage, setHistoryPage] = useState(1);

View File

@@ -0,0 +1,73 @@
import { useEffect, useState } from "react";
export type EffectiveTheme = "dark" | "light";
export type ThemePreference = "dark" | "light" | "system";
export const THEME_PREFERENCE_STORAGE_KEY = "dial.theme.preference";
export const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)";
export function applyInitialThemePreference() {
applyThemeMode(resolveEffectiveTheme(readThemePreference(), getSystemPrefersDark()));
}
export function applyThemeMode(theme: EffectiveTheme, root: HTMLElement = document.documentElement) {
root.setAttribute("theme-mode", theme);
}
export function getSystemPrefersDark(matchMedia: Window["matchMedia"] = window.matchMedia): boolean {
try {
return matchMedia(THEME_MEDIA_QUERY).matches;
} catch {
return false;
}
}
export function parseThemePreference(value: unknown): ThemePreference {
return value === "dark" || value === "light" || value === "system" ? value : "system";
}
export function readThemePreference(storage: Storage = window.localStorage): ThemePreference {
try {
return parseThemePreference(storage.getItem(THEME_PREFERENCE_STORAGE_KEY));
} catch {
return "system";
}
}
export function resolveEffectiveTheme(preference: ThemePreference, systemPrefersDark: boolean): EffectiveTheme {
if (preference === "dark" || preference === "light") return preference;
return systemPrefersDark ? "dark" : "light";
}
export function useThemePreference() {
const [preference, setPreferenceState] = useState<ThemePreference>(() => readThemePreference());
const [systemPrefersDark, setSystemPrefersDark] = useState(() => getSystemPrefersDark());
const effectiveTheme = resolveEffectiveTheme(preference, systemPrefersDark);
useEffect(() => {
applyThemeMode(effectiveTheme);
}, [effectiveTheme]);
useEffect(() => {
const mediaQueryList = window.matchMedia(THEME_MEDIA_QUERY);
const handleChange = (event: MediaQueryListEvent) => setSystemPrefersDark(event.matches);
mediaQueryList.addEventListener("change", handleChange);
return () => mediaQueryList.removeEventListener("change", handleChange);
}, []);
const setPreference = (nextPreference: ThemePreference) => {
setPreferenceState(nextPreference);
writeThemePreference(nextPreference);
};
return { effectiveTheme, preference, setPreference };
}
export function writeThemePreference(preference: ThemePreference, storage: Storage = window.localStorage) {
try {
storage.setItem(THEME_PREFERENCE_STORAGE_KEY, preference);
} catch {
// 存储不可用时仅使用当前内存状态,避免阻断 Dashboard 渲染。
}
}

View File

@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
import { App } from "./app";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { applyInitialThemePreference } from "./hooks/use-theme-preference";
import "tdesign-react/dist/reset.css";
import "tdesign-react/dist/tdesign.min.css";
@@ -26,6 +27,8 @@ if (!rootElement) {
throw new Error("找不到前端挂载节点 #root");
}
applyInitialThemePreference();
createRoot(rootElement).render(
<StrictMode>
<ErrorBoundary>

View File

@@ -46,7 +46,7 @@
font-weight: 400;
}
.dashboard-refresh-control {
.dashboard-header-controls {
display: inline-flex;
align-items: center;
gap: var(--td-comp-margin-s);
@@ -63,6 +63,24 @@
white-space: nowrap;
}
.refresh-countdown-flow {
display: inline-flex;
align-items: baseline;
font-variant-numeric: tabular-nums;
white-space: nowrap;
color: var(--td-text-color-secondary);
}
.refresh-countdown-flow__number {
line-height: 0.85;
--number-flow-mask-height: 0.15em;
--number-flow-mask-width: 0.25em;
}
.refresh-countdown-flow__unit {
margin: 0 1px;
}
.status-dot {
display: inline-block;
width: 12px;
@@ -170,28 +188,46 @@
min-width: 360px;
}
.target-detail-drawer {
--target-detail-drawer-width: calc(100vw - 16px);
}
@media (min-width: 640px) {
.target-detail-drawer {
--target-detail-drawer-width: 86vw;
}
}
@media (min-width: 768px) {
.target-detail-drawer {
--target-detail-drawer-width: 82vw;
}
}
@media (min-width: 1024px) {
.target-detail-drawer {
--target-detail-drawer-width: 68vw;
}
}
@media (min-width: 1440px) {
.target-detail-drawer {
--target-detail-drawer-width: 58vw;
}
}
@media (min-width: 1920px) {
.target-detail-drawer {
--target-detail-drawer-width: min(50vw, 960px);
}
}
.overview-stat-card {
background: var(--td-bg-color-container-hover);
border-radius: var(--td-radius-default);
padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl);
}
.overview-stat-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--td-comp-margin-m);
}
.overview-stat-value {
font-size: var(--td-font-size-body-medium);
text-align: right;
}
.overview-stat-value .t-statistic-content {
font-size: var(--td-font-size-body-medium);
}
.summary-stat-col {
text-align: center;
}

View File

@@ -50,6 +50,7 @@ describe("API 路由", () => {
method: "GET",
url: "http://a.com",
},
id: "test-a",
intervalMs: 30000,
name: "test-a",
timeoutMs: 10000,
@@ -64,6 +65,7 @@ describe("API 路由", () => {
maxOutputBytes: 104857600,
},
group: "default",
id: "test-b",
intervalMs: 60000,
name: "test-b",
timeoutMs: 5000,
@@ -204,6 +206,7 @@ describe("API 路由", () => {
expect(body.targets).toHaveLength(2);
const tA = body.targets.find((t) => t.name === "test-a")!;
expect(tA.id).toBe("test-a");
expect(tA.type).toBe("http");
expect(tA.target).toBe("http://a.com");
expect(tA.group).toBe("default");
@@ -372,9 +375,9 @@ describe("API 路由", () => {
expect(body["error"]).toContain("from and to");
});
test("metrics 无效 targetId 返回 400", async () => {
test("metrics 无效 target id 返回 400", async () => {
const response = await fetch(
`${baseUrl}/api/targets/invalid/metrics?from=2024-01-01T00:00:00Z&to=2024-01-02T00:00:00Z`,
`${baseUrl}/api/targets/_invalid/metrics?from=2024-01-01T00:00:00Z&to=2024-01-02T00:00:00Z`,
);
const body = (await response.json()) as Record<string, unknown>;

View File

@@ -12,6 +12,7 @@ type ShutdownSignal = "SIGINT" | "SIGTERM";
const target: ResolvedTargetBase = {
group: "default",
id: "test",
intervalMs: 30000,
name: "test",
timeoutMs: 5000,

View File

@@ -28,6 +28,7 @@ describe("config contract", () => {
targets: [
{
http: { method: "get", unknownHttpField: true, url: "https://example.com" },
id: "api",
name: "api",
type: "http",
},
@@ -36,6 +37,31 @@ describe("config contract", () => {
).toBe(false);
});
test("导出 schema 支持 variables 且要求 target id", () => {
const ajv = new Ajv({
allErrors: true,
coerceTypes: false,
removeAdditional: false,
strict: true,
useDefaults: false,
});
const validate = ajv.compile(createProbeConfigJsonSchema(createDefaultCheckerRegistry()));
expect(
validate({
targets: [{ http: { url: "https://example.com" }, id: "api", type: "http" }],
variables: { base_url: "https://example.com", enabled: true, port: 443 },
}),
).toBe(true);
expect(
validate({
targets: [{ http: { url: "https://example.com" }, type: "http" }],
variables: { bad: null },
}),
).toBe(false);
});
test("Ajv 错误转换为中文结构化 issue", () => {
const result = validateProbeConfigContract(
{
@@ -43,6 +69,7 @@ describe("config contract", () => {
{
group: 123,
http: { extra: true },
id: "api",
name: "api",
type: "http",
},

View File

@@ -107,6 +107,7 @@ describe("loadConfig", () => {
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -121,6 +122,7 @@ describe("loadConfig", () => {
expect(config.targets).toHaveLength(1);
const t = config.targets[0]! as ResolvedHttpTarget;
expect(t.type).toBe("http");
expect(t.id).toBe("test");
expect(t.name).toBe("test");
expect(t.http.url).toBe("http://example.com");
expect(t.http.method).toBe("GET");
@@ -140,6 +142,7 @@ describe("loadConfig", () => {
configPath,
`targets:
- name: "check-nginx"
id: "check-nginx"
type: cmd
cmd:
exec: "pgrep"
@@ -151,12 +154,13 @@ describe("loadConfig", () => {
expect(config.targets).toHaveLength(1);
const t = config.targets[0]! as ResolvedCommandTarget;
expect(t.type).toBe("cmd");
expect(t.id).toBe("check-nginx");
expect(t.name).toBe("check-nginx");
expect(t.cmd.exec).toBe("pgrep");
expect(t.cmd.args).toEqual(["nginx"]);
expect(t.cmd.cwd).toBe(subdir);
expect(t.cmd.maxOutputBytes).toBe(104857600);
expect(t.cmd.env["PATH"]).toBeDefined();
expect(Object.keys(t.cmd.env).some((key) => key.toUpperCase() === "PATH")).toBe(true);
});
test("解析完整配置", async () => {
@@ -173,7 +177,6 @@ defaults:
interval: "15s"
timeout: "5s"
http:
method: "POST"
headers:
Authorization: "Bearer token"
maxBodyBytes: "50MB"
@@ -182,10 +185,12 @@ defaults:
maxOutputBytes: "10MB"
targets:
- name: "http-target"
id: "http-target"
type: http
interval: "1m"
http:
url: "http://example.com"
method: "POST"
ignoreSSL: true
maxRedirects: 5
expect:
@@ -193,6 +198,7 @@ targets:
body:
- contains: "ok"
- name: "cmd-target"
id: "cmd-target"
type: cmd
cmd:
exec: "ls"
@@ -228,15 +234,150 @@ targets:
expect(cmd.cmd.maxOutputBytes).toBe(10485760);
});
test("name 缺省时 fallback 到 id", async () => {
const configPath = join(tempDir, "name-fallback.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
const target = config.targets[0]!;
expect(target.id).toBe("api-health");
expect(target.name).toBe("api-health");
});
test("name 支持变量替换且不要求唯一", async () => {
const configPath = join(tempDir, "name-variable.yaml");
await writeFile(
configPath,
`variables:
env: "生产"
targets:
- id: "api-a"
name: "\${env} API"
type: http
http:
url: "http://a.example.com"
- id: "api-b"
name: "\${env} API"
type: http
http:
url: "http://b.example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets.map((target) => [target.id, target.name])).toEqual([
["api-a", "生产 API"],
["api-b", "生产 API"],
]);
});
test("包含 variables 的完整配置在 schema 校验前完成替换", async () => {
const configPath = join(tempDir, "variables-full.yaml");
await writeFile(
configPath,
`variables:
env: "生产"
base_url: "https://example.com"
ignore_ssl: true
max_redirects: 5
targets:
- id: "api-health"
name: "\${env} API 健康检查"
type: http
http:
url: "\${base_url}/health"
ignoreSSL: "\${ignore_ssl}"
maxRedirects: "\${max_redirects}"
`,
);
const config = await loadConfig(configPath);
const target = config.targets[0] as ResolvedHttpTarget;
expect(target.id).toBe("api-health");
expect(target.name).toBe("生产 API 健康检查");
expect(target.http.url).toBe("https://example.com/health");
expect(target.http.ignoreSSL).toBe(true);
expect(target.http.maxRedirects).toBe(5);
});
test("变量替换后类型不匹配导致 schema 校验失败", async () => {
const configPath = join(tempDir, "bad-var-type.yaml");
await writeFile(
configPath,
`variables:
max_redirects: "not-a-number"
targets:
- id: "bad-var"
type: http
http:
url: "http://example.com"
maxRedirects: "\${max_redirects}"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("maxRedirects");
});
test("变量替换后通过 schema 校验", async () => {
const configPath = join(tempDir, "good-var-type.yaml");
const origEnv = process.env["DIAL_VAR_MAX_REDIRECTS"];
process.env["DIAL_VAR_MAX_REDIRECTS"] = "3";
try {
await writeFile(
configPath,
`targets:
- id: "good-var"
type: http
http:
url: "http://example.com"
maxRedirects: "\${DIAL_VAR_MAX_REDIRECTS}"
`,
);
const config = await loadConfig(configPath);
const target = config.targets[0] as ResolvedHttpTarget;
expect(target.http.maxRedirects).toBe(3);
} finally {
if (origEnv === undefined) {
delete process.env["DIAL_VAR_MAX_REDIRECTS"];
} else {
process.env["DIAL_VAR_MAX_REDIRECTS"] = origEnv;
}
}
});
test("未定义变量且无默认值阻止启动", async () => {
const configPath = join(tempDir, "unresolved-var.yaml");
await writeFile(
configPath,
`targets:
- id: "unresolved"
type: http
http:
url: "\${MISSING_BASE_URL}/health"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("未定义的变量");
});
test("绝对 dataDir 保持不变", async () => {
const dataDir = join(tempDir, "absolute-data");
const configPath = join(tempDir, "absolute-data-dir.yaml");
await writeFile(
configPath,
`server:
dataDir: "${dataDir}"
dataDir: ${JSON.stringify(dataDir)}
targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -255,10 +396,10 @@ targets:
interval: "30s"
timeout: "10s"
http:
method: "GET"
maxBodyBytes: "10MB"
targets:
- name: "override-all"
id: "override-all"
type: http
interval: "5m"
timeout: "30s"
@@ -282,8 +423,8 @@ targets:
await expect(loadConfig("/nonexistent/file.yaml")).rejects.toThrow("配置文件不存在");
});
test("target 缺少 name 抛出错误", async () => {
const configPath = join(tempDir, "no-name.yaml");
test("target 缺少 id 抛出错误", async () => {
const configPath = join(tempDir, "no-id.yaml");
await writeFile(
configPath,
`targets:
@@ -293,7 +434,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("缺少 name 字段");
await expect(loadConfig(configPath)).rejects.toThrow("缺少 id 字段");
});
test("target 缺少 type 抛出错误", async () => {
@@ -302,6 +443,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
http:
url: "http://example.com"
`,
@@ -316,6 +458,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http: {}
`,
@@ -330,6 +473,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
`,
);
@@ -343,6 +487,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -359,6 +504,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -375,6 +521,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -392,6 +539,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: cmd
cmd: {}
`,
@@ -406,6 +554,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: dns
`,
);
@@ -413,23 +562,70 @@ targets:
await expect(loadConfig(configPath)).rejects.toThrow("不支持的 type");
});
test("target name 重复抛出错误", async () => {
const configPath = join(tempDir, "dup-name.yaml");
test("target id 重复抛出错误", async () => {
const configPath = join(tempDir, "dup-id.yaml");
await writeFile(
configPath,
`targets:
- name: "dup"
id: "dup"
type: http
http:
url: "http://a.com"
- name: "dup"
id: "dup"
type: http
http:
url: "http://b.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("target name 重复");
await expect(loadConfig(configPath)).rejects.toThrow("target id 重复");
});
test("target id 为空字符串抛出错误", async () => {
const configPath = join(tempDir, "empty-id.yaml");
await writeFile(
configPath,
`targets:
- id: ""
type: http
http:
url: "http://example.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("缺少 id 字段");
});
test("target id 命名不合法抛出错误", async () => {
const configPath = join(tempDir, "bad-id.yaml");
await writeFile(
configPath,
`targets:
- id: "_invalid"
type: http
http:
url: "http://example.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("id 不符合命名规则");
});
test("target id 包含下划线和连字符通过", async () => {
const configPath = join(tempDir, "id-underscore-dash.yaml");
await writeFile(
configPath,
`targets:
- id: "db_check-01"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.id).toBe("db_check-01");
});
test("targets 为空数组抛出错误", async () => {
@@ -447,6 +643,7 @@ targets:
port: 99999
targets:
- name: "t"
id: "t"
type: http
http:
url: "http://a.com"
@@ -464,6 +661,7 @@ targets:
maxConcurrentChecks: -1
targets:
- name: "t"
id: "t"
type: http
http:
url: "http://a.com"
@@ -482,6 +680,7 @@ targets:
maxBodyBytes: "100TB"
targets:
- name: "t"
id: "t"
type: http
http:
url: "http://a.com"
@@ -497,6 +696,7 @@ targets:
configPath,
`targets:
- name: "t"
id: "t"
type: http
interval: "30x"
http:
@@ -513,6 +713,7 @@ targets:
configPath,
`targets:
- name: "with-expect"
id: "with-expect"
type: http
http:
url: "http://example.com"
@@ -544,6 +745,7 @@ targets:
configPath,
`targets:
- name: "cmd-with-expect"
id: "cmd-with-expect"
type: cmd
cmd:
exec: "mycheck"
@@ -578,6 +780,7 @@ targets:
configPath,
`targets:
- name: "cwd-test"
id: "cwd-test"
type: cmd
cmd:
exec: "ls"
@@ -596,6 +799,7 @@ targets:
configPath,
`targets:
- name: "env-test"
id: "env-test"
type: cmd
cmd:
exec: "echo"
@@ -609,7 +813,7 @@ targets:
const t = config.targets[0] as ResolvedCommandTarget;
expect(t.cmd.env["LANG"]).toBe("C");
expect(t.cmd.env["CUSTOM_VAR"]).toBe("test");
expect(t.cmd.env["PATH"]).toBeDefined();
expect(Object.keys(t.cmd.env).some((key) => key.toUpperCase() === "PATH")).toBe(true);
});
test("解析 group 字段", async () => {
@@ -618,6 +822,7 @@ targets:
configPath,
`targets:
- name: "grouped"
id: "grouped"
type: http
group: "搜索引擎"
http:
@@ -635,6 +840,7 @@ targets:
configPath,
`targets:
- name: "no-group"
id: "no-group"
type: http
http:
url: "http://example.com"
@@ -651,6 +857,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
group: 123
http:
@@ -668,6 +875,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -685,6 +893,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -701,6 +910,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -717,6 +927,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -733,6 +944,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -750,6 +962,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -767,6 +980,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -784,6 +998,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -801,6 +1016,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -819,6 +1035,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -837,6 +1054,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -856,6 +1074,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -874,6 +1093,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -894,6 +1114,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -913,6 +1134,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -932,6 +1154,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -952,6 +1175,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -972,6 +1196,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -994,6 +1219,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -1013,6 +1239,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -1029,6 +1256,7 @@ targets:
"lowercase-method.yaml",
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -1038,19 +1266,20 @@ targets:
);
});
test("defaults.http.method 小写输入失败", async () => {
test("defaults.http.method 触发未知字段错误", async () => {
await expectConfigError(
"lowercase-default-method.yaml",
"unknown-default-method.yaml",
`defaults:
http:
method: post
method: POST
targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
`,
"defaults.http.method 不在允许范围内",
"defaults.http.method 是未知字段",
);
});
@@ -1060,6 +1289,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -1082,6 +1312,7 @@ targets:
X-Default-Header: "default"
targets:
- name: "http-test"
id: "http-test"
type: http
http:
url: "http://example.com"
@@ -1092,6 +1323,7 @@ targets:
X-Response-Header:
contains: "ok"
- name: "cmd-test"
id: "cmd-test"
type: cmd
cmd:
exec: "true"
@@ -1114,6 +1346,7 @@ targets:
"bad-cmd-args.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1128,6 +1361,7 @@ targets:
"bad-cmd-cwd.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1142,6 +1376,7 @@ targets:
"bad-cmd-env.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1157,6 +1392,7 @@ targets:
"bad-cmd-max-output.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1171,6 +1407,7 @@ targets:
"bad-cmd-exit-code.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1186,6 +1423,7 @@ targets:
"bad-cmd-stdout-empty.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1202,6 +1440,7 @@ targets:
"bad-cmd-stderr-operator.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1218,6 +1457,7 @@ targets:
"bad-cmd-stdout-regex.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1234,6 +1474,7 @@ targets:
"bad-cmd-expect-unknown.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1250,6 +1491,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -1267,6 +1509,7 @@ targets:
retention: "24h"
targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -1283,6 +1526,7 @@ targets:
retention: "7x"
targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"

View File

@@ -15,8 +15,7 @@ const processEnv = Object.fromEntries(
);
function createMockStore(targetNames: string[]) {
let nextId = 1;
const targets = targetNames.map((name) => ({ id: nextId++, name }));
const targets = targetNames.map((name) => ({ id: name, name }));
const results: Array<Record<string, unknown>> = [];
return {
@@ -57,6 +56,7 @@ function makeCommandTarget(name: string, overrides?: Partial<ResolvedCommandTarg
maxOutputBytes: 1024 * 1024,
},
group: "default",
id: name,
intervalMs: 60000,
name,
timeoutMs: 5000,
@@ -177,7 +177,7 @@ describe("ProbeEngine", () => {
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
expect(results.length).toBe(2);
expect(results[0]!["targetId"]).toBe(1);
expect(results[0]!["targetId"]).toBe("reject-cmd");
expect(results[0]!["matched"]).toBe(false);
expect(results[0]!["durationMs"]).toBeNull();
expect(results[0]!["statusDetail"]).toBeNull();
@@ -188,7 +188,7 @@ describe("ProbeEngine", () => {
phase: "internal",
});
expect(typeof results[0]!["timestamp"]).toBe("string");
expect(results[1]!["targetId"]).toBe(2);
expect(results[1]!["targetId"]).toBe("good-cmd");
expect(results[1]!["matched"]).toBe(true);
} finally {
checker.execute = originalExecute;
@@ -235,7 +235,7 @@ describe("ProbeEngine", () => {
expect(true).toBe(true);
});
test("未注册的 targetName 不写入结果", async () => {
test("未注册的 target id 不写入结果", async () => {
const target = makeCommandTarget("unknown-target");
const mockStore = createMockStore(["other-name"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [target]);
@@ -268,6 +268,7 @@ describe("ProbeEngine", () => {
method: "GET",
url: `http://localhost:${httpServer.port}/`,
},
id: "http-test",
intervalMs: 60000,
name: "http-test",
timeoutMs: 5000,

View File

@@ -31,6 +31,7 @@ function makeTarget(
...cmd,
},
group: "default",
id: "test-cmd",
intervalMs: 60000,
name: "test-cmd",
timeoutMs: 5000,

View File

@@ -0,0 +1,159 @@
import { describe, expect, test } from "bun:test";
import type { ResolvedDbTarget } from "../../../../../src/server/checker/runner/db/types";
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
import { DbChecker } from "../../../../../src/server/checker/runner/db/execute";
const checker = new DbChecker();
function makeCtx(timeoutMs = 5000): CheckerContext {
const controller = new AbortController();
setTimeout(() => controller.abort(), timeoutMs);
return { signal: controller.signal };
}
function makeTarget(db: Partial<ResolvedDbTarget["db"]>, overrides?: Partial<ResolvedDbTarget>): ResolvedDbTarget {
return {
db: {
url: "sqlite://:memory:",
...db,
},
group: "default",
id: "test-db",
intervalMs: 60000,
name: "test-db",
timeoutMs: 5000,
type: "db",
...overrides,
};
}
describe("DbChecker", () => {
test("无 query 时仅测试连接成功", async () => {
const result = await checker.execute(makeTarget({}), makeCtx());
expect(result.matched).toBe(true);
expect(result.statusDetail).toBe("connected");
expect(result.failure).toBeNull();
});
test("执行查询成功", async () => {
const result = await checker.execute(makeTarget({ query: "SELECT 1 as num, 'hello' as str" }), makeCtx());
expect(result.matched).toBe(true);
expect(result.statusDetail).toBe("1 rows");
expect(result.durationMs).toBeGreaterThanOrEqual(0);
});
test("查询返回多行", async () => {
const result = await checker.execute(
makeTarget({ query: "SELECT 1 as n UNION ALL SELECT 2 UNION ALL SELECT 3" }),
makeCtx(),
);
expect(result.matched).toBe(true);
expect(result.statusDetail).toBe("3 rows");
});
test("查询返回空结果", async () => {
const result = await checker.execute(makeTarget({ query: "SELECT 1 as n WHERE 1=0" }), makeCtx());
expect(result.matched).toBe(true);
expect(result.statusDetail).toBe("0 rows");
});
test("连接失败返回 connect phase 错误", async () => {
const result = await checker.execute(makeTarget({ url: "sqlite:///nonexistent/path/db.db" }), makeCtx());
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("connect");
expect(result.failure!.message).toBeTruthy();
});
test("SQL 语法错误返回 query phase 错误", async () => {
const result = await checker.execute(makeTarget({ query: "SELECT INVALID SQL" }), makeCtx());
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("query");
expect(result.failure!.message).toBeTruthy();
});
test("maxDurationMs 超时返回失败", async () => {
const result = await checker.execute(
makeTarget({ query: "SELECT 1" }, { expect: { maxDurationMs: -1 } }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("duration");
});
test("rowCount 断言通过", async () => {
const result = await checker.execute(
makeTarget({ query: "SELECT 1 UNION ALL SELECT 2" }, { expect: { rowCount: { gte: 2 } } }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("rowCount 断言失败", async () => {
const result = await checker.execute(
makeTarget({ query: "SELECT 1 UNION ALL SELECT 2" }, { expect: { rowCount: { gte: 5 } } }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("rowCount");
});
test("rows 断言通过(字面量形式)", async () => {
const result = await checker.execute(
makeTarget({ query: "SELECT 100 as cnt" }, { expect: { rows: [{ cnt: 100 }] } }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("rows 断言通过operator 形式)", async () => {
const result = await checker.execute(
makeTarget({ query: "SELECT 150 as cnt" }, { expect: { rows: [{ cnt: { gte: 100 } }] } }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("rows 断言失败", async () => {
const result = await checker.execute(
makeTarget({ query: "SELECT 50 as cnt" }, { expect: { rows: [{ cnt: { gte: 100 } }] } }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("row");
expect(result.failure!.path).toBe("rows[0].cnt");
});
test("rows 结果行数不足", async () => {
const result = await checker.execute(
makeTarget({ query: "SELECT 1 as n" }, { expect: { rows: [{ n: 1 }, { n: 2 }] } }),
makeCtx(),
);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("row");
expect(result.failure!.message).toContain("行数不足");
});
test("rows 只检查声明的列", async () => {
const result = await checker.execute(
makeTarget({ query: "SELECT 1 as cnt, 'ignored' as other" }, { expect: { rows: [{ cnt: { gte: 1 } }] } }),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("serialize 屏蔽凭据", () => {
const target = makeTarget({ url: "postgres://user:pass@host:5432/db" });
const s = checker.serialize(target);
expect(s.target).toBe("postgres://***:***@host:5432/db");
const config = JSON.parse(s.config) as { url: string };
expect(config.url).toBe("postgres://***:***@host:5432/db");
});
test("serialize 无凭据的 url 保持原样", () => {
const target = makeTarget({ url: "sqlite:///data/app.db" });
const s = checker.serialize(target);
expect(s.target).toBe("sqlite:///data/app.db");
});
});

View File

@@ -0,0 +1,134 @@
import { describe, expect, test } from "bun:test";
import { checkRowCount, checkRows } from "../../../../../src/server/checker/runner/db/expect";
describe("checkRowCount", () => {
test("空数组通过 rowCount gte 0", () => {
const result = checkRowCount([], { gte: 0 });
expect(result.matched).toBe(true);
expect(result.failure).toBeNull();
});
test("非数组视为 0 行", () => {
const result = checkRowCount(null, { gte: 0 });
expect(result.matched).toBe(true);
});
test("rowCount gte 通过", () => {
const result = checkRowCount([1, 2, 3], { gte: 3 });
expect(result.matched).toBe(true);
});
test("rowCount gte 失败", () => {
const result = checkRowCount([1, 2], { gte: 3 });
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("rowCount");
expect(result.failure!.path).toBe("rowCount");
});
test("rowCount equals 通过", () => {
const result = checkRowCount([1, 2, 3], { equals: 3 });
expect(result.matched).toBe(true);
});
test("rowCount equals 失败", () => {
const result = checkRowCount([1, 2, 3], { equals: 5 });
expect(result.matched).toBe(false);
});
});
describe("checkRows", () => {
test("非数组返回失败", () => {
const result = checkRows(null, [{ col: 1 }]);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("row");
expect(result.failure!.path).toBe("rows");
});
test("空规则通过", () => {
const result = checkRows([], []);
expect(result.matched).toBe(true);
});
test("单行单列匹配(字面量)", () => {
const result = checkRows([{ col: "value" }], [{ col: "value" }]);
expect(result.matched).toBe(true);
});
test("单行单列匹配operator", () => {
const result = checkRows([{ col: 100 }], [{ col: { gte: 50 } }]);
expect(result.matched).toBe(true);
});
test("单行单列不匹配", () => {
const result = checkRows([{ col: 10 }], [{ col: { gte: 50 } }]);
expect(result.matched).toBe(false);
expect(result.failure!.phase).toBe("row");
expect(result.failure!.path).toBe("rows[0].col");
});
test("多行多列全部匹配", () => {
const result = checkRows(
[
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
],
[{ id: { gte: 1 } }, { name: "Bob" }],
);
expect(result.matched).toBe(true);
});
test("多行中有一行不匹配", () => {
const result = checkRows([{ col: 1 }, { col: 2 }], [{ col: { gte: 2 } }, { col: { gte: 3 } }]);
expect(result.matched).toBe(false);
// 第一行 { col: 1 } 不满足 { gte: 2 },所以失败在第一行
expect(result.failure!.path).toBe("rows[0].col");
});
test("结果行数不足", () => {
const result = checkRows([{ col: 1 }], [{ col: 1 }, { col: 2 }]);
expect(result.matched).toBe(false);
expect(result.failure!.message).toContain("行数不足");
});
test("只检查声明的列", () => {
const result = checkRows([{ col: 1, other: "ignored" }], [{ col: { gte: 0 } }]);
expect(result.matched).toBe(true);
});
test("行不是对象返回失败", () => {
const result = checkRows(["not-an-object"] as unknown[], [{ col: 1 }]);
expect(result.matched).toBe(false);
expect(result.failure!.path).toBe("rows[0]");
});
test("列不存在视为 undefined", () => {
const result = checkRows([{}], [{ col: { exists: false } }]);
expect(result.matched).toBe(true);
});
test("列存在且值为 null", () => {
const result = checkRows([{ col: null }], [{ col: { empty: true } }]);
expect(result.matched).toBe(true);
});
test("contains 匹配字符串", () => {
const result = checkRows([{ text: "hello world" }], [{ text: { contains: "hello" } }]);
expect(result.matched).toBe(true);
});
test("match 正则匹配", () => {
const result = checkRows([{ code: "ABC-123" }], [{ code: { match: "^ABC-" } }]);
expect(result.matched).toBe(true);
});
test("多个断言同时满足", () => {
const result = checkRows([{ val: 50 }], [{ val: { gte: 10, lte: 100 } }]);
expect(result.matched).toBe(true);
});
test("多个断言中有一个不满足", () => {
const result = checkRows([{ val: 50 }], [{ val: { gte: 10, lte: 30 } }]);
expect(result.matched).toBe(false);
});
});

View File

@@ -0,0 +1,170 @@
import { describe, expect, test } from "bun:test";
import { validateDbConfig } from "../../../../../src/server/checker/runner/db/validate";
describe("validateDbConfig", () => {
test("空配置无问题", () => {
const result = validateDbConfig({ defaults: {}, targets: [] });
expect(result).toHaveLength(0);
});
test("缺少 db.url 返回错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [{ id: "test", name: "test", type: "db" }],
});
expect(result.length).toBeGreaterThan(0);
const dbError = result.find((e) => e.path.includes("db"));
expect(dbError).toBeDefined();
expect(dbError!.code).toBe("required");
});
test("db.url 为空字符串返回错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [{ db: { url: "" }, id: "test", name: "test", type: "db" }],
});
const urlError = result.find((e) => e.path.includes("db.url"));
expect(urlError).toBeDefined();
expect(urlError!.code).toBe("required");
});
test("db.query 为空字符串返回错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [{ db: { query: "", url: "sqlite://:memory:" }, id: "test", name: "test", type: "db" }],
});
const queryError = result.find((e) => e.path.includes("db.query"));
expect(queryError).toBeDefined();
expect(queryError!.code).toBe("invalid-value");
});
test("db 分组未知字段返回错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [{ db: { timeout: 5, url: "sqlite://:memory:" }, id: "test", name: "test", type: "db" }],
});
const unknownError = result.find((e) => e.path.includes("db.timeout"));
expect(unknownError).toBeDefined();
expect(unknownError!.code).toBe("unknown-field");
});
test("expect.maxDurationMs 非数字返回错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [
{
db: { url: "sqlite://:memory:" },
expect: { maxDurationMs: "invalid" },
id: "test",
name: "test",
type: "db",
},
],
});
const durationError = result.find((e) => e.path.includes("expect.maxDurationMs"));
expect(durationError).toBeDefined();
expect(durationError!.code).toBe("invalid-type");
});
test("expect.rowCount 非法 operator 返回错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [
{ db: { url: "sqlite://:memory:" }, expect: { rowCount: { foo: 1 } }, id: "test", name: "test", type: "db" },
],
});
const rowCountError = result.find((e) => e.path.includes("expect.rowCount"));
expect(rowCountError).toBeDefined();
expect(rowCountError!.code).toBe("unknown-operator");
});
test("expect.rows 不是数组返回错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [
{ db: { url: "sqlite://:memory:" }, expect: { rows: "not-array" }, id: "test", name: "test", type: "db" },
],
});
const rowsError = result.find((e) => e.path.includes("expect.rows"));
expect(rowsError).toBeDefined();
expect(rowsError!.code).toBe("invalid-type");
});
test("expect.rows 元素不是对象返回错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [
{ db: { url: "sqlite://:memory:" }, expect: { rows: ["not-object"] }, id: "test", name: "test", type: "db" },
],
});
const rowError = result.find((e) => e.path.includes("expect.rows[0]"));
expect(rowError).toBeDefined();
expect(rowError!.code).toBe("invalid-type");
});
test("expect.rows 中 match 正则非法返回错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [
{
db: { url: "sqlite://:memory:" },
expect: { rows: [{ name: { match: "[invalid" } }] },
id: "test",
name: "test",
type: "db",
},
],
});
const matchError = result.find((e) => e.path.includes("expect.rows[0].name"));
expect(matchError).toBeDefined();
expect(matchError!.code).toBe("invalid-regex");
});
test("expect 未知字段返回错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [{ db: { url: "sqlite://:memory:" }, expect: { status: [200] }, id: "test", name: "test", type: "db" }],
});
const unknownError = result.find((e) => e.path.includes("expect.status"));
expect(unknownError).toBeDefined();
expect(unknownError!.code).toBe("unknown-field");
});
test("有效配置无错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [
{
db: { query: "SELECT 1", url: "sqlite://:memory:" },
expect: { maxDurationMs: 5000, rowCount: { gte: 1 }, rows: [{ cnt: { gte: 1 } }] },
id: "test",
name: "test",
type: "db",
},
],
});
expect(result).toHaveLength(0);
});
test("忽略非 db 类型 target", () => {
const result = validateDbConfig({
defaults: {},
targets: [{ id: "test", name: "test", type: "http" }],
});
expect(result).toHaveLength(0);
});
test("多个 db target 分别校验", () => {
const result = validateDbConfig({
defaults: {},
targets: [
{ db: { url: "sqlite://:memory:" }, id: "db1", name: "db1", type: "db" },
{ db: { url: "" }, id: "db2", name: "db2", type: "db" },
],
});
expect(result.length).toBeGreaterThan(0);
const db2Error = result.find((e) => e.targetName === "db2");
expect(db2Error).toBeDefined();
});
});

View File

@@ -166,6 +166,7 @@ describe("HttpChecker", () => {
method: overrides.method ?? "GET",
url: overrides.url ?? `${baseUrl}/ok`,
},
id: "test-http",
intervalMs: 60000,
name: "test-http",
timeoutMs: overrides.timeoutMs ?? 5000,
@@ -850,6 +851,7 @@ describe("HttpChecker.resolve", () => {
const errors = validateHttpTarget({
expect: { status: ["abc"] },
http: { url: "https://example.com" },
id: "test",
name: "test",
type: "http",
});
@@ -858,7 +860,7 @@ describe("HttpChecker.resolve", () => {
test("ignoreSSL 默认值为 false", () => {
const result = checker.resolve(
{ http: { url: "https://example.com" }, name: "test", type: "http" },
{ http: { url: "https://example.com" }, id: "test", name: "test", type: "http" },
makeResolveContext(),
);
expect(result.http.ignoreSSL).toBe(false);
@@ -866,7 +868,7 @@ describe("HttpChecker.resolve", () => {
test("maxRedirects 默认值为 0", () => {
const result = checker.resolve(
{ http: { url: "https://example.com" }, name: "test", type: "http" },
{ http: { url: "https://example.com" }, id: "test", name: "test", type: "http" },
makeResolveContext(),
);
expect(result.http.maxRedirects).toBe(0);
@@ -874,7 +876,13 @@ describe("HttpChecker.resolve", () => {
test("合法 status 范围模式通过校验", () => {
const result = checker.resolve(
{ expect: { status: ["2xx", 301] }, http: { url: "https://example.com" }, name: "test", type: "http" },
{
expect: { status: ["2xx", 301] },
http: { url: "https://example.com" },
id: "test",
name: "test",
type: "http",
},
makeResolveContext(),
);
expect(result.expect?.status).toEqual(["2xx", 301]);
@@ -882,7 +890,12 @@ describe("HttpChecker.resolve", () => {
test("显式 ignoreSSL 和 maxRedirects 正确解析", () => {
const result = checker.resolve(
{ http: { ignoreSSL: true, maxRedirects: 3, url: "https://example.com" }, name: "test", type: "http" },
{
http: { ignoreSSL: true, maxRedirects: 3, url: "https://example.com" },
id: "test",
name: "test",
type: "http",
},
makeResolveContext(),
);
expect(result.http.ignoreSSL).toBe(true);

View File

@@ -66,8 +66,8 @@ describe("CheckerRegistry", () => {
const second = createDefaultCheckerRegistry();
first.register(createChecker("custom"));
expect(first.supportedTypes).toEqual(["http", "cmd", "custom"]);
expect(second.supportedTypes).toEqual(["http", "cmd"]);
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "custom"]);
expect(second.supportedTypes).toEqual(["http", "cmd", "db"]);
expect(
first.definitions.every(
(checker) => checker.schemas.config && checker.schemas.defaults && checker.schemas.expect,

View File

@@ -24,6 +24,10 @@ beforeAll(() => {
ensureRegistered();
});
function targetId(store: ProbeStore, name: string): string {
return store.getTargets().find((target) => target.name === name)!.id;
}
const httpTarget: ResolvedHttpTarget = {
expect: { maxDurationMs: 3000, status: [200] },
group: "default",
@@ -35,6 +39,7 @@ const httpTarget: ResolvedHttpTarget = {
method: "GET",
url: "https://example.com/health",
},
id: "test-http",
intervalMs: 30000,
name: "test-http",
timeoutMs: 10000,
@@ -50,6 +55,7 @@ const commandTarget: ResolvedCommandTarget = {
maxOutputBytes: 104857600,
},
group: "default",
id: "test-cmd",
intervalMs: 60000,
name: "test-cmd",
timeoutMs: 5000,
@@ -79,8 +85,7 @@ describe("ProbeStore", () => {
store.syncTargets([httpTarget, commandTarget]);
const targets = store.getTargets();
expect(targets).toHaveLength(2);
expect(targets[0]!.name).toBe("test-http");
expect(targets[1]!.name).toBe("test-cmd");
expect(targets.map((target) => target.name).sort()).toEqual(["test-cmd", "test-http"]);
});
test("http target 字段正确", () => {
@@ -144,20 +149,18 @@ describe("ProbeStore", () => {
});
test("getTargetById", () => {
const targets = store.getTargets();
const found = store.getTargetById(targets[0]!.id);
const found = store.getTargetById(targetId(store, "test-http"));
expect(found).toBeDefined();
expect(found!.name).toBe("test-http");
});
test("getTargetById 不存在", () => {
expect(store.getTargetById(99999)).toBeNull();
expect(store.getTargetById("missing-target")).toBeNull();
});
test("写入 check result 并查询", () => {
store.syncTargets([httpTarget, commandTarget]);
const targets = store.getTargets();
const t1Id = targets[0]!.id;
const t1Id = targetId(store, "test-http");
store.insertCheckResult({
durationMs: 150.5,
@@ -209,8 +212,7 @@ describe("ProbeStore", () => {
});
test("getHistory 默认 limit=20", () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
const t1Id = targetId(store, "test-http");
for (let i = 0; i < 25; i++) {
store.insertCheckResult({
@@ -228,8 +230,7 @@ describe("ProbeStore", () => {
});
test("getTargetWindowStats 按时间窗口计算基础计数", () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
const t1Id = targetId(store, "test-http");
const stats = store.getTargetWindowStats(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(stats.totalChecks).toBeGreaterThan(0);
@@ -239,8 +240,7 @@ describe("ProbeStore", () => {
});
test("无记录目标的窗口 stats", () => {
const targets = store.getTargets();
const t2Id = targets.find((t) => t.name === "test-cmd")!.id;
const t2Id = targetId(store, "test-cmd");
const stats = store.getTargetWindowStats(t2Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(stats.totalChecks).toBe(0);
@@ -251,16 +251,14 @@ describe("ProbeStore", () => {
test("getLatestChecksMap 支持 Dashboard 组装当前状态", () => {
const latestChecksMap = store.getLatestChecksMap();
const targets = store.getTargets();
const latest = latestChecksMap.get(targets[0]!.id);
const latest = latestChecksMap.get(targetId(store, "test-http"));
expect(latest).toBeDefined();
expect(latest!.timestamp).toBe("2025-01-01T01:24:00.000Z");
});
test("getTargetCheckpoints 返回窗口内升序检查点", () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
const t1Id = targetId(store, "test-http");
const checkpoints = store.getTargetCheckpoints(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(checkpoints).toEqual([
@@ -271,16 +269,14 @@ describe("ProbeStore", () => {
});
test("getTargetDurations 返回成功检查耗时升序数组", () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
const t1Id = targetId(store, "test-http");
const durations = store.getTargetDurations(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(durations).toEqual([150.5, 300]);
});
test("getRecentSamples 返回最近采样数据", () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
const t1Id = targetId(store, "test-http");
const samples = store.getRecentSamples(t1Id, 10);
expect(Array.isArray(samples)).toBe(true);
@@ -293,15 +289,17 @@ describe("ProbeStore", () => {
test("getAllRecentSamples 返回每个 target 的最近采样数据", () => {
const sampleStore = new ProbeStore(join(tempDir, "all-samples.db"));
const httpA: ResolvedHttpTarget = { ...httpTarget, name: "sample-http-a" };
const httpA: ResolvedHttpTarget = { ...httpTarget, id: "sample-http-a", name: "sample-http-a" };
const httpB: ResolvedHttpTarget = {
...httpTarget,
http: { ...httpTarget.http, url: "https://example.com/other" },
id: "sample-http-b",
name: "sample-http-b",
};
const httpEmpty: ResolvedHttpTarget = {
...httpTarget,
http: { ...httpTarget.http, url: "https://example.com/empty" },
id: "sample-http-empty",
name: "sample-http-empty",
};
sampleStore.syncTargets([httpA, httpB, httpEmpty]);
@@ -360,7 +358,7 @@ describe("ProbeStore", () => {
const closedStore = new ProbeStore(join(tempDir, "closed.db"));
closedStore.close();
expect(closedStore.getTargets()).toHaveLength(0);
expect(closedStore.getTargetById(1)).toBeNull();
expect(closedStore.getTargetById("closed-target")).toBeNull();
});
test("删除 target 级联删除 check_results", () => {
@@ -375,6 +373,7 @@ describe("ProbeStore", () => {
method: "GET",
url: "http://cascade.test",
},
id: "cascade-test",
intervalMs: 30000,
name: "cascade-test",
timeoutMs: 10000,
@@ -437,6 +436,7 @@ describe("ProbeStore", () => {
method: "GET",
url: "http://no.records",
},
id: "no-records",
intervalMs: 30000,
name: "no-records",
timeoutMs: 10000,
@@ -451,9 +451,8 @@ describe("ProbeStore", () => {
});
test("getAllTargetWindowStats 返回所有 target 的窗口聚合统计", () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
const t2Id = targets[1]!.id;
const t1Id = targetId(store, "test-http");
const t2Id = targetId(store, "test-cmd");
const stats = store.getAllTargetWindowStats("2024-01-01T00:00:00.000Z", "2026-12-31T23:59:59.999Z");
expect(stats).toBeInstanceOf(Map);
@@ -484,6 +483,7 @@ describe("ProbeStore", () => {
method: "GET",
url: "http://no.stats",
},
id: "no-stats",
intervalMs: 30000,
name: "no-stats",
timeoutMs: 10000,
@@ -499,7 +499,7 @@ describe("ProbeStore", () => {
test("getAllTargetWindowStats 与 getTargetWindowStats 的 availability 精度一致", () => {
const statsStore = new ProbeStore(join(tempDir, "stats-precision.db"));
const target: ResolvedHttpTarget = { ...httpTarget, name: "stats-precision" };
const target: ResolvedHttpTarget = { ...httpTarget, id: "stats-precision", name: "stats-precision" };
statsStore.syncTargets([target]);
const targetId = statsStore.getTargets()[0]!.id;
@@ -534,10 +534,11 @@ describe("ProbeStore", () => {
test("getDashboardIncidentStates 返回按 target 和 timestamp 升序排列的状态序列", () => {
const incidentStore = new ProbeStore(join(tempDir, "dashboard-incidents.db"));
const httpA: ResolvedHttpTarget = { ...httpTarget, name: "incident-http-a" };
const httpA: ResolvedHttpTarget = { ...httpTarget, id: "incident-http-a", name: "incident-http-a" };
const httpB: ResolvedHttpTarget = {
...httpTarget,
http: { ...httpTarget.http, url: "https://example.com/incident-b" },
id: "incident-http-b",
name: "incident-http-b",
};
incidentStore.syncTargets([httpA, httpB]);

View File

@@ -0,0 +1,313 @@
import { describe, expect, test } from "bun:test";
import { extractVariables, resolveVariables } from "../../../src/server/checker/variables";
describe("config variables", () => {
test("提取合法 variables 类型", () => {
const result = extractVariables({ variables: { enabled: true, host: "example.com", port: 5432 } });
expect(result.issues).toHaveLength(0);
expect(Object.fromEntries(result.variables)).toEqual({ enabled: true, host: "example.com", port: 5432 });
});
test("拒绝非法 variables value 和 key", () => {
const result = extractVariables({
variables: {
"123start": "value",
empty: null,
list: [1, 2, 3],
obj: { a: 1 },
},
});
expect(result.issues.map((item) => [item.code, item.path, item.message])).toEqual([
["invalid-format", "variables.123start", "变量名不符合命名规则"],
["invalid-type", "variables.empty", "变量值不允许为 null"],
["invalid-type", "variables.list", "变量值不允许为 array"],
["invalid-type", "variables.obj", "变量值不允许为 object"],
]);
});
test("解析简单引用、默认值、转义、多变量拼接和无引用字符串", () => {
const result = resolveVariables({
targets: [
{
http: {
body: "Hello $${name}",
headers: {
Authorization: "Bearer ${token}",
Pattern: "${PATTERN|foo|bar}",
},
url: "${protocol}://${host}:${port}/api",
},
id: "api-health",
name: "API",
type: "http",
},
],
variables: { host: "example.com", port: 443, protocol: "https", token: "abc" },
});
expect(result.issues).toHaveLength(0);
const target = (
result.config as { targets: Array<{ http: { body: string; headers: Record<string, string>; url: string } }> }
).targets[0]!;
expect(target.http.url).toBe("https://example.com:443/api");
expect(target.http.headers["Authorization"]).toBe("Bearer abc");
expect(target.http.headers["Pattern"]).toBe("foo|bar");
expect(target.http.body).toBe("Hello ${name}");
});
test("完整引用保留类型,部分引用强制为字符串", () => {
const result = resolveVariables({
targets: [
{
http: {
body: "port: ${port}",
ignoreSSL: "${ssl}",
maxRedirects: "${port}",
url: "${host}",
},
id: "typed-http",
type: "http",
},
],
variables: { host: "https://example.com", port: 5, ssl: true },
});
expect(result.issues).toHaveLength(0);
const http = (result.config as { targets: Array<{ http: Record<string, unknown> }> }).targets[0]!.http;
expect(http["url"]).toBe("https://example.com");
expect(http["maxRedirects"]).toBe(5);
expect(http["ignoreSSL"]).toBe(true);
expect(http["body"]).toBe("port: 5");
});
test("环境变量和默认值在完整引用时做类型推断", () => {
const originalMaxRedirects = process.env["MAX_REDIRECTS"];
const originalIgnoreSsl = process.env["IGNORE_SSL"];
process.env["MAX_REDIRECTS"] = "5";
process.env["IGNORE_SSL"] = "false";
try {
const result = resolveVariables({
targets: [
{
http: {
ignoreSSL: "${IGNORE_SSL}",
maxRedirects: "${MAX_REDIRECTS}",
url: "${HOST|localhost}",
},
id: "env-http",
type: "http",
},
],
});
expect(result.issues).toHaveLength(0);
const http = (result.config as { targets: Array<{ http: Record<string, unknown> }> }).targets[0]!.http;
expect(http["maxRedirects"]).toBe(5);
expect(http["ignoreSSL"]).toBe(false);
expect(http["url"]).toBe("localhost");
} finally {
restoreEnv("MAX_REDIRECTS", originalMaxRedirects);
restoreEnv("IGNORE_SSL", originalIgnoreSsl);
}
});
test("解析优先级为 variables、环境变量、默认值", () => {
const originalPort = process.env["PORT"];
const originalHost = process.env["HOST"];
process.env["PORT"] = "3000";
process.env["HOST"] = "env-host";
try {
const result = resolveVariables({
targets: [
{
http: {
body: "${MISSING|fallback}",
headers: { Host: "${HOST}" },
maxRedirects: "${PORT}",
url: "${HOST_FROM_VARIABLES}",
},
id: "priority-http",
type: "http",
},
],
variables: { HOST_FROM_VARIABLES: "config-host", PORT: 5432 },
});
expect(result.issues).toHaveLength(0);
const http = (result.config as { targets: Array<{ http: Record<string, unknown> }> }).targets[0]!.http;
expect(http["maxRedirects"]).toBe(5432);
expect((http["headers"] as Record<string, string>)["Host"]).toBe("env-host");
expect(http["body"]).toBe("fallback");
expect(http["url"]).toBe("config-host");
} finally {
restoreEnv("PORT", originalPort);
restoreEnv("HOST", originalHost);
}
});
test("替换范围仅 targets且跳过 id 和 type 字段", () => {
const result = resolveVariables({
defaults: { interval: "${interval}" },
server: { host: "${host}" },
targets: [
{
cmd: {
args: ["--host", "${host}"],
env: { TOKEN: "${token}" },
exec: "echo",
},
id: "${id}",
type: "${type}",
},
],
variables: { host: "localhost", id: "resolved", interval: "30s", token: "abc", type: "cmd" },
});
expect(result.issues).toHaveLength(0);
const config = result.config as {
defaults: { interval: string };
server: { host: string };
targets: Array<{ cmd: { args: string[]; env: Record<string, string> }; id: string; type: string }>;
};
expect(config.server.host).toBe("${host}");
expect(config.defaults.interval).toBe("${interval}");
expect(config.targets[0]!.id).toBe("${id}");
expect(config.targets[0]!.type).toBe("${type}");
expect(config.targets[0]!.cmd.args[1]).toBe("localhost");
expect(config.targets[0]!.cmd.env["TOKEN"]).toBe("abc");
});
test("默认值推断为 booleantrue/false", () => {
const result = resolveVariables({
targets: [
{
http: {
ignoreSSL: "${DUMMY_SSL|false}",
url: "${HOST|localhost}",
},
id: "default-bool",
type: "http",
},
],
});
expect(result.issues).toHaveLength(0);
const http = (result.config as { targets: Array<{ http: Record<string, unknown> }> }).targets[0]!.http;
expect(http["ignoreSSL"]).toBe(false);
expect(typeof http["ignoreSSL"]).toBe("boolean");
const result2 = resolveVariables({
targets: [
{
http: {
ignoreSSL: "${DUMMY_SSL|true}",
url: "${HOST|localhost}",
},
id: "default-bool-true",
type: "http",
},
],
});
expect(result2.issues).toHaveLength(0);
const http2 = (result2.config as { targets: Array<{ http: Record<string, unknown> }> }).targets[0]!.http;
expect(http2["ignoreSSL"]).toBe(true);
expect(typeof http2["ignoreSSL"]).toBe("boolean");
});
test("runtime 段不替换", () => {
const result = resolveVariables({
runtime: { maxConcurrentChecks: 10, retention: "${retention}" },
targets: [
{
http: { url: "${host}" },
id: "rt-no-replace",
type: "http",
},
],
variables: { host: "https://example.com", retention: "24h" },
});
expect(result.issues).toHaveLength(0);
const config = result.config as { runtime: { retention: string } };
expect(config.runtime.retention).toBe("${retention}");
});
test("variables 段为非对象时报错", () => {
const strResult = extractVariables({ variables: "invalid" });
expect(strResult.issues).toHaveLength(1);
expect(strResult.issues[0]!.code).toBe("invalid-type");
const numResult = extractVariables({ variables: 123 });
expect(numResult.issues).toHaveLength(1);
expect(numResult.issues[0]!.code).toBe("invalid-type");
const nullResult = extractVariables({ variables: null });
expect(nullResult.issues).toHaveLength(1);
expect(nullResult.issues[0]!.code).toBe("invalid-type");
});
test("无 variables 段时环境变量仍可引用", () => {
const original = process.env["DIAL_TEST_ENV_ONLY"];
process.env["DIAL_TEST_ENV_ONLY"] = "env-value";
try {
const result = resolveVariables({
targets: [
{
http: { url: "${DIAL_TEST_ENV_ONLY}" },
id: "env-only",
type: "http",
},
],
});
expect(result.issues).toHaveLength(0);
const http = (result.config as { targets: Array<{ http: { url: string } }> }).targets[0]!.http;
expect(http.url).toBe("env-value");
} finally {
restoreEnv("DIAL_TEST_ENV_ONLY", original);
}
});
test("缺失变量收集所有 unresolved-variable issue", () => {
const result = resolveVariables({
targets: [
{
http: {
headers: { Authorization: "${missing_token}" },
url: "${missing_base_url}/health/${missing_path}",
},
id: "api-health",
type: "http",
},
],
});
expect(result.issues).toHaveLength(3);
expect(result.issues.map((item) => item.code)).toEqual([
"unresolved-variable",
"unresolved-variable",
"unresolved-variable",
]);
expect(result.issues.map((item) => item.path)).toEqual([
"targets[0].http.headers.Authorization",
"targets[0].http.url",
"targets[0].http.url",
]);
expect(result.issues.every((item) => item.targetId === "api-health")).toBe(true);
expect(result.issues.map((item) => item.message).join("\n")).toContain('变量 "missing_base_url"');
});
});
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
return;
}
process.env[key] = value;
}

View File

@@ -10,14 +10,14 @@ import {
} from "../../src/server/middleware";
describe("validateTargetId", () => {
test("有效的 target ID 返回字", () => {
const result = validateTargetId("123", "production");
test("有效的 target ID 返回字符串", () => {
const result = validateTargetId("api-health_01", "production");
expect(result).not.toHaveProperty("status");
expect((result as { id: number }).id).toBe(123);
expect((result as { id: string }).id).toBe("api-health_01");
});
test("无效的 target ID 返回 400", () => {
const invalid = ["0", "-1", "abc", "1.5", ""];
const invalid = ["-1", "_abc", "has space", "1.5", ""];
for (const id of invalid) {
const result = validateTargetId(id, "production");

View File

@@ -51,6 +51,15 @@ globalThis.ResizeObserver = class {
unobserve() {}
};
globalThis.MutationObserver = class {
disconnect() {}
observe() {}
takeRecords() {
return [];
}
unobserve() {}
};
globalThis.IntersectionObserver = class {
disconnect() {}
observe() {}
@@ -79,3 +88,23 @@ Object.defineProperty(dom.window, "matchMedia", {
dom.window.Element.prototype.scrollTo = () => {};
dom.window.Element.prototype.scrollIntoView = () => {};
Object.defineProperty(dom.window, "customElements", {
value: {
define: () => {},
get: () => undefined,
},
writable: true,
});
globalThis.customElements = dom.window.customElements;
// Mock @number-flow/react globally (custom elements not supported in jsdom)
import { mock } from "bun:test";
import { createElement } from "react";
void mock.module("@number-flow/react", () => {
const NumberFlow = () => createElement("span", { "data-testid": "number-flow" });
const NumberFlowGroup = ({ children }: { children: unknown }) => children;
return { default: NumberFlow, NumberFlowGroup };
});

View File

@@ -3,14 +3,14 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import "../../../tests/web/test-utils";
import { render } from "@testing-library/react";
import { describe, expect, test, vi } from "bun:test";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
import { App } from "../../../src/web/app";
import { THEME_MEDIA_QUERY, THEME_PREFERENCE_STORAGE_KEY } from "../../../src/web/hooks/use-theme-preference";
// Mock hooks
void vi.mock("../../../src/web/hooks/use-queries", () => ({
useDashboard: vi.fn(() => ({
function createDashboardResult(overrides = {}) {
return {
data: {
summary: {
down: 0,
@@ -31,7 +31,43 @@ void vi.mock("../../../src/web/hooks/use-queries", () => ({
isFetching: false,
isLoading: false,
refetch: vi.fn(),
})),
...overrides,
};
}
function installMatchMedia(initialMatches: boolean) {
const originalMatchMedia = window.matchMedia;
let matches = initialMatches;
const listeners = new Set<(event: MediaQueryListEvent) => void>();
const mediaQueryList = {
addEventListener: (_type: string, listener: (event: MediaQueryListEvent) => void) => listeners.add(listener),
addListener: (listener: (event: MediaQueryListEvent) => void) => listeners.add(listener),
dispatchEvent: () => true,
get matches() {
return matches;
},
media: THEME_MEDIA_QUERY,
onchange: null,
removeEventListener: (_type: string, listener: (event: MediaQueryListEvent) => void) => listeners.delete(listener),
removeListener: (listener: (event: MediaQueryListEvent) => void) => listeners.delete(listener),
} as MediaQueryList;
window.matchMedia = () => mediaQueryList;
return {
restore: () => {
window.matchMedia = originalMatchMedia;
},
setMatches: (nextMatches: boolean) => {
matches = nextMatches;
listeners.forEach((listener) => listener({ matches, media: THEME_MEDIA_QUERY } as MediaQueryListEvent));
},
};
}
// Mock hooks
void vi.mock("../../../src/web/hooks/use-queries", () => ({
useDashboard: vi.fn(() => createDashboardResult()),
useMeta: vi.fn(() => ({
data: { checkerTypes: ["http", "cmd"] },
})),
@@ -61,6 +97,20 @@ void vi.mock("../../../src/web/hooks/use-target-detail", () => ({
}));
describe("App", () => {
let matchMediaController: ReturnType<typeof installMatchMedia>;
beforeEach(() => {
const { useDashboard } = require("../../../src/web/hooks/use-queries");
useDashboard.mockReturnValue(createDashboardResult());
window.localStorage.clear();
document.documentElement.removeAttribute("theme-mode");
matchMediaController = installMatchMedia(false);
});
afterEach(() => {
matchMediaController?.restore();
});
test("渲染不崩溃", () => {
const { container } = render(<App />);
expect(container.firstChild).not.toBeNull();
@@ -68,14 +118,16 @@ describe("App", () => {
test("loading 状态不崩溃", () => {
const { useDashboard } = require("../../../src/web/hooks/use-queries");
useDashboard.mockReturnValue({
data: null,
dataUpdatedAt: 0,
error: null,
isFetching: true,
isLoading: true,
refetch: vi.fn(),
});
useDashboard.mockReturnValue(
createDashboardResult({
data: null,
dataUpdatedAt: 0,
error: null,
isFetching: true,
isLoading: true,
refetch: vi.fn(),
}),
);
const { container } = render(<App />);
expect(container.firstChild).not.toBeNull();
@@ -83,14 +135,16 @@ describe("App", () => {
test("错误状态不崩溃", () => {
const { useDashboard } = require("../../../src/web/hooks/use-queries");
useDashboard.mockReturnValue({
data: null,
dataUpdatedAt: 0,
error: { message: "Network error" },
isFetching: false,
isLoading: false,
refetch: vi.fn(),
});
useDashboard.mockReturnValue(
createDashboardResult({
data: null,
dataUpdatedAt: 0,
error: { message: "Network error" },
isFetching: false,
isLoading: false,
refetch: vi.fn(),
}),
);
const { container } = render(<App />);
expect(container.firstChild).not.toBeNull();
@@ -98,30 +152,60 @@ describe("App", () => {
test("有数据状态不崩溃", () => {
const { useDashboard } = require("../../../src/web/hooks/use-queries");
useDashboard.mockReturnValue({
data: {
summary: {
down: 1,
incidents: 0,
lastCheckTime: "2025-01-15T10:00:00.000Z",
total: 2,
up: 1,
window: {
from: "2025-01-14T10:00:00.000Z",
label: "24h",
to: "2025-01-15T10:00:00.000Z",
useDashboard.mockReturnValue(
createDashboardResult({
data: {
summary: {
down: 1,
incidents: 0,
lastCheckTime: "2025-01-15T10:00:00.000Z",
total: 2,
up: 1,
window: {
from: "2025-01-14T10:00:00.000Z",
label: "24h",
to: "2025-01-15T10:00:00.000Z",
},
},
targets: [],
},
targets: [],
},
dataUpdatedAt: Date.now(),
error: null,
isFetching: false,
isLoading: false,
refetch: vi.fn(),
});
dataUpdatedAt: Date.now(),
error: null,
isFetching: false,
isLoading: false,
refetch: vi.fn(),
}),
);
const { container } = render(<App />);
expect(container.firstChild).not.toBeNull();
});
test("默认渲染主题模式选项并按系统亮色应用主题", async () => {
render(<App />);
expect(screen.getByText("系统")).not.toBeNull();
expect(screen.getByText("明亮")).not.toBeNull();
expect(screen.getByText("黑暗")).not.toBeNull();
await waitFor(() => expect(document.documentElement.getAttribute("theme-mode")).toBe("light"));
});
test("切换黑暗模式后写入本地存储并应用主题", async () => {
render(<App />);
fireEvent.click(screen.getByText("黑暗"));
expect(window.localStorage.getItem(THEME_PREFERENCE_STORAGE_KEY)).toBe("dark");
await waitFor(() => expect(document.documentElement.getAttribute("theme-mode")).toBe("dark"));
});
test("刷新后恢复已保存的主题偏好", async () => {
window.localStorage.setItem(THEME_PREFERENCE_STORAGE_KEY, "dark");
render(<App />);
await waitFor(() => expect(document.documentElement.getAttribute("theme-mode")).toBe("dark"));
});
test("系统模式响应 matchMedia 变化", async () => {
render(<App />);
await waitFor(() => expect(document.documentElement.getAttribute("theme-mode")).toBe("light"));
act(() => matchMediaController.setMatches(true));
await waitFor(() => expect(document.documentElement.getAttribute("theme-mode")).toBe("dark"));
});
});

View File

@@ -10,7 +10,7 @@ describe("OverviewTab", () => {
const target: TargetStatus = {
currentStreak: null,
group: "default",
id: 1,
id: "1",
interval: "30s",
latestCheck: {
durationMs: 100,
@@ -40,7 +40,7 @@ describe("OverviewTab", () => {
totalChecks: 20,
upChecks: 19,
},
targetId: 1,
targetId: "1",
trend: [],
window: { bucket: "1h", from: "", to: "" },
};

View File

@@ -1,27 +1,57 @@
import "../../../tests/web/test-utils";
import { render } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { describe, expect, test, vi } from "bun:test";
import { RefreshCountdown } from "../../../src/web/components/RefreshCountdown";
describe("RefreshCountdown", () => {
test("手动模式不崩溃", () => {
const { container } = render(
test("手动模式渲染刷新按钮", () => {
const onRefresh = vi.fn();
render(
<RefreshCountdown
dashboardUpdatedAt={0}
isFetching={false}
isManualRefresh={true}
onRefresh={onRefresh}
refreshInterval={30000}
/>,
);
const button = screen.getByRole("button", { name: "刷新 Dashboard" });
expect(button).toBeTruthy();
});
test("等待首次刷新显示文本", () => {
render(
<RefreshCountdown
dashboardUpdatedAt={0}
isFetching={false}
isManualRefresh={false}
onRefresh={vi.fn()}
refreshInterval={30000}
/>,
);
expect(container.firstChild).not.toBeNull();
expect(screen.getByText("等待首次刷新")).toBeTruthy();
});
test("自动模式不崩溃", () => {
test("刷新中显示文本", () => {
render(
<RefreshCountdown
dashboardUpdatedAt={Date.now()}
isFetching={true}
isManualRefresh={false}
onRefresh={vi.fn()}
refreshInterval={30000}
/>,
);
expect(screen.getByText("刷新中...")).toBeTruthy();
});
test("秒级间隔渲染 NumberFlow 倒计时", () => {
const now = Date.now();
const { container } = render(
render(
<RefreshCountdown
dashboardUpdatedAt={now - 10000}
isFetching={false}
@@ -31,34 +61,42 @@ describe("RefreshCountdown", () => {
/>,
);
expect(container.firstChild).not.toBeNull();
const countdown = screen.getByLabelText(/秒$/);
expect(countdown).toBeTruthy();
expect(countdown.className).toContain("refresh-countdown-flow");
});
test("fetching 状态不崩溃", () => {
const { container } = render(
test("分钟级间隔渲染带分秒的 NumberFlow 倒计时", () => {
const now = Date.now();
render(
<RefreshCountdown
dashboardUpdatedAt={1000}
isFetching={true}
isManualRefresh={false}
onRefresh={vi.fn()}
refreshInterval={30000}
/>,
);
expect(container.firstChild).not.toBeNull();
});
test("未刷新状态不崩溃", () => {
const { container } = render(
<RefreshCountdown
dashboardUpdatedAt={0}
dashboardUpdatedAt={now - 10000}
isFetching={false}
isManualRefresh={false}
onRefresh={vi.fn()}
refreshInterval={30000}
refreshInterval={60000}
/>,
);
expect(container.firstChild).not.toBeNull();
const countdown = screen.getByLabelText(/分\d{2}秒$/);
expect(countdown).toBeTruthy();
expect(countdown.className).toContain("refresh-countdown-flow");
});
test("5 分钟间隔使用稳定分钟格式", () => {
const now = Date.now();
const { container } = render(
<RefreshCountdown
dashboardUpdatedAt={now - 290000}
isFetching={false}
isManualRefresh={false}
onRefresh={vi.fn()}
refreshInterval={300000}
/>,
);
const countdown = container.querySelector(".refresh-countdown-flow");
expect(countdown).toBeTruthy();
expect(countdown?.getAttribute("aria-label")).toMatch(/分\d{2}秒$/);
});
});

View File

@@ -20,7 +20,7 @@ describe("TargetBoard", () => {
{
currentStreak: null,
group: "default",
id: 1,
id: "1",
interval: "30s",
latestCheck: null,
name: "target-1",
@@ -32,7 +32,7 @@ describe("TargetBoard", () => {
{
currentStreak: null,
group: "production",
id: 2,
id: "2",
interval: "30s",
latestCheck: null,
name: "target-2",

View File

@@ -10,7 +10,7 @@ describe("TargetDetailDrawer", () => {
const target: TargetStatus = {
currentStreak: null,
group: "default",
id: 1,
id: "1",
interval: "30s",
latestCheck: {
durationMs: 100,
@@ -40,7 +40,7 @@ describe("TargetDetailDrawer", () => {
totalChecks: 20,
upChecks: 19,
},
targetId: 1,
targetId: "1",
trend: [],
window: { bucket: "1h", from: "", to: "" },
};
@@ -85,4 +85,39 @@ describe("TargetDetailDrawer", () => {
// Just verify rendering doesn't throw
expect(asFragment()).not.toBeNull();
});
test("Drawer 使用响应式默认宽度 CSS 变量", () => {
render(<TargetDetailDrawer {...defaultProps} />);
const wrapper = document.querySelector<HTMLElement>(".t-drawer__content-wrapper")!;
expect(wrapper).not.toBeNull();
expect(wrapper.style.width).toBe("var(--target-detail-drawer-width)");
});
test("Drawer 包含业务 className", () => {
render(<TargetDetailDrawer {...defaultProps} />);
const drawer = document.querySelector(".target-detail-drawer");
expect(drawer).not.toBeNull();
});
test("Drawer 启用 sizeDraggable 拖拽", () => {
render(<TargetDetailDrawer {...defaultProps} />);
const wrapper = document.querySelector<HTMLElement>(".t-drawer__content-wrapper")!;
expect(wrapper).not.toBeNull();
const dragLine = wrapper.querySelector('[style*="col-resize"]');
expect(dragLine).not.toBeNull();
});
test("Drawer 拖拽宽度不写入 localStorage", () => {
const keysBefore = window.localStorage.length;
render(<TargetDetailDrawer {...defaultProps} />);
expect(window.localStorage.length).toBe(keysBefore);
});
test("Drawer sizeDraggable 配置最小拖拽边界", () => {
render(<TargetDetailDrawer {...defaultProps} />);
const wrapper = document.querySelector<HTMLElement>(".t-drawer__content-wrapper")!;
expect(wrapper).not.toBeNull();
const dragLine = wrapper.querySelector('[style*="col-resize"]');
expect(dragLine).not.toBeNull();
});
});

View File

@@ -16,7 +16,7 @@ describe("TargetGroup", () => {
{
currentStreak: null,
group: "default",
id: 1,
id: "1",
interval: "30s",
latestCheck: {
durationMs: 100,
@@ -34,7 +34,7 @@ describe("TargetGroup", () => {
{
currentStreak: null,
group: "default",
id: 2,
id: "2",
interval: "30s",
latestCheck: {
durationMs: 100,

View File

@@ -21,7 +21,7 @@ function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
return {
currentStreak: null,
group: "default",
id: 1,
id: "1",
interval: "5s",
latestCheck: null,
name: "test",

View File

@@ -13,7 +13,7 @@ function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
return {
currentStreak: null,
group: "default",
id: 1,
id: "1",
interval: "5s",
latestCheck: null,
name: "test",

View File

@@ -0,0 +1,83 @@
import { describe, expect, test } from "bun:test";
import {
applyThemeMode,
parseThemePreference,
readThemePreference,
resolveEffectiveTheme,
THEME_PREFERENCE_STORAGE_KEY,
writeThemePreference,
} from "../../../src/web/hooks/use-theme-preference";
function createMemoryStorage(initialValue?: string): Storage {
const data = new Map<string, string>();
if (initialValue !== undefined) data.set(THEME_PREFERENCE_STORAGE_KEY, initialValue);
return {
clear: () => data.clear(),
getItem: (key: string) => data.get(key) ?? null,
key: (index: number) => Array.from(data.keys())[index] ?? null,
get length() {
return data.size;
},
removeItem: (key: string) => void data.delete(key),
setItem: (key: string, value: string) => void data.set(key, value),
};
}
function createThrowingStorage(): Storage {
return {
clear: () => {
throw new Error("storage unavailable");
},
getItem: () => {
throw new Error("storage unavailable");
},
key: () => {
throw new Error("storage unavailable");
},
get length(): number {
throw new Error("storage unavailable");
},
removeItem: () => {
throw new Error("storage unavailable");
},
setItem: () => {
throw new Error("storage unavailable");
},
};
}
describe("use-theme-preference 工具函数", () => {
test("解析有效主题偏好并对非法值回退为系统", () => {
expect(parseThemePreference("system")).toBe("system");
expect(parseThemePreference("light")).toBe("light");
expect(parseThemePreference("dark")).toBe("dark");
expect(parseThemePreference("unknown")).toBe("system");
expect(parseThemePreference(null)).toBe("system");
});
test("根据系统模式计算有效主题", () => {
expect(resolveEffectiveTheme("system", true)).toBe("dark");
expect(resolveEffectiveTheme("system", false)).toBe("light");
expect(resolveEffectiveTheme("light", true)).toBe("light");
expect(resolveEffectiveTheme("dark", false)).toBe("dark");
});
test("读取本地存储偏好并在非法值时回退", () => {
expect(readThemePreference(createMemoryStorage("dark"))).toBe("dark");
expect(readThemePreference(createMemoryStorage("bad-value"))).toBe("system");
});
test("本地存储不可用时读取和写入均不抛错", () => {
const storage = createThrowingStorage();
expect(readThemePreference(storage)).toBe("system");
expect(() => writeThemePreference("dark", storage)).not.toThrow();
});
test("应用有效主题到指定根元素", () => {
const root = document.createElement("html");
applyThemeMode("dark", root);
expect(root.getAttribute("theme-mode")).toBe("dark");
});
});

View File

@@ -41,9 +41,8 @@ export const testHelpers = {
};
},
toHaveTextContent: (element: Element | null, text: RegExp | string) => {
const pass =
element?.textContent !== null &&
(typeof text === "string" ? element.textContent.includes(text) : text.test(element.textContent));
const content = element?.textContent ?? "";
const pass = element !== null && (typeof text === "string" ? content.includes(text) : text.test(content));
return {
message: () => (pass ? `Expected element not to have text "${text}"` : `Expected element to have text "${text}"`),
pass,