Compare commits
9 Commits
8793fbd786
...
7926514986
| Author | SHA1 | Date | |
|---|---|---|---|
| 7926514986 | |||
| 366b3211c8 | |||
| e924732a02 | |||
| 04c24e6796 | |||
| 146cef982e | |||
| c36df94e59 | |||
| f8d563c668 | |||
| 88f4119a4e | |||
| c46ab14cce |
@@ -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 全局面板查询 hook(dashboard/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(yaml:YAML 解析 → 变量替换 → 契约校验 → 语义校验 → 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()` | 查询全部 targets(default 分组优先排序) |
|
||||
| `getLatestChecksMap()` | 批量获取每个 target 的最新检查结果(单次 SQL 聚合) |
|
||||
@@ -458,8 +463,8 @@ TcpChecker implements Checker
|
||||
|
||||
**Schema**:
|
||||
|
||||
- `targets` 表:name(UNIQUE)、type、target(展示摘要)、config(JSON)、interval_ms、timeout_ms、expect(JSON)、grp
|
||||
- `check_results` 表:target_id(FK CASCADE)、timestamp、matched(0/1)、duration_ms、status_detail、failure(JSON)
|
||||
- `targets` 表:id(TEXT PRIMARY KEY,配置 target id)、name(展示名称)、type、target(展示摘要)、config(JSON)、interval_ms、timeout_ms、expect(JSON)、grp
|
||||
- `check_results` 表:target_id(TEXT FK CASCADE,引用配置 target id)、timestamp、matched(0/1)、duration_ms、status_detail、failure(JSON)
|
||||
- 复合索引:`(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` 触发 abort;checker 必须使用 `CheckerContext.signal` 感知超时,HTTP 将 signal 传给 `fetch()`,Cmd 在 signal abort 时 `proc.kill()`
|
||||
- **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLite,engine 通过 `targetNameToId` 缓存 name→id 映射
|
||||
- **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLite,engine 基于配置 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
|
||||
└── ErrorBoundary(React 错误边界)
|
||||
└── QueryClientProvider(TanStack 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 包裹 PrimaryTable,headerBordered)
|
||||
│ └── 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.ts(Drawer 状态与详情级条件查询)
|
||||
├── 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 / dark(RadioGroup 受控值)
|
||||
├── 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` | 单个分组 Card(title+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` | 单个分组 Card(title+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
290
README.md
@@ -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 展示各目标的实时状态、可用率和耗时趋势。
|
||||
|
||||
**功能亮点:**
|
||||
|
||||
- 多种拨测类型:HTTP(GET/POST/PUT 等)、Cmd(命令行执行)、DB(PostgreSQL/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
|
||||
|
||||
7
bun.lock
7
bun.lock
@@ -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=="],
|
||||
|
||||
170
openspec/specs/config-variables/spec.md
Normal file
170
openspec/specs/config-variables/spec.md
Normal 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),校验失败并报错
|
||||
@@ -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`。
|
||||
|
||||
@@ -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 主题背景之上
|
||||
|
||||
141
openspec/specs/db-checker/spec.md
Normal file
141
openspec/specs/db-checker/spec.md
Normal 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 在启动期配置校验失败,而不是延迟到运行期抛错
|
||||
@@ -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 到 id)。HTTP 领域字段 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、targets(含 id、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
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
#### Scenario: 配置变更后重新同步
|
||||
- **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启
|
||||
- **THEN** 系统 SHALL 根据 name 字段匹配:新增的插入、删除的移除、修改的更新(含 grp 字段)
|
||||
- **THEN** 系统 SHALL 根据 id 字段匹配:新增的插入、删除的移除、修改的更新(含 grp 字段)
|
||||
|
||||
### Requirement: check_results 表追加写入
|
||||
系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。
|
||||
|
||||
@@ -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 位移
|
||||
|
||||
@@ -9,7 +9,7 @@ Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示
|
||||
|
||||
#### Scenario: 打开 Drawer
|
||||
- **WHEN** 用户点击某个目标表格行
|
||||
- **THEN** 系统 SHALL 从右侧滑出 Drawer(placement="right"),宽度为 52%,并将当前 Tab 重置为"概览"
|
||||
- **THEN** 系统 SHALL 从右侧滑出 Drawer(placement="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 在同一行展示。
|
||||
|
||||
51
openspec/specs/target-identity/spec.md
Normal file
51
openspec/specs/target-identity/spec.md
Normal 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 不要求全局唯一)
|
||||
73
openspec/specs/theme-mode-preference/spec.md
Normal file
73
openspec/specs/theme-mode-preference/spec.md
Normal file
@@ -0,0 +1,73 @@
|
||||
## Purpose
|
||||
|
||||
定义 Dashboard 主题模式选择、系统主题跟随、浏览器本地持久化和 TDesign 主题变量应用行为。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 主题模式选择器
|
||||
Dashboard SHALL 在 Header 右侧提供主题模式 RadioGroup,允许用户选择"系统""明亮""黑暗"三种模式。
|
||||
|
||||
#### Scenario: 主题模式选项渲染
|
||||
- **WHEN** Dashboard 页面渲染
|
||||
- **THEN** HeadMenu operations 区域 SHALL 在刷新频率选择器前显示 RadioGroup(theme="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` 属性
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"], {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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} 字符)`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
208
src/server/checker/runner/db/execute.ts
Normal file
208
src/server/checker/runner/db/execute.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
60
src/server/checker/runner/db/expect.ts
Normal file
60
src/server/checker/runner/db/expect.ts
Normal 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 };
|
||||
}
|
||||
1
src/server/checker/runner/db/index.ts
Normal file
1
src/server/checker/runner/db/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { DbChecker } from "./execute";
|
||||
52
src/server/checker/runner/db/schema.ts
Normal file
52
src/server/checker/runner/db/schema.ts
Normal 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",
|
||||
]);
|
||||
27
src/server/checker/runner/db/types.ts
Normal file
27
src/server/checker/runner/db/types.ts
Normal 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";
|
||||
}
|
||||
143
src/server/checker/runner/db/validate.ts
Normal file
143
src/server/checker/runner/db/validate.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -28,7 +28,6 @@ export const httpCheckerSchemas: CheckerSchemas = {
|
||||
{
|
||||
headers: Type.Optional(stringMapSchema),
|
||||
maxBodyBytes: Type.Optional(sizeSchema),
|
||||
method: Type.Optional(httpMethodSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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[]]),
|
||||
},
|
||||
|
||||
@@ -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$" }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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},必须为非负安全整数`);
|
||||
}
|
||||
|
||||
205
src/server/checker/variables.ts
Normal file
205
src/server/checker/variables.ts
Normal 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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}秒`;
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
73
src/web/hooks/use-theme-preference.ts
Normal file
73
src/web/hooks/use-theme-preference.ts
Normal 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 渲染。
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ type ShutdownSignal = "SIGINT" | "SIGTERM";
|
||||
|
||||
const target: ResolvedTargetBase = {
|
||||
group: "default",
|
||||
id: "test",
|
||||
intervalMs: 30000,
|
||||
name: "test",
|
||||
timeoutMs: 5000,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -31,6 +31,7 @@ function makeTarget(
|
||||
...cmd,
|
||||
},
|
||||
group: "default",
|
||||
id: "test-cmd",
|
||||
intervalMs: 60000,
|
||||
name: "test-cmd",
|
||||
timeoutMs: 5000,
|
||||
|
||||
159
tests/server/checker/runner/db/execute.test.ts
Normal file
159
tests/server/checker/runner/db/execute.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
134
tests/server/checker/runner/db/expect.test.ts
Normal file
134
tests/server/checker/runner/db/expect.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
170
tests/server/checker/runner/db/validate.test.ts
Normal file
170
tests/server/checker/runner/db/validate.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
313
tests/server/checker/variables.test.ts
Normal file
313
tests/server/checker/variables.test.ts
Normal 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("默认值推断为 boolean(true/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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: "" },
|
||||
};
|
||||
|
||||
@@ -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}秒$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
83
tests/web/hooks/use-theme-preference.test.ts
Normal file
83
tests/web/hooks/use-theme-preference.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user