1
0

Compare commits

...

54 Commits

Author SHA1 Message Date
2f8fd8bd9c refactor: 将 memory checker 重命名为 mem
- 类型标识符 memory → mem
- 类名 MemoryChecker → MemChecker
- 内部类型名统一 Memory* → Mem*
- 内部函数名统一 *Memory* → *Mem*
- 目录重命名 memory/ → mem/(源码、测试、文档)
- 配置键 memory: → mem:
- 重新生成 probe-config.schema.json
- 保留中文"内存"用户提示

破坏性变更:无向后兼容
2026-05-27 18:19:29 +08:00
3390eb5e8d fix: 强化 CPU/memory checker 错误处理、timeout 遵守和快照校验
- Memory checker: reader 与 ctx.signal race,abort 返回 memory/timeout,reject 保持 memory/snapshot
- CPU checker: 第二次快照异常返回 cpu/snapshot,计算前校验空数组/核心数不一致/非有限值/负 delta
- CPU 计算: 零 delta 安全处理,observation 不含 NaN/Infinity
- 文档: CPU 互补描述修正,Memory timeout 约束说明
- 测试: +18 覆盖 timeout、异常和边界输入
2026-05-27 16:33:39 +08:00
145bb8fd04 feat: 新增 memory checker,支持系统级内存和交换空间检测 2026-05-27 00:05:06 +08:00
358f8d011a chore: 放宽权限配置 & 归档 openspec 变更记录 2026-05-26 22:36:33 +08:00
c2dcfab80c feat: 新增本机 CPU checker
- 新增 type: cpu checker,基于 os.cpus() 两次快照计算 CPU 使用率
- 配置项:sampleDuration(默认 1s)、includePerCore(默认 false)
- expect 字段:usagePercent、idlePercent、maxCoreUsagePercent、minCoreUsagePercent、durationMs
- idlePercent 与 usagePercent 互补恒等于 100,百分比范围 0-100
- logicalCoreCount 仅输出到 observation,不作为 expect 字段
- 不暴露 userPercent / systemPercent
- 语义校验禁止 sampleDuration >= timeout
- 支持 AbortSignal 超时取消
- 完整测试覆盖:schema、validate、normalize、resolve、calculate、execute、expect、config-loader
- 新增用户文档 docs/user/checkers/cpu.md
- 更新 checker 索引、配置类型列表、示例配置和 schema
2026-05-26 22:34:57 +08:00
f38286d74d feat: 添加 pi 配置文件
- 添加 .pi/mcp.json 配置 tdesign-mcp-server
- 添加 .pi/extensions/pi-permission-system/config.json 权限配置
- 更新 .gitignore 以跟踪 .pi/mcp.json 和 .pi/extensions
2026-05-26 15:37:27 +08:00
08b61cbf47 refactor: ProbeEngine 调度引擎重写为 per-target setTimeout 链
将 per-group setInterval + groupBy 调度模式改为 per-target setTimeout 链,
实现 catch-up 语义(超时后立即补执行)、AbortController 优雅停止、
循环内错误隔离和 overrun warn 日志。
移除 groupBy/probeGroup/timers,新增 sleep/runLoop/runOnce。
新增 croner 依赖供后续 cron 表达式支持使用。
2026-05-26 11:35:06 +08:00
c120690cf1 feat: target 时间配置校验,interval 最小 10s,timeout 不大于 interval
在配置加载阶段新增通用 target 时间字段语义校验:
- interval 解析后不得小于 10s
- timeout 解析后不得大于同一 target 的 interval
- 默认值(30s / 10s)参与校验
- 变量引用先解析再校验
- 格式错误优先于关系错误,避免级联提示
2026-05-25 17:48:51 +08:00
77c6015b3a refactor: 将 checker normalize 职责下沉到各 runner 目录
- 新增 CheckerDefinition.normalize 必需方法,typecheck 兜底遗漏实现
- 新增 expect/normalize.ts 共享 helper(compactExpect、normalizeValue、
  normalizeContent、normalizeKeyed)
- 为 HTTP、Cmd、DB、TCP、UDP、ICMP、LLM、WS、DNS 各新增独立 normalize.ts
- 简化 normalizer.ts:删除所有 checker type switch,改为 registry 委托
- 修复 DNS authoring 简写 bug:durationMs、valueCount、result 等字段
  现可通过完整加载链路
- 新增 DNS 回归测试和 registry 级合同测试
- 更新 docs/development/checker.md:补充 normalize 规范、文件结构、
  测试要求和 checklist
2026-05-25 16:16:41 +08:00
c1db793073 feat: WS checker,支持可达性检测和单次请求-响应交互验证 2026-05-25 14:13:43 +08:00
714b635aef docs: 重构文档体系
- 合并 DEVELOPMENT.md 至 docs/development/README.md
- 合并 CONTRIBUTING.md 至 docs/development/checker.md
- 合并 build-release.md 至 release.md
- 合并 testing-quality.md 内容至各专题文档
- 合并 status-model.md 至 expectations.md
- 新增 docs/user/README.md 用户入口
- 简化 docs/README.md 文档路由
- 各专题文档新增适用场景和更新触发条件
- 更新 openspec/config.yaml 文档规则
2026-05-25 10:47:52 +08:00
a6504d5a62 docs: 重构文档体系 2026-05-24 20:18:18 +08:00
483cdc596b feat: DNS checker,自研 codec/transport,支持 system/server 双模式,UDP/TCP + TC fallback 2026-05-24 17:06:22 +08:00
4f33fba793 feat: 动态粒度趋势图,支持 auto bucket 选择 + P95 延迟 + 状态条 2026-05-23 23:53:18 +08:00
6601ab458d chore: 清理过时 specs,新增 schemas 目录,更新审查提示词 2026-05-23 22:24:08 +08:00
cfca03b4d6 refactor: 规范审查与重组,合并细粒度规范,清理过时内容
- 合并 20+ 细粒度 spec 为粗粒度主题规范:dashboard、data-store、probe-engine、probe-api、probe-config 等
- 删除完全冗余规范:data-retention(被 probe-engine+data-store 覆盖)、backend-code-quality(DEVELOPMENT.md 已记录)
- 补充 http-checker 规范至完整标准(配置+执行+expect+校验+observation),匹配代码 440 行实现
- 清理 tcp/udp/llm checker 规范中已废弃 defaults 配置段的残留 Scenario
- 清理 checker-cohesion-structure 中的实现路径引用(src/server/...)
- 统一所有 spec 格式(## Purpose 开头,去除 # Capability/Title 形式)
- 更新 prompt-spec-review.md 审查提示文档
2026-05-22 18:55:18 +08:00
cf847ccd7a feat: 重构配置生命周期为 Authoring/Normalized/Resolved 三层
将变量替换和 expect 简写展开统一放入 Normalized 阶段,
运行时 AJV 使用 Normalized schema,导出 schema 面向 Authoring Config。

主要变更:
- 新增 normalizer.ts 实现 normalizeAuthoringConfig()
- 拆分 Authoring/Normalized 双 schema,checker 接口支持 authoring/normalized 片段
- config-loader 流程:normalize → Normalized AJV → semantic → resolve
- validator 兼容层自动分派 raw/normalized expect 形态
- 删除 rawExpect,store.expect 列写入 null
- Authoring schema 对 integer/boolean/enum 字段接受变量引用
- 修复 DB/HTTP validate 入口守卫和 LLM options integer 变量引用
- 优化 compact() 避免 undefined 覆盖隐患
- 移除 content.ts 恒为 true 的前置条件
- 同步 5 个主规范并归档 change
2026-05-22 14:00:47 +08:00
6e53c8130d feat: Alpine 多阶段 Docker 镜像,支持 amd64/arm64 musl 构建 2026-05-21 19:20:47 +08:00
6ca8b36542 feat: 扩展配置变量替换范围至 server/probes/targets,支持空默认值语法 2026-05-21 18:42:42 +08:00
79358ba50d refactor: 移除顶层 defaults 配置段,简化为 target 显式字段 > 代码内置默认值
- 移除 DefaultsConfig 类型、ProbeConfig.defaults 字段
- 移除 CheckerSchemas.defaults、ResolveContext.defaults、CheckerValidationInput.defaults
- 更新所有 checker schema/resolve/validate 删除 defaults 合并逻辑
- 更新 config-loader 不再读取传递 defaults
- 更新测试、README、DEVELOPMENT、probes.example.yaml
- 重新生成 probe-config.schema.json(不含 defaults)
- 同步 delta specs 到主规范
- 归档 openspec change
2026-05-21 16:53:12 +08:00
e448cb4654 feat: 重构配置布局,server.listen/storage/logging + probes.execution 分组
- 新增 server.listen (host/port)、server.storage (dataDir/retention)、
  server.logging 分组
- 新增 probes.execution (maxConcurrentChecks) 分组,替代顶层 runtime
- 旧配置入口 (runtime/logging/server.host/server.port/server.dataDir)
  启动期拒绝
- 更新 types.ts、builder.ts、config-loader.ts 适配新路径
- 更新 probe-config.schema.json、probes.example.yaml、README.md、
  DEVELOPMENT.md
- 补充 config-loader 和 variables 测试覆盖新路径和旧入口拒绝
- 同步 5 个 delta specs 到主规范 (probe-config, config-variables,
  data-retention, probe-engine, runtime-logging)
- 归档 openspec change reorganize-config-layout
2026-05-21 13:54:41 +08:00
5238dbe77d chore: 约束后端统一日志输出 2026-05-21 12:36:37 +08:00
007d74934d feat: 运行时日志系统,Pino + pino-pretty + pino-roll,console/file 双输出,敏感信息 redaction 2026-05-21 12:21:59 +08:00
0d709c7681 fix: 修复构建脚本跨平台 import specifier 路径规范化
- toImportSpecifier() 使用 replaceAll 替代 split(sep).join,明确 ESM import specifier 语义
- 增加 relativePath 可选参数支持测试注入 Windows relative 语义
- 重写 Windows 路径测试,使用 node:path.win32 显式模拟而非依赖当前平台 sep
- 更新 DEVELOPMENT.md 记录构建 code generation 约定
- 同步 static-asset-embedding 和 windows-test-compat spec 新增要求
2026-05-21 09:32:43 +08:00
b432581444 refactor: 清理测试代码 eslint-disable 指令,消除文件级和重复局部禁用 2026-05-21 00:35:08 +08:00
ccd16a583e feat: 跨平台发布打包,支持 7 个目标平台交叉编译和 tar.gz 分发
- 新增 scripts/release.ts,支持 7 个编译目标(linux/darwin/windows + musl 变体)
- 从 build.ts 提取共享构建逻辑到 build-common.ts,现有 build 行为不变
- 使用 tar-stream + node:zlib 创建 tar.gz,精确控制 Unix 权限位
- SHA256 校验和文件格式兼容 sha256sum -c
- 支持 --target 参数选择特定平台编译
- 新增 devDependency: tar-stream、@types/tar-stream
- 更新 README.md 和 DEVELOPMENT.md 文档
- 同步 openspec specs
2026-05-20 23:24:36 +08:00
8eac814cc6 feat: 版本管理,package.json 唯一版本源、/api/meta 返回版本、Dashboard Header 展示版本号 2026-05-20 19:14:37 +08:00
f3df3a203b docs: 优化审查提示词,禁止 subagent 读取文件,明确 apply 阶段不动主规范
config.yaml: subagent 限定为计算密集/多步骤任务,文件读取用 Read 工具
prompt-proposal-review.md: 收集阶段加入读取约束和分步策略,复核补全待澄清清单
prompt-apply-review.md: 禁止同步主规范,新增 Spec 覆盖完整性扫描与补充流程
2026-05-20 17:44:02 +08:00
60a54b483f refactor: expect 类型模型重构,Raw/Resolved 双层分离与断言基础设施内聚
- 重命名 ContentRules→ContentExpectations, KeyValueExpect→KeyedExpectations
- 新增 Raw/Resolved 双层模型:resolve 阶段物化为执行计划,store 持久化 Raw 快照
- HTTP body 按需读取:status/headers 失败或无 body expectation 时不读取 body
- 新增 displayValueExpectation() 解包 failure.expected 用户可读展示
- 修复 checkEarlyTimeout 独立 lte/lt 检查,修复 KeyedExpectations JSON Schema
- 新增 expect/value.ts(resolve/check/display)、keyed.ts、content.ts、headers.ts、status.ts
- 删除旧 normalize.ts/matcher.ts/validate-matcher.ts/key-value.ts
- 更新 DEVELOPMENT.md:expect 五层管线表、displayValueExpectation、1.7↔1.10 交叉引用
- 同步 13 个 main specs,归档 refactor-expect-type-model 变更(62/62 tasks)
2026-05-20 16:12:48 +08:00
6098be2d9e docs: 重构 README 文档结构,新增应用截图,按 checker 类型组织配置说明 2026-05-20 09:58:19 +08:00
b591dcca97 feat: target 软删除机制,配置移除时保留历史数据 2026-05-20 00:43:39 +08:00
9b53c746f6 refactor: ICMP checker type 从 ping 统一改为 icmp,修复前端 UI 细节
- ICMP checker 的 type/configKey/YAML 配置键/接口属性名从 ping 改为 icmp
- IcmpChecker 添加 platform 构造函数注入,修复 Windows 测试兼容性
- 前端 target 表格延迟列优化:标题简化为「延迟」,单位下移到单元格,宽度 80px
- Drawer 概览页 Descriptions 添加 tableLayout=auto 收窄 label 宽度
- 同步更新 README.md、DEVELOPMENT.md、probes.example.yaml、JSON Schema 和全部测试
2026-05-20 00:02:23 +08:00
375dd3492b feat: 结构化 observation 替代 statusDetail,API 层动态构造 detail
- CheckResult: statusDetail -> observation (持久化) + detail (API 动态派生)
- 存储: status_detail 列 -> observation TEXT (JSON)
- CheckerDefinition: 新增 buildDetail(observation) 方法
- 各 checker 返回结构化 observation,API 层通过 registry 调用 buildDetail
- HTTP: bodyPreview 在 status/header 失败时也提前采集
- UDP: observation 包含 durationMs,未响应归为 error failure
- CMD: 超时/输出超限时保留已收集 observation
- TCP: connectTimeMs 仅含连接建立耗时,不含 banner 等待
- 新增 buildDetail 单测和 mapCheckResult 覆盖测试
- 同步 openspec 主规范,归档 checker-observation 变更
2026-05-19 22:49:00 +08:00
22c06820fa docs: 新增 checker-observation 变更提案,归档历史 openspec 变更记录 2026-05-19 19:22:16 +08:00
12cd05b04e feat: ValueMatcher 支持 primitive 原始值简写,等价于 { equals: value } 2026-05-19 17:07:47 +08:00
8d8549d07f refactor: 消除 es-toolkit/compat 依赖,isArray/isObject 替换为原生实现 2026-05-19 14:57:56 +08:00
7a635a0a9f refactor: 统一 expect 断言体系,引入共享 ValueMatcher/ContentRules/KeyValueExpect 模型
- 引入共享 ValueMatcher(equals/contains/regex/exists/empty/gt/gte/lt/lte)
- 引入共享 ContentRules 数组(direct/json/css/xpath 提取器)
- 引入共享 KeyValueExpect(动态键值断言,字面量等价 equals)
- maxDurationMs → durationMs: ValueMatcher(所有 checker)
- match → regex(固定无 flags)
- Ping max* → packetLossPercent/avgLatencyMs/maxLatencyMs(ValueMatcher)
- LLM finishReason/rawFinishReason → ValueMatcher
- DB 新增 result: ContentRules
- TCP banner → ContentRules 数组
- 删除旧模块:operator.ts、validate-operator.ts、duration.ts、body.ts、text.ts、output.ts
- 更新全部 checker schema/validate/expect/execute
- 更新 probe-config.schema.json、probes.example.yaml
- 更新 README.md、DEVELOPMENT.md(含 expect 字段选择规范)
- 同步 10 个 delta specs 到主 specs,归档 change
2026-05-19 14:24:27 +08:00
349896bd02 feat: 新增 LLM checker 支持大模型服务应用层拨测
基于 AI SDK v6 实现 openai/openai-responses/anthropic 三类 provider 的 http/stream 模式调用
支持 output/finishReason/usage/stream 等完整 expect 断言链路
新增 9 个源文件和 5 个测试文件共 78 个测试
更新 README/DEVELOPMENT/probes.example.yaml 和 probe-config.schema.json
2026-05-19 00:06:53 +08:00
52262a31f6 feat: 新增 UDP checker,支持自定义 payload 请求-响应探测与断言
基于 Bun connected UDP socket 实现通用 UDP 拨测能力:
- 支持 text/hex/base64 payload 编码与独立 responseEncoding 响应视图
- 支持 responded、response、responseSize、sourceHost、sourcePort、maxDurationMs 专属 expect
- 单 datagram 发送,仅断言首个 UDP 响应 datagram
- 通过 maxResponseBytes 和 flags.truncated 进行响应大小限制与截断保护
- payload 可选,省略时发送空 datagram
- 自包含模块结构(types/schema/validate/expect/encoding/execute)
- 新增 741 tests(含 unit、execute 集成、expect 和编码 roundtrip),全部通过
2026-05-18 17:23:17 +08:00
550c427814 feat: 新增 ICMP/Ping checker,支持跨平台主机存活检测与延迟监控
实现 type: ping checker,通过 Bun.spawn 调用系统 ping 命令,自行实现跨平台
输出解析器(Linux/macOS/Windows 含中文 locale),支持 alive、丢包率、延迟、
耗时等 expect 断言,复用现有 checker 架构零外部依赖。

包含完整的类型定义、TypeBox schema、语义校验、命令构建、解析、断言、执行、
注册、配置加载测试,以及 probe-config.schema.json 更新和文档更新。

审查修复:提取 buildPingCommand 为独立纯函数并补充跨平台单测,补充
maxDurationMs/maxAvgLatencyMs 类型非法和空字符串 host 边界测试用例。

变更已归档,delta specs 已同步至 main specs。
2026-05-18 10:45:17 +08:00
c51bc5a0d8 refactor: 优化 clean 脚本,目录直接删除替代 glob 逐文件清理,补充 playwright-report/test-results 2026-05-18 09:18:36 +08:00
393e8da5fd feat: 新增 ICMP/Ping checker 设计提案
- 定义 ping target 配置:host、count、packetSize
- 定义 ping expect 断言:alive、maxPacketLoss、maxAvgLatencyMs、maxMaxLatencyMs
- 设计跨平台 ping 输出解析器(Linux/macOS/Windows 含多语言支持)
- 双重超时保障:ping 命令自身超时 + AbortSignal 兜底
- 扩展 checker-runner-abstraction spec 支持 ping checker 子进程控制
- 更新 probe-config spec 支持 ping type 配置
2026-05-18 00:33:11 +08:00
0a9a9016be feat: 新增 TCP checker,支持端口可达性探测与 banner 读取
- 新增 src/server/checker/runner/tcp/ 自包含目录(types/schema/validate/execute/expect)
- 注册 TcpChecker 到 checkerRegistry,schema/engine/store/config-loader 自动委托
- 支持 expect.connected 正反向语义(默认期待可达,可配置期待不可达)
- 支持 readBanner opt-in banner 读取,受 bannerReadTimeout + maxBannerBytes 双重限制
- 复用电有 expect/operator/duration/failure 基础设施
- 新增 3 个测试文件 51 条用例(execute/validate/expect),全量 634 测试通过
- 更新 README/DEVELOPMENT/probes.example.yaml,新增 tcp-checker capability spec
2026-05-17 23:53:37 +08:00
31fd3a2a43 refactor: 统一 target name/description 可空语义,前端展示 fallback 到 id
- schema: name/description 允许省略或显式 null,TypeBox Union([Null, String])
- 类型: RawTargetConfig/ResolvedTargetBase/子类型/StoredTarget/TargetStatus name 改为 string | null
- checker resolve: name: t.name ?? null,不再 fallback 到 id
- 语义校验: 拒绝空字符串和纯空白 name
- SQLite: targets.name 列改为可空 TEXT
- 前端: 新增 getTargetDisplayName(target) 展示 name ?? id
- 测试: 覆盖 name/description null 全场景,查找改为按 id
- 文档: 更新 README/DEVELOPMENT 和 6 个 openspec specs
2026-05-17 20:12:39 +08:00
f7193e98ff feat: 新增 target description 字段,收紧 id/name 长度,调整延迟列和名称列 2026-05-17 18:42:46 +08:00
7926514986 feat: 配置变量系统与 target id/name 双字段标识
- 新增顶层 variables 段支持 string/number/boolean 字面量
- target 字符串字段支持 、、{...} 转义语法
- 变量解析优先级: variables -> process.env -> 默认值 -> 报错
- 完整引用保留原始类型,部分引用拼接为字符串
- 变量替换在 YAML 解析后、AJV 校验前执行
- 替换仅作用于 targets,跳过 id/type 字段
- target 新增必填 id 字段作为唯一标识,name 改为可选展示名称
- 数据库存储/API/前端全面迁移到 id 标识
- 统一 checker 运行时类型检查为 es-toolkit predicates
- 同步 delta specs 到主 specs,归档 config-variables 变更
2026-05-17 00:37:54 +08:00
366b3211c8 fix: 移除 README 示例配置中的 defaults.http.method 2026-05-16 21:46:20 +08:00
e924732a02 refactor: 移除 defaults.http.method 配置,简化默认值体系
- HTTP checker defaults schema 不再支持 method 字段
- resolve 逻辑从三级 fallback 简化为两级(target -> 内置默认)
- 配置文件中出现 defaults.http.method 将触发未知字段校验错误
- per-target http.method 覆盖功能保持不变
- 同步更新示例配置、README 文档和测试用例
2026-05-16 21:45:08 +08:00
04c24e6796 docs: 重写 README 为结构化项目文档,精简示例配置 2026-05-16 20:58:04 +08:00
146cef982e feat: 新增 DB checker — 支持 PostgreSQL/MySQL/SQLite 连接测试与 SQL 查询断言
- 实现 db 类型 checker,使用 Bun 内置 SQL 类
- 支持 db.url 连接字符串和可选 db.query 查询语句
- expect 支持 maxDurationMs、rowCount、rows 逐列校验
- 凭据屏蔽序列化输出
- SQLite 内存数据库测试覆盖
2026-05-16 09:00:15 +08:00
c36df94e59 chore: 归档 responsive-resizable-drawer 变更 2026-05-16 00:17:31 +08:00
f8d563c668 feat: Header 倒计时数字滚动动画 — @number-flow/react 替换静态文本 2026-05-16 00:14:35 +08:00
88f4119a4e feat: Drawer 响应式默认宽度与拖拽调整,统计卡片上下布局优化
Drawer 宽度从固定百分比改为按视口响应式默认值(6段断点),宽屏占比更小、窄屏占比更大。

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

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

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

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

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

15
.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
.build
.claude
.codex
.env
.env.*
.git
.opencode
.agents
.DS_Store
coverage
data
dist
node_modules
*.log
*.bun-build

4
.gitignore vendored
View File

@@ -403,6 +403,9 @@ cython_debug/
!.claude/settings.json
.opencode
.codex
.pi/*
!.pi/mcp.json
!.pi/extensions
openspec/changes/archive
temp
.agents
@@ -422,3 +425,4 @@ backend/cmd/desktop/rsrc_windows_*.syso
# Bun
.build/
*.bun-build
dist/release/

View File

@@ -0,0 +1,19 @@
{
"$schema": "https://raw.githubusercontent.com/gotgenes/pi-permission-system/main/schemas/permissions.schema.json",
"permission": {
"*": "allow",
"write": "allow",
"edit": "allow",
"bash": {
"*": "allow",
"npm *": "deny",
"npx *": "deny",
"pnpm *": "deny",
"pnpx *": "deny"
},
"external_directory": {
"*": "ask",
"/tmp/*": "allow"
}
}
}

8
.pi/mcp.json Normal file
View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"tdesign-mcp-server": {
"command": "bunx",
"args": ["tdesign-mcp-server@latest"]
}
}
}

File diff suppressed because it is too large Load Diff

45
Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
# syntax=docker/dockerfile:1.7
ARG BUN_IMAGE=oven/bun:1-alpine
ARG ALPINE_IMAGE=alpine:3.22
FROM ${BUN_IMAGE} AS deps
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
FROM deps AS build
WORKDIR /app
COPY . .
ARG TARGETARCH
RUN set -eux; \
case "${TARGETARCH:-$(uname -m)}" in \
amd64|x86_64) export BUN_TARGET=bun-linux-x64-musl ;; \
arm64|aarch64) export BUN_TARGET=bun-linux-arm64-musl ;; \
*) echo "不支持的架构: ${TARGETARCH:-$(uname -m)}" >&2; exit 1 ;; \
esac; \
bun run build
FROM ${ALPINE_IMAGE} AS runtime
RUN apk add --no-cache ca-certificates iputils-ping libgcc libstdc++ tzdata \
&& addgroup -S dial \
&& adduser -S -G dial -h /nonexistent -s /sbin/nologin dial \
&& mkdir -p /etc/dial /data/dial \
&& chown -R dial:dial /data/dial
COPY --from=build --chmod=0755 /app/dist/dial-server /usr/local/bin/dial-server
COPY --chmod=0644 docker/probes.yaml /etc/dial/probes.yaml
USER dial
WORKDIR /data/dial
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -q -O - "http://127.0.0.1:3000/health" >/dev/null || exit 1
ENTRYPOINT ["/usr/local/bin/dial-server"]
CMD ["/etc/dial/probes.yaml"]

265
README.md
View File

@@ -1,234 +1,99 @@
# DiAL
<h1 align="center">DiAL</h1>
基于 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**、**命令行**、**数据库**、**TCP**、**UDP**、**DNS**、**ICMP** 和 **LLM** 多种拨测类型。通过 YAML 配置文件定义拨测目标,后端定时并发执行拨测并将结果持久化到本地 SQLite前端 Dashboard 展示各目标的实时状态、可用率和耗时趋势。
## 功能亮点
- 多类型拨测HTTP、Cmd、DB、TCP、UDP、DNS、ICMP、LLM
- 丰富校验规则状态码、响应头、JSONPath、CSS 选择器、XPath、正则匹配、数值比较等
- 结构化观测数据HTTP body 预览、TCP/UDP 响应摘要、ICMP 丢包率、CMD 输出、LLM token 用量等
- 内置 Dashboard实时状态、可用率统计、趋势图、最近状态条、手动/自动刷新、版本号展示
- 多主题支持:系统、明亮、黑暗三种主题模式
- 自托管部署:本地 SQLite 存储,无需额外数据库服务
## 应用截图
| | 亮色 | 暗色 |
| ------ | --------------------------------------------------- | ------------------------------------------------- |
| 主页 | ![index-light](assets/screenshot/light_index.png) | ![index-dark](assets/screenshot/dark_index.png) |
| 详情页 | ![detail-light](assets/screenshot/light_detail.png) | ![detail-dark](assets/screenshot/dark_detail.png) |
## 快速开始
**前置条件:** [Bun](https://bun.sh/) >= 1.0
ICMP checker 依赖系统 `ping` 命令。精简容器镜像需额外安装,例如 Alpine 可安装 `iputils-ping`
```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
```
`verify` 会基于当前源码重新构建生产 executable。原 smoke test 已移除executable/E2E 验证后续单独补充。
## 配置文件
程序通过 YAML 配置文件定义所有运行参数:
## 最小配置示例
```yaml
# yaml-language-server: $schema=./probe-config.schema.json
server:
host: "127.0.0.1"
port: 3000
dataDir: "/tmp/probes_data"
runtime:
maxConcurrentChecks: 20
retention: "7d"
defaults:
interval: "5s"
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"
expect:
status: [200]
maxDurationMs: 10000
- name: "JSON API 示例"
type: http
http:
url: "https://httpbin.org/json"
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 脚本检查"
type: cmd
cmd:
exec: "bun"
args: ["-e", "console.log('ok')"]
expect:
exitCode: [0]
stdout:
- contains: "ok"
durationMs:
lte: 5000
```
### 配置说明
完整配置、checker、expect 和部署说明参见 [用户文档](docs/user/README.md)、[配置文件](docs/user/configuration.md)、[Checker 参考](docs/user/checkers/README.md) 和 [校验规则](docs/user/expectations.md)。
- **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`
大小说明:`maxBodyBytes``maxOutputBytes` 支持单位 `KB``MB``GB`,也可直接使用数字(非负安全整数字节数)。
配置校验:系统启动时会先用 TypeBox 生成的 JSON Schema 契约校验字段类型、必填字段、枚举、数组/对象形状和未知字段,再执行语义 validator 校验 target name 唯一性、URL、正则、JSONPath、XPath、size/duration 解析等规则。非法配置会阻止启动并输出中文错误信息。
未知字段:除 `http.headers``defaults.http.headers``expect.headers``cmd.env` 等动态键值表外,未知字段会导致启动失败。配置备注请使用 YAML 注释,不要添加 `note``comment` 等未声明字段。
JSON Schema仓库根目录导出 `probe-config.schema.json`,可在 YAML 文件顶部添加 `# yaml-language-server: $schema=./probe-config.schema.json` 获取编辑器提示和静态校验。该 schema 由运行期契约 fragments 生成,提交前可用 `bun run schema:check` 检查同步。
时长格式支持:`500ms``30s``5m``2h``7d`
## API 端点
| 端点 | 说明 |
| ----------------------------------------------------------------- | ------------------------------------------------------------ |
| `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` |
### 响应字段
**DashboardResponse**: `summary``targets`
**DashboardResponse.summary**: `total``up``down``lastCheckTime``incidents``window`
**MetaResponse**: `checkerTypes`(已注册 checker 类型标识符列表)
**TargetStatus**: `id``name``type`checker 类型,如 http/cmd`target`URL 或命令摘要)、`group``interval``latestCheck``stats``currentStreak``recentSamples`
**RecentSample**: `timestamp``durationMs``up`
**CheckResult**: `timestamp``matched``durationMs``statusDetail``failure`
**CheckFailure**: `kind`error/mismatch`phase``path``message``expected?`(仅 mismatch`actual?`(仅 mismatch
**TargetStats**: `totalChecks``upChecks``downChecks``availability`
**CurrentStreak**: `up``count``capped?`
**TargetMetricsResponse**: `targetId``window``stats``trend`
**TargetMetricsResponse.stats**: `totalChecks``upChecks``downChecks``availability``avgDurationMs``p95DurationMs``p99DurationMs``mttr``longestOutage``incidentCount``currentStreak`
**TrendPoint**: `bucketStart``avgDurationMs``minDurationMs``maxDurationMs``availability``totalChecks``upChecks``downChecks`
**HistoryResponse**: `items`CheckResult[])、`total``page``pageSize`
### 错误响应
API 错误返回 `ApiErrorResponse` 格式:
```json
{ "error": "描述信息", "status": 400 }
```
| 状态码 | 触发场景 |
| ------ | ------------------------------------------------------------------------------------------ |
| 400 | 参数格式错误(无效 ID、from/to 缺失或格式错误、page/pageSize 非正整数、pageSize 超过 200 |
| 404 | 目标不存在、API 路由未匹配、非 GET 方法请求 API 路由 |
## 运行参数
CLI 只接受一个参数YAML 配置文件路径。
## 生产运行
```bash
bun run build
./dist/dial-server ./probes.yaml
```
## 目标状态判定
Docker、跨平台发布包和运行时注意事项参见 [部署文档](docs/user/deployment.md)。
单层判定模型,适用于 HTTP 和 Cmd 两种类型:
## 文档导航
- **matched**: 是否符合 expect 规则HTTP 未指定 `expect.status` 时默认检查 `[200]`
- **UP** = matched
- **DOWN** = NOT matched
| 入口 | 内容 |
| -------------------------------------------- | ------------------------------------------- |
| [文档总览](docs/README.md) | 全部文档入口和文档归属矩阵 |
| [用户文档](docs/user/README.md) | 配置、部署、expect、排障和 checker 使用入口 |
| [配置文件](docs/user/configuration.md) | YAML 结构、变量、server、targets 通用字段 |
| [Checker 参考](docs/user/checkers/README.md) | 所有 checker 的配置、expect 和示例 |
| [校验规则](docs/user/expectations.md) | expect 规则、状态判定、failure、observation |
| [部署文档](docs/user/deployment.md) | 构建、Docker、发布包和容器运行边界 |
| [故障排查](docs/user/troubleshooting.md) | 常见运行问题和排查入口 |
| [开发文档](docs/development/README.md) | 开发入口、常用命令、质量门禁和专题索引 |
执行失败(网络错误、超时、进程崩溃)和 expect 不匹配都统一为 `matched=false`,通过 `failure.kind` 区分(`"error"` vs `"mismatch"`)。
## 开发
---
```bash
bun run check # schema:check + typecheck + lint + test
bun run verify # check + build
```
> 开发相关文档(项目结构、构建、测试、代码规范等)请参阅 [DEVELOPMENT.md](DEVELOPMENT.md)。
开发入口参见 [开发文档](docs/development/README.md)。新增或修改 checker 前请先阅读 [Checker 开发](docs/development/checker.md)。
## License
Apache-2.0

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

127
bun.lock
View File

@@ -5,15 +5,24 @@
"": {
"name": "gateway-checker",
"dependencies": {
"@ai-sdk/anthropic": "^3",
"@ai-sdk/openai": "^3",
"@number-flow/react": "^0.6.0",
"@sinclair/typebox": "^0.34.49",
"@tanstack/react-query": "^5.100.10",
"@xmldom/xmldom": "^0.9.10",
"ai": "^6",
"ajv": "^8.20.0",
"cheerio": "^1.2.0",
"croner": "^10.0.1",
"es-toolkit": "^1.46.1",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"pino-roll": "^4.0.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"recharts": "^3.8.1",
"systeminformation": "^5.31.6",
"tdesign-icons-react": "^0.6.4",
"tdesign-react": "^1.16.9",
"xpath": "^0.0.34",
@@ -28,6 +37,7 @@
"@types/jsdom": "^28.0.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/tar-stream": "^3.1.4",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
@@ -41,6 +51,7 @@
"jsdom": "^29.1.1",
"lint-staged": "^17.0.4",
"prettier": "^3.8.3",
"tar-stream": "^3.2.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.3",
"vite": "^8.0.13",
@@ -48,6 +59,16 @@
},
},
"packages": {
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "https://registry.npmmirror.com/@ai-sdk/anthropic/-/anthropic-3.0.78.tgz", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.115", "https://registry.npmmirror.com/@ai-sdk/gateway/-/gateway-3.0.115.tgz", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-xonmGfN9pt54WdKqMzWe68BRYS3rsYvraBzioyA0gfNcecHs8Ir5qk/X8grJSyZ95hghjWiOphrK6bAc11E6SA=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.64", "https://registry.npmmirror.com/@ai-sdk/openai/-/openai-3.0.64.tgz", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-epO4iS6QwktaY2PF6uBcPnDTJ3BxPOfsGS7/OEtBe3GtNj7C8h8gMDVtIe5K8W16HNDbn0tbR4dcQfpfs+XVFg=="],
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "https://registry.npmmirror.com/@ai-sdk/provider/-/provider-3.0.10.tgz", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "https://registry.npmmirror.com/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="],
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "https://registry.npmmirror.com/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="],
@@ -186,8 +207,14 @@
"@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=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.1", "https://registry.npmmirror.com/@opentelemetry/api/-/api-1.9.1.tgz", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
"@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=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "https://registry.npmmirror.com/@pinojs/redact/-/redact-0.4.0.tgz", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
"@pkgr/core": ["@pkgr/core@0.2.9", "https://registry.npmmirror.com/@pkgr/core/-/core-0.2.9.tgz", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="],
"@popperjs/core": ["@popperjs/core@2.11.8", "https://registry.npmmirror.com/@popperjs/core/-/core-2.11.8.tgz", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="],
@@ -292,6 +319,8 @@
"@types/sortablejs": ["@types/sortablejs@1.15.9", "https://registry.npmmirror.com/@types/sortablejs/-/sortablejs-1.15.9.tgz", {}, "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ=="],
"@types/tar-stream": ["@types/tar-stream@3.1.4", "https://registry.npmmirror.com/@types/tar-stream/-/tar-stream-3.1.4.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg=="],
"@types/tough-cookie": ["@types/tough-cookie@4.0.5", "https://registry.npmmirror.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
@@ -356,6 +385,8 @@
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "https://registry.npmmirror.com/@vercel/oidc/-/oidc-3.2.0.tgz", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.9.10.tgz", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="],
@@ -364,6 +395,8 @@
"acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ai": ["ai@6.0.184", "https://registry.npmmirror.com/ai/-/ai-6.0.184.tgz", { "dependencies": { "@ai-sdk/gateway": "3.0.115", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-j//zHkKvj5ra27l8izHco8cj1g1Pr7vx1ZK+hrzrkHvndgIRmdfZKOb6+RAPpvbk42qGIsuYvlYbGlVAu3erNQ=="],
"ajv": ["ajv@8.20.0", "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ansi-escapes": ["ansi-escapes@7.3.0", "https://registry.npmmirror.com/ansi-escapes/-/ansi-escapes-7.3.0.tgz", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
@@ -392,10 +425,26 @@
"async-function": ["async-function@1.0.0", "https://registry.npmmirror.com/async-function/-/async-function-1.0.0.tgz", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "https://registry.npmmirror.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"b4a": ["b4a@1.8.1", "https://registry.npmmirror.com/b4a/-/b4a-1.8.1.tgz", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw=="],
"balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"bare-events": ["bare-events@2.8.3", "https://registry.npmmirror.com/bare-events/-/bare-events-2.8.3.tgz", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw=="],
"bare-fs": ["bare-fs@4.7.1", "https://registry.npmmirror.com/bare-fs/-/bare-fs-4.7.1.tgz", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw=="],
"bare-os": ["bare-os@3.9.1", "https://registry.npmmirror.com/bare-os/-/bare-os-3.9.1.tgz", {}, "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ=="],
"bare-path": ["bare-path@3.0.0", "https://registry.npmmirror.com/bare-path/-/bare-path-3.0.0.tgz", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw=="],
"bare-stream": ["bare-stream@2.13.1", "https://registry.npmmirror.com/bare-stream/-/bare-stream-2.13.1.tgz", { "dependencies": { "streamx": "^2.25.0", "teex": "^1.0.1" }, "peerDependencies": { "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-abort-controller", "bare-buffer", "bare-events"] }, "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow=="],
"bare-url": ["bare-url@2.4.3", "https://registry.npmmirror.com/bare-url/-/bare-url-2.4.3.tgz", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.28", "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.28.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA=="],
"bidi-js": ["bidi-js@1.0.3", "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.0.3.tgz", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
@@ -432,6 +481,8 @@
"clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"colorette": ["colorette@2.0.20", "https://registry.npmmirror.com/colorette/-/colorette-2.0.20.tgz", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
"compare-func": ["compare-func@2.0.0", "https://registry.npmmirror.com/compare-func/-/compare-func-2.0.0.tgz", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="],
"concat-map": ["concat-map@0.0.1", "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
@@ -448,6 +499,8 @@
"cosmiconfig-typescript-loader": ["cosmiconfig-typescript-loader@6.3.0", "https://registry.npmmirror.com/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz", { "dependencies": { "jiti": "2.6.1" }, "peerDependencies": { "@types/node": "*", "cosmiconfig": ">=9", "typescript": ">=5" } }, "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA=="],
"croner": ["croner@10.0.1", "https://registry.npmmirror.com/croner/-/croner-10.0.1.tgz", {}, "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g=="],
"cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-select": ["css-select@5.2.2", "https://registry.npmmirror.com/css-select/-/css-select-5.2.2.tgz", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
@@ -488,6 +541,10 @@
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "https://registry.npmmirror.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
"date-fns": ["date-fns@4.2.1", "https://registry.npmmirror.com/date-fns/-/date-fns-4.2.1.tgz", {}, "sha512-37RhSdxaG1suen6VDCza6rNrQfooyQh57HFVPwQGEq2QWliVLzPQZ8Oa017weOu+HZCnzI7N3Pf/wyoBKfEqrA=="],
"dateformat": ["dateformat@4.6.3", "https://registry.npmmirror.com/dateformat/-/dateformat-4.6.3.tgz", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
"dayjs": ["dayjs@1.11.10", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.10.tgz", {}, "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="],
"debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -530,6 +587,8 @@
"encoding-sniffer": ["encoding-sniffer@0.2.1", "https://registry.npmmirror.com/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="],
"end-of-stream": ["end-of-stream@1.4.5", "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"entities": ["entities@8.0.0", "https://registry.npmmirror.com/entities/-/entities-8.0.0.tgz", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
"env-paths": ["env-paths@2.2.1", "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
@@ -584,6 +643,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=="],
@@ -596,14 +657,24 @@
"eventemitter3": ["eventemitter3@5.0.4", "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"events-universal": ["events-universal@1.0.1", "https://registry.npmmirror.com/events-universal/-/events-universal-1.0.1.tgz", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="],
"eventsource-parser": ["eventsource-parser@3.0.8", "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.0.8.tgz", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
"fast-copy": ["fast-copy@4.0.3", "https://registry.npmmirror.com/fast-copy/-/fast-copy-4.0.3.tgz", {}, "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-diff": ["fast-diff@1.3.0", "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
"fast-fifo": ["fast-fifo@1.3.2", "https://registry.npmmirror.com/fast-fifo/-/fast-fifo-1.3.2.tgz", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "https://registry.npmmirror.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
"fast-uri": ["fast-uri@3.1.2", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.2.tgz", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -664,6 +735,8 @@
"hasown": ["hasown@2.0.3", "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
"help-me": ["help-me@5.0.0", "https://registry.npmmirror.com/help-me/-/help-me-5.0.0.tgz", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
"hermes-estree": ["hermes-estree@0.25.1", "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.25.1.tgz", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"hermes-parser": ["hermes-parser@0.25.1", "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.25.1.tgz", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
@@ -758,6 +831,8 @@
"jiti": ["jiti@2.6.1", "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"joycon": ["joycon@3.1.1", "https://registry.npmmirror.com/joycon/-/joycon-3.1.1.tgz", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
"js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
@@ -770,6 +845,8 @@
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json-schema": ["json-schema@0.4.0", "https://registry.npmmirror.com/json-schema/-/json-schema-0.4.0.tgz", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
@@ -852,6 +929,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=="],
@@ -868,6 +947,10 @@
"object.values": ["object.values@1.2.1", "https://registry.npmmirror.com/object.values/-/object.values-1.2.1.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "https://registry.npmmirror.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
"once": ["once@1.4.0", "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"onetime": ["onetime@7.0.0", "https://registry.npmmirror.com/onetime/-/onetime-7.0.0.tgz", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"optionator": ["optionator@0.9.4", "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
@@ -900,6 +983,16 @@
"picomatch": ["picomatch@4.0.4", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pino": ["pino@10.3.1", "https://registry.npmmirror.com/pino/-/pino-10.3.1.tgz", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="],
"pino-abstract-transport": ["pino-abstract-transport@3.0.0", "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
"pino-pretty": ["pino-pretty@13.1.3", "https://registry.npmmirror.com/pino-pretty/-/pino-pretty-13.1.3.tgz", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pump": "^3.0.0", "secure-json-parse": "^4.0.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^5.0.2" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg=="],
"pino-roll": ["pino-roll@4.0.0", "https://registry.npmmirror.com/pino-roll/-/pino-roll-4.0.0.tgz", { "dependencies": { "date-fns": "^4.1.0", "sonic-boom": "^4.0.1" } }, "sha512-axI1aQaIxXdw1F4OFFli1EDxIrdYNGLowkw/ZoZogX8oCSLHUghzwVVXUS8U+xD/Savwa5IXpiXmsSGKFX/7Sg=="],
"pino-std-serializers": ["pino-std-serializers@7.1.0", "https://registry.npmmirror.com/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.14", "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
@@ -912,10 +1005,16 @@
"pretty-format": ["pretty-format@27.5.1", "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"process-warning": ["process-warning@5.0.0", "https://registry.npmmirror.com/process-warning/-/process-warning-5.0.0.tgz", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
"prop-types": ["prop-types@15.8.1", "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"pump": ["pump@3.0.4", "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
"punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "https://registry.npmmirror.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
"raf": ["raf@3.4.1", "https://registry.npmmirror.com/raf/-/raf-3.4.1.tgz", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="],
"react": ["react@19.2.6", "https://registry.npmmirror.com/react/-/react-19.2.6.tgz", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
@@ -930,6 +1029,8 @@
"react-transition-group": ["react-transition-group@4.4.5", "https://registry.npmmirror.com/react-transition-group/-/react-transition-group-4.4.5.tgz", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"real-require": ["real-require@0.2.0", "https://registry.npmmirror.com/real-require/-/real-require-0.2.0.tgz", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
"recharts": ["recharts@3.8.1", "https://registry.npmmirror.com/recharts/-/recharts-3.8.1.tgz", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="],
"redux": ["redux@5.0.1", "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
@@ -964,12 +1065,16 @@
"safe-regex-test": ["safe-regex-test@1.1.0", "https://registry.npmmirror.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
"safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"saxes": ["saxes@6.0.0", "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
"scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"secure-json-parse": ["secure-json-parse@4.1.0", "https://registry.npmmirror.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
"semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"set-function-length": ["set-function-length@1.2.2", "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
@@ -994,14 +1099,20 @@
"slice-ansi": ["slice-ansi@8.0.0", "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-8.0.0.tgz", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="],
"sonic-boom": ["sonic-boom@4.2.1", "https://registry.npmmirror.com/sonic-boom/-/sonic-boom-4.2.1.tgz", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
"sortablejs": ["sortablejs@1.15.7", "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz", {}, "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A=="],
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"split2": ["split2@4.2.0", "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"stable-hash-x": ["stable-hash-x@0.2.0", "https://registry.npmmirror.com/stable-hash-x/-/stable-hash-x-0.2.0.tgz", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="],
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
"streamx": ["streamx@2.25.0", "https://registry.npmmirror.com/streamx/-/streamx-2.25.0.tgz", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg=="],
"string-argv": ["string-argv@0.3.2", "https://registry.npmmirror.com/string-argv/-/string-argv-0.3.2.tgz", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"string-width": ["string-width@7.2.0", "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
@@ -1016,16 +1127,28 @@
"strip-bom": ["strip-bom@3.0.0", "https://registry.npmmirror.com/strip-bom/-/strip-bom-3.0.0.tgz", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
"strip-json-comments": ["strip-json-comments@5.0.3", "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-5.0.3.tgz", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"symbol-tree": ["symbol-tree@3.2.4", "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"synckit": ["synckit@0.11.12", "https://registry.npmmirror.com/synckit/-/synckit-0.11.12.tgz", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="],
"systeminformation": ["systeminformation@5.31.6", "https://registry.npmmirror.com/systeminformation/-/systeminformation-5.31.6.tgz", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-Uv2b2uGGM6ns+26czgW2cYRabYdnswM0ddSOOlryHOaelzsmDSet1iM/NT7VOYxW8x/BW+HkY+b1Ve2pLTSGSA=="],
"tar-stream": ["tar-stream@3.2.0", "https://registry.npmmirror.com/tar-stream/-/tar-stream-3.2.0.tgz", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg=="],
"tdesign-icons-react": ["tdesign-icons-react@0.6.4", "https://registry.npmmirror.com/tdesign-icons-react/-/tdesign-icons-react-0.6.4.tgz", { "dependencies": { "@babel/runtime": "^7.16.5", "classnames": "^2.2.6" }, "peerDependencies": { "react": ">=16.13.1", "react-dom": ">=16.13.1" } }, "sha512-USAoi9vBWcwcJT45VqR3dRqX1MeAsn/RhHVx4bLwplhrlvE80ZQ1N9V+6F3HqE1Qe9mMDbtRM8Ul80+lALScww=="],
"tdesign-react": ["tdesign-react@1.16.9", "https://registry.npmmirror.com/tdesign-react/-/tdesign-react-1.16.9.tgz", { "dependencies": { "@babel/runtime": "~7.26.7", "@popperjs/core": "~2.11.2", "@types/sortablejs": "^1.10.7", "@types/validator": "^13.1.3", "classnames": "~2.5.1", "dayjs": "1.11.10", "hoist-non-react-statics": "~3.3.2", "lodash-es": "^4.17.21", "mitt": "^3.0.0", "raf": "~3.4.1", "react-fast-compare": "^3.2.2", "react-is": "^18.2.0", "react-transition-group": "~4.4.1", "sortablejs": "^1.15.0", "tdesign-icons-react": "^0.6.4", "tslib": "~2.3.1", "validator": "~13.15.0" }, "peerDependencies": { "react": ">=16.13.1", "react-dom": ">=16.13.1" } }, "sha512-C3uZRTkJ1iQ62BrMkuvqvBK+4HEuhl82rABxa6kAHGHL3eBI4DPfzAJGF0T3b+DKCBeJxb0x10elumT6NkQEaw=="],
"teex": ["teex@1.0.1", "https://registry.npmmirror.com/teex/-/teex-1.0.1.tgz", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="],
"text-decoder": ["text-decoder@1.2.7", "https://registry.npmmirror.com/text-decoder/-/text-decoder-1.2.7.tgz", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="],
"thread-stream": ["thread-stream@4.2.0", "https://registry.npmmirror.com/thread-stream/-/thread-stream-4.2.0.tgz", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyexec": ["tinyexec@1.1.2", "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.1.2.tgz", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="],
@@ -1104,6 +1227,8 @@
"wrap-ansi": ["wrap-ansi@10.0.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-10.0.0.tgz", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="],
"wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"xml-name-validator": ["xml-name-validator@5.0.0", "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
"xmlchars": ["xmlchars@2.2.0", "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
@@ -1222,6 +1347,8 @@
"tdesign-react/@babel/runtime": ["@babel/runtime@7.26.10", "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.26.10.tgz", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw=="],
"thread-stream/real-require": ["real-require@1.0.0", "https://registry.npmmirror.com/real-require/-/real-require-1.0.0.tgz", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="],
"typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.3", "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.59.3.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],

17
docker/probes.yaml Normal file
View File

@@ -0,0 +1,17 @@
# yaml-language-server: $schema=../probe-config.schema.json
server:
listen:
host: "${DIAL_HOST|0.0.0.0}"
port: "${DIAL_PORT|3000}"
storage:
dataDir: "${DIAL_DATA_DIR|/data/dial}"
targets:
- id: "self-health"
name: "DiAL 自检"
type: http
http:
url: "http://127.0.0.1:${DIAL_PORT|3000}/health"
expect:
status: [200]

127
docs/README.md Normal file
View File

@@ -0,0 +1,127 @@
# DiAL 文档
本文档是 DiAL 的文档路由入口。AI 工具和开发者应先阅读本文件判断本次任务需要读取和更新哪些专题文档,再按任务类型读取最小必要上下文。
## 目录索引
```text
docs/
README.md
development/
README.md
architecture.md
backend.md
frontend.md
release.md
checker.md
user/
README.md
configuration.md
deployment.md
expectations.md
troubleshooting.md
checkers/
README.md
http.md
cmd.md
db.md
tcp.md
udp.md
icmp.md
dns.md
llm.md
```
`docs/prompts/` 是提示词资产目录,不属于常规开发流程和用户使用文档。代码、配置、部署或 checker 变更不需要更新该目录,除非任务明确要求维护提示词资产。
## 入口文档
| 入口 | 定位 |
| ------------------------------------------- | ------------------------------------------ |
| [项目 README](../README.md) | 项目整体介绍、快速开始、核心能力、文档引导 |
| [开发文档](development/README.md) | 开发入口、全局规则、常用命令、质量门禁 |
| [用户文档](user/README.md) | 用户使用入口、配置、部署、expect、排障 |
| [Checker 用户参考](user/checkers/README.md) | 各 checker 的配置项、expect 字段和示例 |
## 按任务阅读路径
| 任务 | 必读文档 |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| 修改项目介绍或快速开始 | [项目 README](../README.md)、本文档 |
| 修改开发流程、质量门禁或工程规则 | [开发文档](development/README.md)、本文档、[OpenSpec 配置](../openspec/config.yaml) |
| 修改架构边界或启动流程 | [开发文档](development/README.md)、[架构与边界](development/architecture.md) |
| 修改后端 API、store、engine、logger | [开发文档](development/README.md)、[后端开发](development/backend.md) |
| 修改前端 | [开发文档](development/README.md)、[前端开发](development/frontend.md) |
| 新增或修改 checker | [Checker 开发](development/checker.md)、[Checker 用户参考](user/checkers/README.md)、相近 checker 文档 |
| 修改配置 schema | [配置文件](user/configuration.md)、[后端开发](development/backend.md)、相关 checker 文档 |
| 修改 expect 或状态模型 | [校验规则](user/expectations.md)、[后端开发](development/backend.md)、[Checker 开发](development/checker.md) |
| 修改构建、Docker、release | [构建与发布](development/release.md)、[部署文档](user/deployment.md) |
| 修改故障处理或运行依赖 | [故障排查](user/troubleshooting.md)、相关用户文档 |
| 修改文档规则或文档目录结构 | 本文档、[OpenSpec 配置](../openspec/config.yaml) |
## 文档归属矩阵
| 变更类型 | 默认更新位置 |
| -------------------------------------------------------------- | -------------------------------------------------------------- |
| 项目定位、核心能力、快速开始、顶层文档导航 | `README.md` |
| 文档路由、文档更新规则、文档归属矩阵 | `docs/README.md``openspec/config.yaml` |
| 开发入口、常用命令、质量门禁、全局工程规则、OpenSpec 约定 | `docs/development/README.md` |
| 架构边界、启动流程、运行时流程、前后端边界 | `docs/development/architecture.md` |
| 后端 API、共享类型、store、engine、logger、expect 基础设施 | `docs/development/backend.md` |
| 前端技术栈、组件、样式、数据层、前端测试 | `docs/development/frontend.md` |
| checker 开发机制、schema/validate/resolve/execute/expect 约定 | `docs/development/checker.md` |
| 构建、发布、Dockerfile、脚本、前后端静态资源集成 | `docs/development/release.md` |
| YAML 顶层结构、server、variables、targets 通用字段 | `docs/user/configuration.md` |
| checker 配置、expect 字段、示例、用户可见 checker 行为 | `docs/user/checkers/<type>.md``docs/user/checkers/README.md` |
| ValueMatcher、ContentExpectations、KeyedExpectations、状态模型 | `docs/user/expectations.md` |
| 构建产物运行、Docker 参数、发布包、运行时依赖 | `docs/user/deployment.md` |
| 常见运行问题、依赖命令、容器权限、配置校验问题 | `docs/user/troubleshooting.md` |
## development 文档如何更新
开发文档解释“如何实现和维护”。代码变更影响开发者理解、开发流程、测试方式或架构边界时,必须更新 `docs/development/` 对应文档。
- 全局规则、常用命令、质量门禁、目录边界、OpenSpec 约定更新到 `docs/development/README.md`
- 架构图、启动链路、运行时流程、前后端边界更新到 `docs/development/architecture.md`
- 后端 API、配置加载、store、engine、logger、expect 基础设施和后端测试规范更新到 `docs/development/backend.md`
- 前端技术栈、组件边界、数据流、样式规则和前端测试规范更新到 `docs/development/frontend.md`
- checker 开发机制、文件结构、schema、validate、resolve、execute、expect、测试 checklist 更新到 `docs/development/checker.md`
- 构建、Docker、release、脚本和发布验证更新到 `docs/development/release.md`
- 不新增“杂项”开发文档;优先把内容放入上述最贴近的专题,确需新增专题时先更新本文档和 `openspec/config.yaml`
## user 文档如何更新
用户文档解释“如何使用”和“用户能观察到什么”。变更影响用户配置、运行、部署、checker 行为、expect 规则、状态结果或排障方式时,必须更新 `docs/user/` 对应文档。
- 配置事实来源是 TypeBox schema、`probe-config.schema.json`、语义校验器和测试;`docs/user/configuration.md` 负责解释顶层结构和通用字段。
- checker 专属字段和示例只在 `docs/user/checkers/<type>.md` 完整展开,`docs/user/checkers/README.md` 只维护类型索引和选择建议。
- expect 断言模型、UP/DOWN、`failure``observation`、快速失败顺序更新到 `docs/user/expectations.md`
- Docker、生产运行、发布包和运行时依赖更新到 `docs/user/deployment.md`
- 常见错误和排查路径更新到 `docs/user/troubleshooting.md`
- 用户文档避免解释内部实现细节,需要实现细节时链接到 `docs/development/`
## 文档影响分析
每次代码变更都必须执行文档影响分析。
```text
代码或配置变更
-> 用户能感知吗?更新 docs/user/ 或 README.md
-> 开发者需要知道吗?更新 docs/development/
-> 文档规则变化吗?更新 docs/README.md 和 openspec/config.yaml
-> 都不是?收尾说明写明无需更新文档及原因
```
同一事实只在最贴近读者的文档中完整展开,其他文档使用链接引用。根目录 README 保持轻量不承载完整配置参考、checker 表或实现教程。
## 收尾说明示例
```text
文档影响分析:本次修改了 HTTP checker 的配置字段,已更新 docs/user/checkers/http.md、docs/user/configuration.md 和 probe-config.schema.json。
```
无需更新文档时:
```text
文档影响分析:本次仅调整内部测试 helper未改变用户可见行为、配置、架构边界或开发流程因此无需更新文档。
```

115
docs/development/README.md Normal file
View File

@@ -0,0 +1,115 @@
# 开发文档
本文档是 DiAL 的开发入口。AI 工具和开发者应先阅读 [`../README.md`](../README.md) 判断文档归属,再阅读本文和最小必要专题。
适用场景修改源码、测试、构建脚本、开发流程、架构边界、checker 开发机制或项目工程规则。
## 专题索引
| 文档 | 内容 |
| ---------------------------------- | ------------------------------------------------------------------------------------------------- |
| [architecture.md](architecture.md) | 项目结构、启动流程、运行时流程、HTTP 请求流程、前后端边界 |
| [backend.md](backend.md) | 后端库优先级、API 路由、共享 helpers、类型规范、配置契约、store、engine、logger、expect、错误模型 |
| [frontend.md](frontend.md) | React、TDesign、TanStack Query、组件、样式和前端测试规范 |
| [checker.md](checker.md) | 新增或修改 checker 的实现机制、测试要求、文档同步和 checklist |
| [release.md](release.md) | 开发服务、前后端集成、构建、Docker、release、脚本、环境变量 |
| [../README.md](../README.md) | 文档路由、文档归属矩阵、development/user 文档更新规则 |
## 常用命令
| 命令 | 说明 |
| -------------------------------- | ---------------------------------------- |
| `bun install` | 安装依赖 |
| `bun run dev probes.yaml` | 启动双进程开发环境 |
| `bun run dev:server probes.yaml` | 仅启动后端 API server |
| `bun run dev:web` | 仅启动 Vite dev server |
| `bun run schema` | 生成 `probe-config.schema.json` |
| `bun run schema:check` | 检查导出 schema 是否同步 |
| `bun run typecheck` | TypeScript 类型检查 |
| `bun run lint` | ESLint 和 Prettier 格式检查 |
| `bun run format` | Prettier 自动格式化 |
| `bun test` | 运行全部测试 |
| `bun run check` | `schema:check + typecheck + lint + test` |
| `bun run build` | 构建生产可执行文件 |
| `bun run verify` | `check + build` 完整验证 |
| `bun run release` | 跨平台发布打包 |
| `bun run clean` | 清理构建缓存与产物 |
## 质量门禁
代码变更必须按影响范围执行验证。
| 变更类型 | 必跑命令 |
| -------------------------------- | --------------------------------------------------------- |
| 常规代码变更 | `bun run check` |
| 构建、部署、发布、前后端集成变更 | `bun run verify` |
| 配置 schema 变化 | `bun run schema``bun run schema:check``bun run check` |
| checker 新增或修改 | `bun run schema``bun run schema:check``bun run check` |
| 仅文档变更 | 检查链接、索引和文档归属一致性 |
正式提交或影响构建产物时优先运行 `bun run verify`。如果因环境限制无法执行完整验证,必须在收尾说明中记录未执行项和原因。
## 全局工程规则
- 使用中文编写注释、文档和项目内交流内容。
- 仅使用 `bun` 作为包管理器,禁止使用 npm、pnpm、yarn。
- 运行工具使用 `bunx`,禁止使用 npx、pnpx。
- 新增代码优先复用已有组件、工具和依赖库,不引入新依赖;确需新增依赖时先说明原因。
- 后端优先使用 Bun 内置 API其次是 es-toolkit、标准 Web API、主流三方库最后才自行实现。
- 前端样式优先使用 TDesign 组件、组件 props、TDesign CSS tokens、`styles.css` CSS 类,最后才自行开发组件。
- 前端禁止组件内联 `style`、覆盖 TDesign 内部类名、使用 `!important`、硬编码色值。
- 当前项目未上线,不需要为旧行为做向前兼容,除非用户明确要求。
## 包管理、依赖与提交
- 仅使用 `bun` 安装依赖和运行项目脚本,锁文件为 `bun.lock`
- 新增依赖前先确认 Bun 内置 API、es-toolkit、标准 Web API、现有三方库和项目公共工具是否已满足需求。
- Git 提交信息使用中文,格式为 `类型: 简短描述`
- 提交类型限定为 `feat``fix``refactor``docs``style``test``chore`
- 多行提交描述时,标题和正文之间空一行。
## 目录边界
| 目录 | 约定 |
| ------------------- | ---------------------------------------------------------- |
| `src/server/` | Bun 后端代码,不能 import `src/web/`HTML import 集成除外 |
| `src/web/` | React Dashboard不能 import `src/server/` 运行时实现 |
| `src/shared/` | 前后端共享 TypeScript 类型 |
| `scripts/` | 独立运行脚本,可 import 项目源码 |
| `tests/` | 测试目录,结构镜像 `src/` |
| `docs/user/` | 用户使用、配置、部署、checker 和排障文档 |
| `docs/development/` | 架构、后端、前端、发布和 checker 开发文档 |
| `openspec/` | OpenSpec 变更管理与规格文档 |
## 文档影响分析
每次代码变更都必须执行文档影响分析。
| 如果变更影响 | 更新 |
| --------------------------------------------------- | ------------------------------------------ |
| 用户可见行为、配置、checker、expect、部署、状态模型 | `docs/user/` 对应文档 |
| 开发流程、架构、测试、构建发布、checker 开发机制 | `docs/development/` 对应文档 |
| 项目定位、快速开始、核心能力列表、文档导航 | `README.md` |
| 文档同步规则或文档归属矩阵 | `docs/README.md``openspec/config.yaml` |
如果无需更新文档,必须在收尾说明中说明原因。详细规则见 [文档总览](../README.md)。
## OpenSpec 协作规则
- 本项目 OpenSpec 使用 `fast-drive` schema变更文档只包含 `design.md``tasks.md`,不创建 `proposal.md``specs/*.md`
- `design.md` 是 scope、requirements、decisions、guardrails、execution direction 和 verification expectations 的 source of truth。
- `tasks.md` 必须从 `design.md` 派生,一行一个 checkbox 任务。
- 实现阶段按 `tasks.md` 顺序执行,完成后立即标记任务状态。
## 事实来源
| 主题 | 事实来源 |
| -------------- | ---------------------------------------------------------- |
| 代码结构和实现 | `src/``scripts/``tests/` |
| 配置 schema | TypeBox fragments、`probe-config.schema.json`、schema 测试 |
| 项目全局规则 | `openspec/config.yaml`、本文档、本目录专题文档 |
| checker 流程 | [checker.md](checker.md) |
## 更新触发条件
修改常用命令、质量门禁、全局工程规则、目录边界、OpenSpec 协作方式或开发文档索引时,必须更新本文档。

View File

@@ -0,0 +1,114 @@
# 架构与边界
本文档说明 DiAL 的项目结构、启动链路、运行时流程、HTTP 请求流程和前后端边界。
适用场景修改目录边界、启动流程、运行时调度、HTTP server、前后端集成方式或主要模块职责。
## 项目结构
```text
src/
server/
bootstrap.ts
config.ts
dev.ts
logger.ts
main.ts
server.ts
helpers.ts
middleware.ts
version.ts
routes/
checker/
config-loader.ts
variables.ts
schema/
store.ts
engine.ts
expect/
runner/
shared/
api.ts
web/
app.tsx
main.tsx
styles.css
components/
constants/
hooks/
utils/
scripts/
tests/
docs/
openspec/
probe-config.schema.json
```
## 启动流程
```text
dev.ts / main.ts
-> readRuntimeConfig(cli args)
-> bootstrap({ configPath, mode })
-> loadConfig(yaml)
-> createRuntimeLogger(logging)
-> ProbeStore(db)
-> store.syncTargets(targets)
-> ProbeEngine(...).start()
-> startServer({ config, mode, store, logger })
-> 注册 SIGINT/SIGTERM shutdown
```
`loadConfig()` 的处理顺序YAML 解析 -> Authoring normalize变量替换 + expect 简写展开)-> Normalized 契约校验 -> 语义校验 -> resolve。
## 运行时流程
```text
定时器 tick
-> ProbeEngine.probeGroup()
-> checkerRegistry.get(target.type).execute()
-> runner/*/expect.ts 校验
-> engine.writeResult()
-> store.insertCheckResult()
```
数据清理由 engine 定时调用 `store.prune(retentionMs)`,每小时执行一次。
## HTTP 请求流程
```text
Request
-> Bun.serve routes 声明式匹配
-> routes/*.ts handler
-> middleware.ts 参数校验
-> helpers.ts 响应格式化
-> Response
```
生产模式下,非 API 路径由 fetch fallback 处理静态资源和 SPA fallback。开发模式下Vite proxy 将 `/api``/health` 请求转发到 Bun API server。
## 前后端边界
- 前端只通过 HTTP 调用后端API 路径为 `/api/*`
- 共享类型放在 `src/shared/`
- 前端不得 import `src/server/` 的运行时实现。
- 后端不得依赖 `src/web/` 运行时代码HTML import 集成除外。
## 主要模块职责
| 模块 | 职责 |
| ------------------------------------- | ------------------------------------------- |
| `src/server/bootstrap.ts` | 统一启动引导和 shutdown 编排 |
| `src/server/server.ts` | Bun HTTP server 和 routes 注册 |
| `src/server/routes/` | API handler按端点拆分 |
| `src/server/checker/config-loader.ts` | YAML 解析、契约校验、语义校验、resolve 调度 |
| `src/server/checker/store.ts` | SQLite 数据存储 |
| `src/server/checker/engine.ts` | 定时调度、并发控制、结果写入、数据清理 |
| `src/server/checker/runner/` | 各 checker 自包含实现 |
| `src/server/checker/expect/` | 跨 checker 复用的断言基础设施 |
| `src/web/` | React Dashboard |
| `src/shared/api.ts` | 前后端共享 API 类型 |
## 更新触发条件
修改项目结构、启动流程、运行时流程、HTTP 请求流程、前后端边界或主要模块职责时,必须更新本文档。

142
docs/development/backend.md Normal file
View File

@@ -0,0 +1,142 @@
# 后端开发
本文档说明 DiAL 后端的 API、配置加载、存储、拨测引擎、日志、expect 和错误模型开发约定。
适用场景:修改 `src/server/``src/shared/api.ts`、后端测试、配置契约、API 响应、store、engine、logger 或 expect 基础设施。
## 库使用优先级
| 优先级 | 来源 | 典型用途 |
| ------ | ------------ | -------------------------------------------------------------- |
| 1 | Bun 内置 API | `Bun.serve``bun:sqlite``Bun.spawn``Bun.file``Bun.YAML` |
| 2 | es-toolkit | 类型判断、深度比较、错误判断、并发控制、集合操作 |
| 3 | 标准 Web API | `Object.fromEntries``Headers``fetch``AbortController` |
| 4 | 主流三方库 | cheerio、xpath、@xmldom/xmldom |
| 5 | 自行实现 | 仅在以上都无法满足时 |
新增依赖前必须先检查上述每一层是否已有可用方案。
## API 路由开发
路由文件位于 `src/server/routes/`,每个端点一个文件。路由通过 `server.ts``Bun.serve({ routes })` 声明式注册,使用 per-method handler 对象。
新增路由步骤:
1.`src/server/routes/` 下创建 `<name>.ts`
2. 实现 handler 函数并 export。
3.`server.ts``routes` 对象中注册路径和 method handler。
4.`tests/server/app.test.ts` 中添加集成测试。
请求参数校验使用 `middleware.ts` 提供的 `validateTargetId``validateTimeRange``validatePagination``validateDashboardWindow``validateRecentLimit``validateMetricsBucket`
## 共享 helpers
| 函数 | 用途 |
| ------------------------------- | ------------------------------------ |
| `createApiError(error, status)` | 构造 API 错误体 |
| `createHeaders(mode, init)` | 创建响应 Headers生产模式附加安全头 |
| `createHealthResponse()` | 构造健康检查响应 |
| `formatDuration(ms)` | 毫秒转为可读时长字符串 |
| `jsonResponse(body, options)` | JSON 响应构造 |
| `mapCheckResult(row, type)` | 数据库行转 API CheckResult |
## 类型规范
- 共享类型以 `src/shared/api.ts` 为唯一源头。
- 严格联合类型优先于宽类型。
- 存储层类型与 API 类型分离。
- checker 具体类型在各自目录定义,中间层通过 base interface 和 registry 完成类型擦除。
- 纯类型导入使用 `import type`
## 配置契约与校验
配置加载流程固定为:`unknown -> AuthoringProbeConfig -> NormalizedProbeConfig -> ValidatedProbeConfig -> ResolvedConfig`
| 层级 | 职责 |
| ---------- | ------------------------------------------------ |
| Authoring | 用户 YAML 可书写形态,允许变量引用和 expect 简写 |
| Normalized | 变量替换和 expect 简写展开后的契约校验形态 |
| Validated | 通过契约校验和语义校验的形态 |
| Resolved | checker `resolve()` 后的运行期配置 |
Ajv 保持严格拒绝模式:`allErrors: true`、不启用类型强制转换、不注入默认值、不自动删除未知字段。默认对象策略是 `additionalProperties: false`,只有明确的动态键值表可以开放任意键名。
新增或修改配置字段时必须同步更新 TypeBox schema fragments、`probe-config.schema.json`、语义 validator、测试和对应用户文档并运行 `bun run schema:check`
## 数据存储
存储层基于 `bun:sqlite`WAL 模式运行,数据库文件位于配置的 `dataDir` 下。
| 方法 | 用途 |
| ------------------------------------------ | ---------------------------------- |
| `syncTargets(targets)` | 启动期同步 targets |
| `insertCheckResult()` | 写入单条检查结果 |
| `getTargets()` | 查询全部 targets |
| `getLatestChecksMap()` | 批量获取每个 target 的最新检查结果 |
| `getAllTargetWindowStats(from, to)` | 批量获取窗口基础计数 |
| `getDashboardIncidentStates(from, to)` | 获取 Dashboard 窗口状态序列 |
| `getAllRecentSamples(limit)` | 批量获取最近采样 |
| `getTargetCheckpoints(targetId, from, to)` | 获取单目标窗口检查点序列 |
| `getTargetDurations(targetId, from, to)` | 获取单目标成功耗时数组 |
| `getHistory()` | 分页查询历史记录 |
| `prune(retentionMs)` | 清理过期数据 |
数据库只负责存储、筛选、排序、分页、LIMIT 和基础聚合。指标语义在后端应用层实现。
## 拨测引擎
- 按 interval 分组,每组独立定时触发。
- 使用 `es-toolkit/Semaphore` 限制全局最大并发数。
- 通过 `checkerRegistry.get(target.type)` 选择 runner。
- 每次检查创建 `AbortController` 并按 `target.timeoutMs` 触发 abort。
- 状态变化通过注入的 `Logger` 输出结构化日志。
## 日志模块
后端运行时代码统一通过 `Logger` 接口输出日志,禁止直接使用 `console.*`。配置加载失败前使用 `ConsoleFallbackLogger`
| 实现 | 用途 |
| ----------------------- | --------------------------------------------- |
| `PinoLoggerWrapper` | 生产运行时,封装 Pino、pino-pretty、pino-roll |
| `NoopLogger` | 静默丢弃日志 |
| `MemoryLogger` | 测试替身 |
| `ConsoleFallbackLogger` | 配置加载失败前的降级日志 |
敏感信息会自动 redact `authorization``cookie``set-cookie``authToken``key``password``token``apiKey` 及其嵌套路径。
## expect 系统
共享断言基础设施位于 `src/server/checker/expect/`。新增或修改 checker 的 expect 字段时,按以下原则选择模型:
| 模型 | 用途 | 典型字段 |
| --------------------- | ---------------------------- | ------------------------------------------------------------------- |
| enum / boolean | 状态类结果,结果集合小且稳定 | HTTP status、Cmd exitCode、TCP connected、UDP responded、ICMP alive |
| `ValueMatcher` | 数字指标和字符串元数据 | durationMs、rowCount、finishReason、usage |
| `ContentExpectations` | 返回内容或半结构化内容 | body、stdout、stderr、banner、response、output、result |
| `KeyedExpectations` | 动态键值断言 | headers、DB rows 列值 |
详细 checker 开发流程见 [Checker 开发](checker.md)。
## 错误模型
- API 错误:`{ error: "描述", status: <code> }`
- CheckFailure`{ kind: "error" | "mismatch", phase, path, expected?, actual?, message }`
expect 校验失败记录首个失败原因;网络、超时、进程崩溃统一为 `kind: "error"`
## 后端测试与验证
| 变更类型 | 测试重点 |
| ---------------------- | ---------------------------------------- |
| API 路由 | `tests/server/app.test.ts` 集成行为 |
| 配置 schema 或语义校验 | schema 导出、合法配置、非法配置 |
| store | SQLite 写入、查询、分页、聚合和清理 |
| engine | 调度、并发、超时、结果写入和状态变化日志 |
| expect 基础设施 | matcher 语义、快速失败、错误信息 |
| checker runner | 见 [Checker 开发](checker.md#测试要求) |
后端运行时代码统一通过注入的 Logger 输出日志,禁止直接使用 `console.*`。新增或修改后端逻辑通常需要运行 `bun run check`;影响构建产物或前后端集成时运行 `bun run verify`
## 更新触发条件
修改后端 API、共享类型、配置契约、store、engine、logger、expect 基础设施、错误模型或后端测试规范时,必须更新本文档。

221
docs/development/checker.md Normal file
View File

@@ -0,0 +1,221 @@
# Checker 开发
Checker 是 DiAL 的核心扩展单元。每个 checker 是 `src/server/checker/runner/<type>/` 下的自包含目录包含类型、schema、语义校验、执行逻辑、序列化和断言。
适用场景:新增 checker、修改 checker 配置或 expect、调整 checker 注册机制、改动 checker 测试或用户文档同步规则。
新增或修改 checker 前必须阅读 [开发入口](README.md)、[配置文件](../user/configuration.md)、[校验规则](../user/expectations.md) 和 [Checker 用户文档](../user/checkers/README.md)。还应阅读现有同类 checker 的实现和测试,例如 `src/server/checker/runner/http/``tests/server/checker/runner/http/`
## 设计原则
- 每个 checker 必须自包含在 `src/server/checker/runner/<type>/`
- checker 专属类型、schema、validate、execute、expect、normalize 和协议辅助逻辑放在同一目录。
- 注册只修改 `src/server/checker/runner/index.ts`,中间层不新增 type switch。
- schema 层只描述契约,语义规则放入 `validate.ts`
- `resolve()` 只做默认值填充、路径解析和单位转换,不执行校验。
- `execute()` 必须支持 `CheckerContext.signal` 超时取消。
- expect 字段必须选择合适断言模型,不为了统一而滥用 ValueMatcher。
- failure phase 命名遵循去单位后缀规则,例如 `durationMs` 对应 `duration`
## 架构目标
```text
checkerRegistry
├── runner/index.ts
├── schema/builder.ts
├── schema/validate.ts
├── config-loader.ts
├── engine.ts
└── store.ts
```
注册后,中间层通过 registry 自动委托 schema 生成、契约校验、配置 normalize、配置 resolve、执行和序列化。新增 checker 不应在中间层新增 `switch/case` 或类型分支。
## 标准文件结构
| 文件 | 职责 |
| -------------- | ------------------------------------------------------- |
| `index.ts` | 模块入口re-export Checker 类 |
| `types.ts` | Checker 专属类型 |
| `schema.ts` | TypeBox 契约 schema包含 config 和 expect |
| `validate.ts` | 启动期语义校验 |
| `normalize.ts` | Checker 专属 authoring expect 归一化 |
| `execute.ts` | Checker 类,实现 normalize、resolve、execute、serialize |
| `expect.ts` | Checker 专用断言函数 |
| 其他文件 | 协议解析、编码、provider 适配、平台命令封装等专属逻辑 |
## 类型定义
`types.ts` 中定义:
- `RawXxxTargetConfig`
- `RawXxxExpectConfig`
- `ResolvedXxxExpectConfig`
- `ResolvedXxxTarget extends ResolvedTargetBase`
不需要修改顶层 `checker/types.ts`。base interface 使用 index signature 支持扩展。
## Schema
checker 必须提供 `CheckerSchemas`,包含 Authoring 和 Normalized 两套 config/expect 片段。Authoring 描述用户 YAML 可写 DSLNormalized 描述 normalizer 输出。
常用 fragments
| Fragment | 用途 |
| ----------------------------------- | ------------------------- |
| `durationSchema` | 时长字符串 |
| `sizeSchema` | 大小单位 |
| `statusCodePatternSchema` | HTTP 状态码或范围 |
| `stringMapSchema` | headers、env 等字符串映射 |
| `createValueMatcherSchema()` | ValueMatcher |
| `createContentExpectationsSchema()` | ContentExpectations |
| `createKeyedExpectationsSchema()` | KeyedExpectations |
默认对象策略为 `additionalProperties: false`。只有明确的动态键值表可以开放任意键名。
## 语义校验
`validate.ts` 中实现 JSON Schema 无法表达的规则,统一返回 `ConfigValidationIssue[]`,不要直接拼接最终错误字符串。
共享校验工具包括:
| 函数 | 用途 |
| -------------------------------- | ---------------------------- |
| `validateRawValueExpectation` | 校验 Raw ValueExpectation |
| `validateRawContentExpectations` | 校验 ContentExpectations |
| `validateRawKeyedExpectations` | 校验 KeyedExpectations |
| `validateJsonPath` | 校验项目支持的 JSONPath 子集 |
| `isJsonValue` | 判断合法 JSON value |
## normalize 规范
`normalize()``CheckerDefinition` 中定义为必需方法,负责将 authoring expect DSL 转换为 normalized 形态。输入为变量已解析后的 target输出为适配 normalized schema 的 target。该方法在 `resolve()` 和 normalized contract 校验之前执行。
`normalize.ts` 中实现 `normalizeTargetExpect` 函数,`execute.ts` 中的 `normalize` 方法委托到该函数。
共享 normalize helper 位于 `src/server/checker/expect/normalize.ts`
| 函数 | 用途 |
| ------------------ | -------------------------------------------------------- |
| `compactExpect` | 合并两个 expect record过滤 undefined 字段 |
| `normalizeValue` | ValueMatcher 原始值简写展开为 `{equals: value}` |
| `normalizeContent` | ContentExpectations 简写展开为 normalized 形态 |
| `normalizeKeyed` | KeyedExpectations 对象形态展开为 `[{key, matcher}]` 数组 |
```typescript
import { compactExpect, normalizeContent, normalizeKeyed, normalizeValue } from "../../expect/normalize";
export function normalizeTargetExpect(target: RawTargetConfig): RawTargetConfig {
if (target.expect === undefined || !isPlainObject(target.expect)) return target;
const raw = target.expect as Record<string, unknown>;
return {
...target,
expect: compactExpect(raw, {
/* checker 专属字段映射 */
}),
};
}
```
expect 字段的归一化规则ValueMatcher 字段调用 `normalizeValue()`ContentExpectations 字段调用 `normalizeContent()`KeyedExpectations 字段调用 `normalizeKeyed()`boolean/enum/array 等非断言模型字段直接透传。
## resolve 规范
`resolve()` 只做内置默认值填充、路径解析、单位转换,不执行校验。输入已经通过 Normalized schema 和语义校验expect 已是 normalized 形态。
```typescript
const expect = target.expect as ResolvedXxxExpectConfig | undefined;
const resolvedExpect: ResolvedXxxExpectConfig = expect
? { ...expect, status: expect.status ?? [200] }
: { status: [200] };
```
返回值使用 `satisfies ResolvedXxxTarget` 确保类型正确。
## execute 规范
- 始终记录 `timestamp``start = performance.now()`
- 通过 `ctx.signal` 支持超时取消。
- 首个 expect 失败即停止,返回带 `failure` 的结果。
- 成功时 `failure: null, matched: true`
- 异常时使用 `errorFailure()`
- 不匹配时使用 `mismatchFailure()`
- `expected` 参数应传用户可读值,必要时使用 `displayValueExpectation()`
## expect 字段选择
| 场景 | 模型 |
| ------------------------------------ | ------------------- |
| 状态类结果且集合小而稳定 | enum 或 boolean |
| 单值数字指标或字符串元数据 | ValueMatcher |
| 文本、JSON、HTML、XML 或半结构化内容 | ContentExpectations |
| 动态键值表 | KeyedExpectations |
不要为了统一而把状态类字段改成 ValueMatcher。一个 expect 字段只能对应一种断言模型。
## 注册
1. 创建 `src/server/checker/runner/<type>/index.ts`
2.`src/server/checker/runner/index.ts` 添加导入。
3. 在 registry 初始化数组中添加 checker 实例。
注册后schema builder、validate、config-loader、engine、store 会自动按 registry 分发。
## 测试要求
测试文件放在 `tests/server/checker/runner/<type>/`,结构镜像源文件。
| 测试类别 | 覆盖内容 |
| -------------- | ---------------------------------------------------- |
| 契约测试 | TypeBox schema 与 JSON Schema 导出一致性 |
| 语义校验测试 | 合法和非法配置 |
| normalize 测试 | authoring expect 简写展开和 normalized contract 通过 |
| resolve 测试 | 默认值合并、路径解析、单位转换 |
| execute 测试 | 成功、失败、超时、expect 组合 |
| 注册测试 | registry 注册行为 |
| 配置加载测试 | 含新 checker 的 YAML 完整加载流程 |
## 文档和 schema 更新
新增或修改 checker 时通常需要更新:
- `probes.example.yaml`
- `probe-config.schema.json`,通过 `bun run schema` 生成
- `docs/user/checkers/<type>.md`
- `docs/user/checkers/README.md`
- `docs/user/expectations.md`,仅当断言模型、状态模型或通用规则变化
- `docs/user/configuration.md`,仅当 target 通用字段或配置加载形态变化
- `docs/development/checker.md`,仅当 checker 开发机制、测试要求或 checklist 变化
- `docs/README.md``openspec/config.yaml`,仅当文档同步规则变化
## 验证命令
新增或修改 checker 后通常需要运行:
```bash
bun run schema
bun run schema:check
bun run check
```
影响构建、Docker 或发布包时追加运行 `bun run verify`
## 完成检查清单
```text
□ checker 类型、schema、validate、normalize、resolve、execute、serialize 已实现
□ checker 已在 runner/index.ts 注册
□ 配置契约、语义校验和 JSON Schema 导出已同步
□ probes.example.yaml 已添加或更新示例
□ tests/server/checker/runner/<type>/ 已覆盖契约、校验、normalize、resolve、execute、注册和配置加载
□ docs/user/checkers/<type>.md 已添加或更新
□ docs/user/checkers/README.md 已添加或更新
□ 文档影响分析已完成,必要文档已同步
□ bun run schema 和 bun run schema:check 已通过
□ bun run check 已通过
□ bun run verify 已通过或记录未执行原因
```
## 更新触发条件
修改 checker 开发机制、目录结构、schema/validate/normalize/resolve/execute/expect 约定、测试要求、验证命令或文档同步 checklist 时,必须更新本文档。

View File

@@ -0,0 +1,130 @@
# 前端开发
本文档说明 DiAL Dashboard 的 React、TDesign、TanStack Query、组件、样式和前端测试约定。
适用场景:修改 `src/web/`、前端共享类型使用方式、Dashboard 数据流、组件结构、样式规则或前端测试。
## 技术栈
| 层面 | 技术 | 用途 |
| ------ | ------------------------------------- | ---------------------------------------------- |
| 框架 | React 19 | UI 组件开发 |
| 构建 | Bun HTML import + Vite dev server | 开发服务与生产构建 |
| 语言 | TypeScript 6 | 类型安全 |
| UI 库 | TDesign React + tdesign-icons-react | UI 组件与图标 |
| 数据层 | TanStack Query + React Query Devtools | 服务端状态管理与自动轮询 |
| 图表 | Recharts | 拨测趋势图 |
| 动画 | @number-flow/react | 倒计时数字滚动过渡 |
| 路由 | 无 | 单页面 Dashboard通过 Drawer/Tab 做页面内导航 |
不引入 React Router 或额外状态管理库。TanStack Query 承担服务端状态,组件内状态使用 `useState`
## 组件树与数据流
```text
main.tsx
└── StrictMode
└── ErrorBoundary
└── QueryClientProvider
├── App
│ ├── useThemePreference()
│ ├── useDashboard(refreshInterval)
│ ├── SummaryCards
│ └── TargetBoard
│ └── TargetGroup[]
│ └── PrimaryTable
│ └── TargetDetailDrawer
│ └── useTargetDetail()
│ ├── OverviewTab
│ └── HistoryTab
└── ReactQueryDevtools
```
## TanStack Query 规范
Query key 使用 structured array排序为 scope -> id -> 参数。
```typescript
const queryKeys = {
dashboard: () => ["dashboard", "24h", 30] as const,
meta: () => ["meta"] as const,
metrics: (targetId: number, from: string, to: string, bucket: "auto" | MetricsBucket) =>
["metrics", targetId, from, to, bucket] as const,
history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
};
```
全局面板级查询可持续刷新,详情级查询必须按 Drawer 状态和 Tab 状态条件启用。
## fetch 封装
统一使用 `fetch`,不引入 axios。错误抛异常由 TanStack Query 的 `error` 状态承接。
```typescript
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<T>;
}
```
## 组件开发规范
- 每个 React 组件一个 `.tsx` 文件,文件名使用 PascalCase。
- 组件 props 定义为 `interface XxxProps`,紧邻组件函数声明。
- 类型从 `../../shared/api` 导入,使用 `import type`
- 展示组件放在 `components/`,通过 props 接收数据,通过回调返回事件。
- 容器逻辑放在 hooks 中,组件只做数据消费。
- 列定义、排序器、筛选器、颜色阈值等常量放在 `constants/`
- 时间处理等纯函数放在 `utils/`
## 现有组件
| 组件 | 用途 |
| -------------------- | ----------------------------------------------------------------- |
| `App` | 根组件Layout + HeadMenu 骨架、主题模式、刷新控制、Skeleton 加载 |
| `ErrorBoundary` | React 错误边界 |
| `SummaryCards` | 总览统计卡片 |
| `TargetBoard` | 按分组渲染目标表格列表 |
| `TargetGroup` | 单个分组 Card + PrimaryTable |
| `TargetDetailDrawer` | 目标详情抽屉 |
| `OverviewTab` | 目标详情概览 |
| `HistoryTab` | 目标历史记录表格和分页 |
| `TrendChart` | 趋势折线图 |
| `StatusDot` | 圆形状态指示点 |
| `StatusBar` | 最近采样状态条 |
| `RefreshCountdown` | Header 刷新倒计时和手动刷新按钮 |
## 样式规范
前端基于 TDesign React 构建 UI样式开发优先级
1. TDesign 组件
2. TDesign 组件 props
3. TDesign CSS tokens`--td-*`
4. `styles.css` CSS 类
5. 自行开发组件
红线:
- 严禁在组件中使用 `style` 属性内联调整样式。
- 严禁通过 CSS 覆盖 TDesign 组件内部类名。
- 严禁使用 `!important`
- 颜色统一使用 TDesign CSS tokens不使用硬编码色值。
## 前端测试与验证
- 测试目录为 `tests/web/`,结构对应 `src/web/`
- 单元测试重点覆盖 `constants/``utils/` 和 hooks 中的纯逻辑。
- 组件测试使用 jsdom 和 `@testing-library/react`
- 测试用户行为而非实现细节。
- 只 mock 系统边界,例如 `fetch`
- 使用真实的 QueryClientProvider 包裹依赖 TanStack Query 的组件。
- 异步错误断言使用 helper 或显式 try/catch避免依赖 Bun `expect(...).rejects``await-thenable` 规则的类型不匹配。
- 组件测试环境由 `tests/setup.ts``bunfig.toml` preload 提供,包含 ResizeObserver、IntersectionObserver、matchMedia、attachEvent 和 Recharts mock。
前端逻辑变更通常需要运行 `bun run check`。影响生产静态资源、前后端集成或构建流程时运行 `bun run verify`
## 更新触发条件
修改前端技术栈、组件边界、数据流、样式规则、测试环境或前端验证方式时,必须更新本文档。

127
docs/development/release.md Normal file
View File

@@ -0,0 +1,127 @@
# 构建与发布
本文档说明开发服务、前后端集成、生产构建、Docker 镜像、跨平台 release 和相关脚本维护方式。
适用场景:修改 `scripts/`、构建流程、Dockerfile、静态资源集成、release 打包、运行时环境变量或部署产物。
## 开发期运行
```bash
bun run dev probes.yaml
```
`scripts/dev.ts` 同时启动两个进程:
| 进程 | 用途 |
| --------------- | ------------------------------------------------- |
| Bun API server | 后端 API 服务,`--watch` 监听后端文件变更自动重启 |
| Vite dev server | 前端 SPA、HMR、模块热替换 |
也可以单独启动:
```bash
bun run dev:server probes.yaml
bun run dev:web
```
## 前后端集成
开发模式下Vite 通过 proxy 将 `/api/*``/health` 转发到 Bun。
生产模式下,前端通过 Vite 构建为静态资源,通过 `import with { type: "file" }` 嵌入 Bun 可执行文件。非 API 路径由 fetch fallback 处理:有文件扩展名的返回静态资源或 404无扩展名的返回 SPA index.html。
## 构建
```bash
bun run build
```
构建流程:
```text
1. Vite build -> dist/web/
2. Code generation -> .build/static-assets.ts + .build/server-entry.ts
3. Bun compile -> dist/dial-server
```
构建参数:
| 环境变量 | 说明 |
| -------------- | ---------------- |
| `BUN_TARGET` | 交叉编译目标平台 |
| `BUILD_TARGET` | 交叉编译目标平台 |
## Docker 镜像
Docker 镜像使用 Alpine 多阶段构建,保持与生产单可执行文件交付模型一致。
```text
oven/bun:1-alpine -> bun install --frozen-lockfile
-> BUN_TARGET=bun-linux-*-musl bun run build
-> dist/dial-server
alpine -> 仅复制 /usr/local/bin/dial-server
-> 安装 ca-certificates、iputils-ping、libgcc、libstdc++、tzdata
-> 使用非 root dial 用户运行
```
Dockerfile 通过 `TARGETARCH` 选择 Bun compile target。
| `TARGETARCH` | `BUN_TARGET` |
| ------------ | ---------------------- |
| `amd64` | `bun-linux-x64-musl` |
| `arm64` | `bun-linux-arm64-musl` |
## Release
```bash
bun run release
bun run release --target linux-x64
bun run release --target linux-x64,windows-x64,darwin-arm64
```
release 流程:
```text
1. Vite build -> dist/web/
2. Code generation -> .build/
3. 多目标 Bun compile -> dist/release/binaries/
4. tar.gz 打包 -> dist/release/packages/
```
支持的平台见 [用户部署文档](../user/deployment.md#跨平台发布包)。
## 脚本说明
| 脚本 | 文件 | 说明 |
| ---------------------- | ----------------------------------- | ------------------------------ |
| `bun run dev` | `scripts/dev.ts` | 双进程开发服务 |
| `bun run dev:server` | `src/server/dev.ts` | 仅启动后端 API server |
| `bun run dev:web` | Vite CLI | 仅启动 Vite dev server |
| `bun run build` | `scripts/build.ts` | Vite -> codegen -> Bun compile |
| `bun run release` | `scripts/release.ts` | 多目标交叉编译和打包 |
| `bun run schema` | `scripts/generate-config-schema.ts` | 生成配置 JSON Schema |
| `bun run schema:check` | `scripts/generate-config-schema.ts` | 检查配置 JSON Schema 同步 |
| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 |
## 维护约定
- `scripts/build-common.ts` 中的 import specifier 输出必须使用 `/` 分隔符。
- 跨平台路径测试不得用当前平台 `path.sep` 伪装其他平台,应使用 `node:path.win32` 或等价注入方式模拟。
- 如本地 Docker 环境不支持 buildx 或多架构模拟,需在变更记录中说明未执行原因。
## 发布验证
| 变更类型 | 验证方式 |
| ---------------- | --------------------------------------- |
| 构建脚本 | `bun run verify` |
| release 脚本 | `bun run release` 或指定受影响 target |
| Dockerfile | 本地 `docker build`,无法执行时说明原因 |
| 静态资源集成 | `bun run build`,必要时启动产物手动验证 |
| 配置 schema 同步 | `bun run schema:check` |
影响用户部署方式、Docker 运行参数、发布包内容或运行时依赖时,必须同步更新 [用户部署文档](../user/deployment.md)。
## 更新触发条件
修改开发服务、前后端集成、构建产物、Docker 镜像、release target、脚本参数或发布验证方式时必须更新本文档。

View File

@@ -7,7 +7,6 @@
| 文件 | 用途 |
| ------------------------------------------------------ | ------------------------------------------------------------------------ |
| [prompt-smart-merge.md](prompt-smart-merge.md) | 批量合并 `dev*` 分支到目标分支,含规则探测、依赖分析、冲突处理、安全回退 |
| [prompt-spec-review.md](prompt-spec-review.md) | 审查和整理 `openspec/specs/` 下的稳定规范,提升可检索性和一致性 |
| [prompt-proposal-review.md](prompt-proposal-review.md) | 审查 proposal/design/tasks/specs 与讨论、代码现状、OpenSpec 规范的一致性 |
| [prompt-apply-review.md](prompt-apply-review.md) | 审查 apply 后代码、测试、变更文档的一致性,并补齐遗漏或回写文档 |
@@ -85,7 +84,7 @@
- 是否以代码、文档、讨论或用户确认为准
- 何时必须使用提问工具确认
- 删除、重写前是否必须备份
- 改动后是否必须同步 README、测试、变更文档
- 改动后是否必须同步相关用户文档、开发文档、测试、变更文档
### 4. 计划与执行分离
@@ -124,7 +123,7 @@
- 作用域边界:改什么,不改什么
- 真相来源优先级:代码 / README / spec / 讨论 / 用户确认
- 风险动作边界删除、重写、提交、推送、回退、stash、merge 等
- 同步要求:测试、README、变更文档、现有 spec 是否要同步
- 同步要求:测试、用户文档、开发文档、变更文档、现有 spec 是否要同步
- 降级规则:信息不足时如何处理
避免:
@@ -142,7 +141,7 @@
推荐做法:
- 先读仓库规则来源,如 `README.md`、配置、架构文档、近期提交、任务入口
- 先读仓库规则来源,如 `README.md``DEVELOPMENT.md``CONTRIBUTING.md``docs/README.md`配置、架构文档、近期提交、任务入口
- 先读直接相关 artifacts再扩展到相关代码和测试
- 需要探测时,要求 AI 先探测再决定,不把仓库结构写死在提示词里

View File

@@ -1,25 +1,57 @@
审查 OpenSpec apply 完成后以及后续手动修补后的实际实现,判断代码、测试、变更文档是否一致,识别偏离、漏记和可优化点,并将确认后的实际变更同步回变更文档,按以下流程执行。
审查 OpenSpec apply 完成后以及后续手动修补后的实际变更,判断实际产物、验证结果和变更文档是否与 `design.md` source of truth 一致,识别偏离、漏记和可优化点,并将确认后的实际变更同步回变更文档,按以下流程执行。
## 约束
- 先审查再修复;未经用户确认,不修改代码或变更文档
- 默认按 `spec-driven` workflow 审查;识别 change 后先确认 `schemaName`;若实际 schema 不同,说明差异,仅对实际存在的 artifacts 做审查
- 优先使用当前会话中的实现说明、测试结论、手动修补记录和已生成的变更文档;仅在无法明确 change、`schemaName`、改动范围或修补来源时,再用提问工具或 OpenSpec 命令补充定位
- 不要因为代码已经存在就自动以代码为准;先判断差异属于"文档要求未实现"、"测试后新增修补"还是"意外偏离/回归"
- 每批代码或文档修改执行前用提问工具获得用户确认
- 先审查再修复;未经用户确认,不修改实际产物或变更文档
- 默认按 `fast-drive` workflow 审查;识别 change 后先确认 `schemaName`;若实际 schema 不同,说明差异,仅对实际存在的 artifacts 做审查
- `fast-drive` workflow 下,核心 artifacts 是 `design.md``tasks.md`;不要要求存在 `proposal.md``specs/*.md`
- `fast-drive` workflow 下,`design.md` 是 scope、requirements、decisions、guardrails、execution direction 和 verification expectations 的 source of truth`tasks.md` 是 apply 进度和验证闭环的 tracking 文件
- 禁止同步或修改 `openspec/specs/` 下的主规范;若实际 schema 包含 `specs/*.md`,也只允许修改本次 change 目录下实际存在的 spec artifacts主规范同步属于 archive 阶段,不在此提示词范围内
- 优先使用当前会话中的执行说明、验证结论、手动修补记录和已生成的变更文档;仅在无法明确 change、`schemaName`、改动范围或修补来源时,再用提问工具或 OpenSpec 命令补充定位
- 不要因为实际产物已经存在就自动以实际产物为准先判断差异属于“design 要求未完成”、“验证后新增修补”、“合理落地细化”还是“意外偏离/回归”
- 每批实际产物或文档修改执行前用提问工具获得用户确认
- 删除/重写前用提问工具获得用户确认,并先备份原文件为 `{file}.bak.{timestamp}`
- 若修改代码涉及新逻辑、模块结构、API、实体或用户可见行为,同步更新测试、相关变更文档和 README
- 若修改实际产物涉及新行为、流程、接口、内容、数据、配置、责任边界或用户可见结果,同步更新验证材料、相关变更文档和必要的文档/沟通材料
## 1. 收集
并行读取:
读取约束
- 本次 change 的实际 artifacts`spec-driven` 下通常包括 `proposal.md``design.md``tasks.md``specs/*.md`
- 当前会话中与本次变更相关的实现说明、apply 过程中的偏离、测试失败、手动修补原因、待确认事项
- 与本次变更相关的代码和测试文件;优先依据 `git diff --name-only``git diff --name-only --cached``tasks.md`、Impact、失败测试栈定位若工作区已干净再结合文档和代码模块反推
- 最近一次相关测试命令、测试结果、失败信息和修补后的验证结果
- `openspec/config.yaml`
- 与本次改动相关的 README、架构文档以及现有 `openspec/specs/**/spec.md` 中与本次变更相关的规范,相关性来源包括:`proposal.md``Capabilities` / `Modified Capabilities`、手动修补涉及的受影响能力、`design.md` / Impact 中提到的模块、相关代码对应的现有能力
- 直接使用 Read 工具并行读取文件,禁止使用 subagent/Task 工具做文件读取和内容转发
- 不原样输出文件内容,仅在步骤 2 输出审查结论
分步收集:
a) 先并行读取核心入口和配置,确定范围:
- 本次 change 的 `design.md`
- 本次 change 的 `tasks.md`
- workflow context/configuration例如存在时读取 `openspec/config.yaml`
- 若可定位到 schema读取对应 schema`fast-drive` 下优先读取 `openspec/schemas/fast-drive/schema.yaml`
b) 从 `design.md` 提取审查基准:
- `Context`
- `Discussion Notes`
- `Requirements`
- `Goals / Non-Goals`
- `Execution Guardrails`
- `Affected Areas`
- `Decisions`
- `Execution Plan`
- `Verification Plan`
- `Risks / Trade-offs`
- `Open Questions`
c) 从 `tasks.md` 提取任务状态、已完成项、未完成项、验证任务和文档/沟通任务;重点记录所有已标记完成的 `- [x]` 或等价完成状态。
d) 获取实际改动范围:若在版本控制工作区中,优先使用 `git diff --name-only``git diff --name-only --cached`;若工作区已干净或不适用版本控制,再结合 `design.md``tasks.md`、验证记录和执行记录反推。
e) 并行读取实际改动范围、`Affected Areas``Execution Plan``Verification Plan` 涉及的实际产物、参考材料、验证材料、流程说明、配置、文档或沟通材料。
f) 收集当前会话中与本次变更相关的执行说明、apply 过程中的偏离、验证失败、手动修补原因、验证命令或检查结果、待确认事项。
g) 若实际 schema 不是 `fast-drive`,只读取实际存在的 artifacts若存在 `proposal.md``specs/*.md`,再按该 schema 的要求补充读取和审查。
若当前上下文无法明确 change 或文档路径:
@@ -28,63 +60,75 @@
若已明确 change但尚未确认 `schemaName`,先读取 change 元数据或执行 `openspec status --change "{name}" --json` 确认。
若缺少测试结果或手动修补记录,明确说明本次无法可靠判断部分差异的来源,仅能基于代码与文档现状审查。
若缺少验证结果或手动修补记录,明确说明本次无法可靠判断部分差异的来源,仅能基于实际产物与文档现状审查。
## 2. 分析
按以下优先级检查:
| 优先级 | 维度 | 检查点 |
| ------ | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| P0 | 实际实现与测试结论 | 当前代码的真实行为是什么apply 后是否有手动改动或测试后修补;测试是否证明这些实现有效;若缺少测试结果,标记相关结论为"未验证";检查是否存在回归、未覆盖场景或被掩盖的问题 |
| P1 | 文档同步性 | 对实际存在的 artifacts 检查已落地的实现、测试后新增修补、边界处理、异常路径、验证结论是否已同步回变更文档若影响模块结构、API、实体或用户可见行为再检查 README 是否同步 |
| P2 | 文档要求覆盖 | 对实际存在的 artifacts 检查文档中承诺的目标、方案、Requirement、Scenario 是否都已实现;在 `spec-driven` 下重点检查 `proposal.md``design.md``specs/*.md``tasks.md` |
| P3 | 实现质量 | 代码结构、复用、命名、复杂度、错误处理、测试质量、与项目现有模式的一致性是否存在明显问题或可优化点 |
| 优先级 | 维度 | 检查点 |
| ------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| P0 | 实际变更与验证结论 | 当前实际产物的真实状态是什么apply 后是否有手动改动或验证后修补;验证是否证明这些变更有效;若缺少验证结果,标记相关结论为未验证;检查是否存在回归、未覆盖场景或被掩盖的问题 |
| P1 | `design.md` 一致性 | 实际变更是否符合 `Requirements``Goals / Non-Goals``Execution Guardrails``Decisions``Execution Plan``Verification Plan``Open Questions` 是否已明确区分 blocking / non-blocking 或写出 `None`;是否违反被明确否决的方案、用户偏好或约束 |
| P2 | `tasks.md` 真实性 | 已完成任务是否真的完成并完成必要验证未完成任务是否仍然必要apply 或手动修补是否引入了需要补充的新任务、验证任务或文档/沟通任务 |
| P3 | 文档回写完整性 | 已落地的实际变更、验证后新增修补、边界处理、异常路径、验证结论、实际触达产物是否已同步回 `design.md``tasks.md`;若影响行为、流程、接口、内容、数据、配置、责任边界或用户可见结果,再检查必要的文档/沟通材料是否同步 |
| P4 | 质量与可维护性 | 实际产物的结构、清晰度、一致性、可维护性、风险处理、移交质量、验证质量、与现有模式的一致性是否存在明显问题或可优化点 |
| P5 | Schema 兼容性 | 对实际存在的 artifacts 检查是否符合其 schema若不是 `fast-drive`,仅按实际 artifacts 检查,不凭空要求 `fast-drive` 专属结构;最终 artifacts 是否仍保留模板注释、空表格行或占位任务文本 |
分析时区分类差异:
分析时区分类差异:
- 文档要求已明确,但代码未实现或实现不完整 → 需补充代码或测试
- 代码因测试暴露问题、手动修补或合理落地细化而新增/变更 → 需回写文档
- 代码与文档不一致,且无法判断应以哪边为准 → 列入待确认清单
- `design.md` 要求已明确,但实际变更未完成或完成不充分 → 需补充实际工作或验证
- 实际变更因验证暴露问题、手动修补或合理落地细化而新增/变更 → 需回写 `design.md` 和/或 `tasks.md`
- 实际变更与 `design.md` 不一致,且无法判断应以哪边为准 → 列入待确认清单
- `tasks.md` 状态与实际完成情况或验证结果不一致 → 修正任务状态或补充任务
不要把以下情况直接视为合理修补:
- 通过 `skip``only`、弱化断言、绕过错误处理来让测试通过
- 为了贴合现有代码而降低已确认的 Requirement 或行为约束
- 未经过讨论和验证就扩大功能范围
- 通过跳过、弱化或绕过验证来声称变更完成
- 为了贴合当前实际产物而降低已确认的 requirement、acceptance criteria 或 guardrail
- 未经过讨论和验证就扩大功能、流程、内容或责任范围
- 违反 `Execution Guardrails`、被拒绝方案或 `Open Questions` 中尚未解决的 blocker
重点识别:
- 文档要求但未落地的功能、场景、异常处理或验证步骤
- apply 完成后新增的代码修补、边界处理、接口调整、行为变化未同步到文档
- `tasks.md` 标记完成,但代码、测试或文档未闭环
- `Modified Capabilities` 应更新但未更新的现有 spec
- 代码存在明显的重复、复杂度过高、命名不清、错误处理薄弱、测试质量不足等问题
- `design.md` 要求但未落地的结果、流程、内容、场景、异常处理、文档/沟通更新或验证步骤
- 实际变更偏离 `Goals / Non-Goals``Execution Guardrails``Decisions``Execution Plan` 的地方
- apply 完成后新增的修补、边界处理、接口调整、行为变化、流程变化或内容变化未同步到 `design.md`
- `Affected Areas` 与实际改动范围不一致,导致新会话无法复盘真实影响面
- `Verification Plan` 中要求的验证、质量检查、审阅、批准、沟通检查或 manual checks 未执行或未记录
- `tasks.md` 标记完成,但实际产物、验证、文档或沟通未闭环
- `design.md``tasks.md` 仍保留 `<!-- ... -->` 模板注释、空表格行、`Replace with...``TBD``TODO` 等未解决占位内容
- 必要的文档/沟通材料未同步影响行为、流程、接口、内容、数据、配置、责任边界或用户可见结果的变更
- 实际产物存在明显的重复、复杂度过高、表达不清、责任不明、风险处理薄弱、验证质量不足等问题
- `fast-drive` change 中仍错误依赖 `proposal.md``specs/*.md``Capabilities``Modified Capabilities` 的内容
输出审查结果:
1. **问题总览表**:问题类型 × 涉及文件数
2. **实际改动与修补清单**:本次实现中已落地的主要功能、后续修补和验证结论;若缺少测试结果,对未验证部分单独标记
3. **未覆盖清单**:文档要求但未在代码中实现或未充分验证的内容
4. **需回写文档清单**代码和测试中已确认、但文档未体现的实现、修补或约束变化
5. **方向待确认清单**代码与文档不一致,且无法判断应以哪边为准的事项
2. **实际变更与修补清单**:本次已落地的主要变更、后续修补和验证结论;若缺少验证结果,对未验证部分单独标记
3. **Design 偏离清单**:实际变更未完成、完成不充分或偏离 `design.md` 的内容
4. **需回写文档清单**实际产物和验证中已确认、但 `design.md``tasks.md` 或相关文档/沟通材料未体现的变更、修补或约束变化
5. **方向待确认清单**实际变更与 `design.md` 不一致,且无法判断应以哪边为准的事项
6. **任务状态问题清单**:未真正完成、状态错误或需补充的新任务
7. **测试问题清单**:缺失覆盖、掩盖错误、验证不足或修补后未回归验证的测试问题
8. **代码质量/优化清单**:可优化的实问题和建议
9. **逐项分析**:每个问题说明位置、问题、影响、建议和建议修复方向
7. **验证问题清单**:缺失覆盖、掩盖错误、验证不足或修补后未回归验证的问题
8. **质量/优化清单**:可优化的实际产物问题和建议
9. **Schema 差异清单**:实际 schema 与默认 `fast-drive` 不同时,列出因此跳过或改按实际 artifacts 审查的内容
10. **逐项分析**:每个问题说明位置、问题、影响、建议和建议修复方向
若所有清单均为空,输出"审查通过,未发现问题",跳至步骤 5。
若所有清单均为空,输出审查通过,未发现问题,跳至步骤 5。
## 3. 计划(用户确认)
先针对"方向待确认清单"用提问工具逐项向用户确认。
先针对方向待确认清单用提问工具逐项向用户确认。
再整理完整修复方案,按类别列出:
- 代码或测试补充:补实现、补异常处理、补回归测试、修复掩盖错误的测试
- 文档回写:同步 `proposal.md``design.md``tasks.md``specs/*.md`、README 中遗漏或过时的内容
- 实际工作或验证补充:补完成、补异常处理、补回归验证、修复被弱化或绕过的验证
- Design 回写:同步 `design.md` 中遗漏或过时的 requirements、guardrails、affected areas、decisions、execution plan、verification plan、risks 或 open questions
- 任务状态修正:修正已完成/未完成状态,补充 apply 后新增但已完成的修补任务或验证任务
- 代码质量优化:在不改变目标行为的前提下优化结构、复用、命名或可维护性
- 文档/沟通同步:同步行为、流程、接口、内容、数据、配置、责任边界或用户可见结果变化
- 质量优化:在不改变目标结果的前提下优化结构、表达、一致性、可维护性或移交质量
- Schema 兼容处理:若实际 schema 不是 `fast-drive`,按实际存在的 artifacts 说明额外文档同步项
对每个拟修改的文件说明:
@@ -98,33 +142,38 @@
## 4. 执行
逐项执行已确认的代码、测试和文档修复。
逐项执行已确认的实际产物、验证和文档修复。
若涉及删除或重写:
- 先创建备份文件 `{file}.bak.{timestamp}`
- 再执行修改
若修改了代码或测试
若修改了实际产物或验证材料
- 同步更新相关变更文档;若影响模块结构、API、实体或用户可见行为,再同步 README
- 运行相关测试;若修补影响范围较大,再补充执行受影响的回归测试
- 同步更新相关变更文档;若影响行为、流程、接口、内容、数据、配置、责任边界或用户可见结果,再同步必要的文档/沟通材料
- 运行或执行相关验证;若修补影响范围较大,再补充执行受影响的回归验证
若修改了文档:
- 确认实际存在的变更文档之间保持一致;在 `spec-driven` 下重点检查 `proposal.md``design.md``tasks.md``specs/*.md`
- 若 apply 后新增修补改变了能力边界或行为约束,同步更新 `Capabilities` / `Modified Capabilities`
- `fast-drive` workflow 下,确认 `design.md` 仍是 source of truth`tasks.md` 仍从 `design.md` 派生
- 确认 `design.md` 的 requirements、guardrails、affected areas、decisions、execution plan、verification plan、risks 和 open questions 与实际变更一致
- 确认 `tasks.md` 每个完成任务都有对应实际产物和必要验证,新增修补已补充任务或记录在合适任务中
- 禁止将本次 change 内容同步到 `openspec/specs/`,该操作属于 archive 阶段
-`fast-drive` workflow 下不创建 `proposal.md``specs/*.md`;若实际 schema 不是 `fast-drive`,则按实际 schema 的 required artifacts 创建或更新本次 change 目录下的 artifacts
执行后重新读取所有被修改的代码、测试和文档,并复核:
执行后重新读取所有被修改的实际产物、验证材料和文档,并复核:
- "未覆盖清单" 是否已清空或已标注保留原因
- "需回写文档清单" 是否已清空
- "方向待确认清单" 是否已清空或已记录用户决策
- "任务状态问题清单""测试问题清单" 是否已清空或已标注残留原因
- "代码质量/优化清单" 中哪些已处理,哪些有意延期
- “Design 偏离清单 是否已清空或已标注保留原因
- 需回写文档清单 是否已清空
- 方向待确认清单 是否已清空或已记录用户决策
- 任务状态问题清单“验证问题清单 是否已清空或已标注残留原因
- 质量/优化清单 中哪些已处理,哪些有意延期
- 必要的文档/沟通材料是否已按影响范围同步
- 所有模板注释、空表格行和占位文本是否已清空或替换为有效内容
## 5. 收尾
列出所有修改的文件、备份文件、测试命令与结果、文档同步摘要和剩余风险。
列出所有修改的文件、备份文件、验证命令或检查结果、文档同步摘要和剩余风险。
若本次因缺少测试结果、修补记录或上下文而降级执行,或有问题因信息不足暂未处理,单独说明。
若本次因缺少验证结果、修补记录或上下文而降级执行,或有问题因信息不足暂未处理,单独说明。

View File

@@ -1,22 +1,48 @@
审查本次 OpenSpec 变更文档是否与前序讨论、当前代码现状和 OpenSpec 文档规范一致,识别遗漏、冲突和不合理假设,给出可执行的补充建议,按以下流程执行。
审查本次 OpenSpec 变更文档是否与前序讨论、当前实际状态和实际 OpenSpec workflow 一致,重点检查 `fast-drive` workflow 下的 `design.md` 是否足以在上下文压缩或新会话中指导后续 `apply`,并识别遗漏、冲突和不合理假设,给出可执行的补充建议,按以下流程执行。
## 约束
- 仅修改本次变更文档,不修改源码
- 默认按 `spec-driven` workflow 审查;识别 change 后先确认 `schemaName`;若实际 schema 不同,说明差异,仅对实际存在的 artifacts 做审查
- 优先使用当前会话中的讨论和已生成的变更文档;仅在无法明确 change、`schemaName` 或文档范围时,再用提问工具或 OpenSpec 命令补充定位
- 仅修改本次变更文档,不修改实际产物
- 默认按 `fast-drive` workflow 审查;识别 change 后先确认 `schemaName`;若实际 schema 不同,说明差异,仅对实际存在的 artifacts 做审查
- `fast-drive` workflow 下,核心 artifacts 是 `design.md``tasks.md`;不要要求存在 `proposal.md``specs/*.md`
-`fast-drive` workflow 下,`design.md` 是 scope、requirements、decisions、guardrails、execution direction 和 verification expectations 的 source of truth`tasks.md` 必须从 `design.md` 派生
- 优先使用当前会话中的讨论、explore/propose 阶段结论和已生成的变更文档;仅在无法明确 change、`schemaName` 或文档范围时,再用提问工具或 OpenSpec 命令补充定位
- 每批文档修改建议执行前用提问工具获得用户确认
- 删除/重写前用提问工具获得用户确认,并先备份原文件为 `{file}.bak.{timestamp}`
## 1. 收集
并行读取:
读取约束
- 本次 change 的实际 artifacts`spec-driven` 下通常包括 `proposal.md``design.md``tasks.md``specs/*.md`
- 当前会话中与本次变更相关的讨论、澄清、边界约束、非目标、待确认事项
- 与本次变更直接相关的源码、测试、README、架构文档
- `openspec/config.yaml`
- 现有 `openspec/specs/**/spec.md` 中与本次变更相关的规范,相关性来源包括:`proposal.md``Capabilities` / `Modified Capabilities`、讨论中提到的受影响能力、`design.md` / Impact 中提到的模块、相关代码对应的现有能力
- 直接使用 Read 工具并行读取文件,禁止使用 subagent/Task 工具做文件读取和内容转发
- 不原样输出文件内容,仅在步骤 2 输出审查结论
分步收集:
a) 先并行读取核心入口和配置,确定范围:
- 本次 change 的 `design.md`
- 本次 change 的 `tasks.md`
- workflow context/configuration例如存在时读取 `openspec/config.yaml`
- 若可定位到 schema读取对应 schema`fast-drive` 下优先读取 `openspec/schemas/fast-drive/schema.yaml`
b) 从 `design.md` 提取审查基准:
- `Context`
- `Discussion Notes`
- `Requirements`
- `Goals / Non-Goals`
- `Execution Guardrails`
- `Affected Areas`
- `Decisions`
- `Execution Plan`
- `Verification Plan`
- `Risks / Trade-offs`
- `Open Questions`
c) 基于 `Affected Areas``Execution Plan``Verification Plan`、讨论中提到的受影响范围,并行读取相关实际产物、参考材料、验证材料、流程说明、配置、文档或沟通材料,确认文档是否贴合当前实际状态。
d) 若实际 schema 不是 `fast-drive`,只读取实际存在的 artifacts若存在 `proposal.md``specs/*.md`,再按该 schema 的要求补充读取和审查。
若当前上下文无法明确 change 或文档路径:
@@ -25,48 +51,55 @@
若已明确 change但尚未确认 `schemaName`,先读取 change 元数据或执行 `openspec status --change "{name}" --json` 确认。
若缺少讨论记录,明确说明本次降级为"文档 + 代码现状审查",不做讨论一致性结论。
若缺少讨论记录,明确说明本次降级为文档 + 当前实际状态审查,不做讨论一致性结论。
## 2. 分析
按以下优先级检查:
| 优先级 | 维度 | 检查点 |
| ------ | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| P0 | 讨论一致性 | 仅在存在讨论记录时检查:文档是否完整覆盖已确认的目标、范围、非目标、约束、边界条件、风险、决策点、待办事项;若无讨论记录,标记为"跳过" |
| P1 | 代码现实性 | 文档对当前模块、接口、数据结构、命名、依赖、目录结构、复用路径的描述是否准确;是否把"计划变更"误写成"当前现状";是否遗漏真实受影响的现有能力 |
| P2 | 文档内部一致性 | 对实际存在的 artifacts 检查是否互相支撑;在 `spec-driven` 下重点检查 `proposal.md``design.md``tasks.md``specs/*.md``Capabilities` / `Modified Capabilities` 是否完整;每个 capability 是否有对应 spec`tasks.md` 是否覆盖 `design.md``specs/*.md` |
| P3 | OpenSpec 合规性 | 对实际存在的 artifacts 检查是否遵循 OpenSpec 格式和术语;`specs/*.md` 是否只描述行为与约束、不混入实现细节;`tasks.md` 是否一行一个任务;是否混入 git 操作任务 |
| 优先级 | 维度 | 检查点 |
| ------ | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| P0 | 讨论承接性 | 仅在存在讨论记录时检查:`design.md` 是否完整记录已确认的目标、非目标、用户偏好、约束、边界条件、风险、关键决策、被否决方案和待澄清事项;若无讨论记录,标记为跳过 |
| P1 | `design.md` 自包含性 | `design.md` 是否足以让看不到前序对话的执行者继续工作;是否包含完整 required sections`Open Questions` 是否明确区分 blocking / non-blocking 或写出 `None`;是否存在依赖未记录聊天上下文的隐含要求 |
| P2 | 当前状态真实性 | `design.md` 对当前实际产物、流程、接口、内容、数据、配置、依赖、责任边界、参考材料和验证入口的描述是否准确;是否把“计划变更”误写成“当前现状”;`Affected Areas` 是否遗漏真实受影响区域 |
| P3 | `tasks.md` 派生性 | `tasks.md` 是否从 `design.md` 派生;是否覆盖 requirements、guardrails、decisions、execution plan 和 verification plan是否依赖 `proposal.md``specs/*.md` 中未写入 `design.md` 的内容 |
| P4 | OpenSpec 合规性 | 对实际存在的 artifacts 检查是否遵循对应 schema 和 OpenSpec 术语;`tasks.md` 是否一行一个 `- [ ]` checkbox 任务、按 `##` numbered headings 分组、无无关的仓库/版本控制/发布操作任务;`design.md` 是否避免 task checkbox最终 artifacts 是否仍保留模板注释、空表格行或占位任务文本 |
分析时区分两类情况:
- 文档对当前代码现状的描述错误
- 文档描述的是预期变更,本来就应当与当前代码不同
- 文档对当前实际状态的描述错误
- 文档描述的是预期变更,本来就应当与当前状态不同
重点识别:
- 讨论中已确定但文档未记录的内容
- 文档基于错误现状做出的设计或任务拆分
- 文档之间相互冲突的目标、方案、约束、任务
- `proposal -> specs -> design -> tasks` 链路中的断点
- `Modified Capabilities` 应更新但未更新的现有 spec
- 讨论中已确定但 `design.md` 未记录的内容
- `design.md` 中缺失或含糊的 requirements、acceptance criteria、guardrails、decisions、verification expectations
- `Open Questions` 未明确区分 blocking / non-blocking、与 `tasks.md` 冲突,或包含 apply 前必须解决的 blocker
- `tasks.md` 未覆盖 `design.md` 的要求、约束、执行计划、验证计划或文档/沟通更新要求
- `tasks.md` 标记了无法验证、跨行、过大、顺序错误或包含无关仓库/版本控制/发布操作的任务
- 文档仍保留 `<!-- ... -->` 模板注释、空表格行、`Replace with...``TBD``TODO` 等未解决占位内容
- 文档基于错误当前状态做出的设计或任务拆分
- 文档之间相互冲突的目标、方案、约束、任务和验证要求
- `fast-drive` change 中仍错误依赖 `proposal.md``specs/*.md``Capabilities``Modified Capabilities` 的内容
输出审查结果:
1. **问题总览表**:问题类型 × 涉及文档数
2. **讨论遗漏清单**:讨论已确定但文档未体现的内容;若缺少讨论记录,标记为"未审查"
3. **现实性问题清单**与当前代码现状不符的描述、假设或影响分析
4. **文档冲突清单**proposal、design、tasks、specs 之间的不一致
5. **OpenSpec 规范问题清单**:格式、术语、结构问题
6. **待澄清清单**仅靠讨论和代码仍无法判断的事项
7. **逐项分析**:每个问题说明位置、问题、影响、建议
8. **补充建议方案**:按文件列出建议补充/修正的内容、原因和可选方案
2. **讨论遗漏清单**:讨论已确定但 `design.md` 未体现的内容;若缺少讨论记录,标记为未审查
3. **Design 自包含性问题清单**缺失、含糊或无法指导新会话 apply 的内容
4. **当前状态问题清单**与当前实际状态不符的描述、假设或影响分析
5. **Tasks 派生与覆盖问题清单**`tasks.md` 未从 `design.md` 正确派生或覆盖不足的内容
6. **文档冲突清单**`design.md``tasks.md` 和实际存在的其他 artifacts 之间的不一致
7. **OpenSpec 规范问题清单**:格式、术语、结构问题
8. **待澄清清单**:仅靠讨论和当前状态仍无法判断的事项
9. **逐项分析**:每个问题说明位置、问题、影响、建议
10. **补充建议方案**:按文件列出建议补充/修正的内容、原因和可选方案
若所有清单均为空,输出"审查通过,未发现问题",跳至步骤 5。
若所有清单均为空,输出审查通过,未发现问题,跳至步骤 5。
## 3. 计划(用户确认)
先针对"待澄清清单"用提问工具逐项向用户确认。
先针对待澄清清单用提问工具逐项向用户确认。
再整理完整修复方案,按文件列出:
@@ -79,7 +112,9 @@
## 4. 执行
逐项修改已确认的变更文档,不修改源码
逐项修改已确认的变更文档,不修改实际产物
`fast-drive` workflow 下,通常只修改本次 change 的 `design.md``tasks.md`;若实际 schema 存在其他 artifacts仅在确有必要且用户确认后修改实际存在的 artifacts。
若涉及删除或重写:
@@ -88,9 +123,14 @@
执行后重新读取所有被修改的文档,并复核:
- "讨论遗漏清单" 是否已清空或已标注保留原因
- "现实性问题清单" 是否已清空或已标注为预期变更
- "文档冲突清单" 和 "OpenSpec 规范问题清单" 是否已清空
- 讨论遗漏清单 是否已清空或已标注保留原因
- “Design 自包含性问题清单 是否已清空
- “当前状态问题清单 是否已清空或已标注为预期变更
- “Tasks 派生与覆盖问题清单” 是否已清空
- “文档冲突清单” 是否已清空
- “OpenSpec 规范问题清单” 是否已清空
- “待澄清清单” 是否已清空或已记录用户决策
- 所有模板注释、空表格行和占位文本是否已清空或替换为有效内容
## 5. 收尾

View File

@@ -1,142 +0,0 @@
请审查并整理 `openspec/specs/` 下的稳定规范,使其成为可搜索、边界清晰、无冗余、与当前业务一致的能力索引,按以下流程执行。
## 约束
- `openspec/specs/` 描述长期稳定的业务能力、规则和外部行为,不记录变更过程、迁移说明、实现路径、内部类型名、组件 props、样式数值、层级分层等实现细节
- 用户可感知或对外暴露的契约可以保留:公开 API 路径、请求/响应字段、协议名、错误码、数据约束、交互结果
- `Requirement``Scenario` 应描述业务能力、外部行为或稳定约束,不以“使用某层/某组件/某库实现”作为标题或核心表述
- 不把当前代码自动视为唯一真相若代码、README、现有 spec 冲突且无法判断应以哪边为准,列入待确认清单,不直接改写规范
- 仅删除内容已被其他规范完整覆盖且无独立检索价值的规范;非冗余内容仅迁移、合并、拆分或重命名
- 每批重构执行前用提问工具获得用户确认;删除或重写前先备份原文件为 `{file}.bak.{timestamp}`
- 命名、Purpose、Requirement 标题都必须保留用户下一次最可能搜索的业务关键词
## 1. 收集
并行读取:
- `openspec/config.yaml`
- `README.md`以及与模块结构、API、架构相关的 README 或文档
- `openspec/specs/*/spec.md`
默认不读取 `openspec/changes/**`、历史 proposal/design/tasks 作为稳定规范整理依据;仅在用户明确要求“连同历史变更一起校对”时再纳入。
先建立索引,不直接开始改写:
| 索引 | 内容 |
| -------------- | ----------------------------------------------------------------------------- |
| `spec_index[]` | 每个 spec 的目录名、Purpose、Requirement 摘要、关键词、外部契约、疑似重叠对象 |
| `domain_map[]` | 从 README、API、模块文档中提炼的核心业务域、横切能力和术语 |
| `term_map[]` | 同义词、旧名、缩写和推荐标准术语 |
| `suspects[]` | 需要进一步对照代码或测试确认的 spec |
仅对 `suspects[]` 做定向读取:
- 读取与该 spec 对应的源码、测试、README 或架构文档
- 不对 `backend/``frontend/` 做无差别逐文件扫描
判定依据优先级:
- 当前稳定 spec 与 README 共同支持的事实,可直接视为高置信度
- 仅代码可见但 README 和 spec 未体现的内容,先判断它是稳定外部行为还是临时实现细节
- 代码、README、现有 spec 互相冲突且无法自动定夺时,进入 `待确认清单`
## 2. 审查
按 spec、Requirement、Scenario 三层检查:
| 维度 | 检查点 |
| --------- | --------------------------------------------------------------------------------- |
| 过时 | 描述的能力、术语、外部契约是否仍成立;是否存在 `TBD``TODO`、占位说明 |
| 冲突 | 不同规范是否对同一行为给出不同约束、命名或边界 |
| 重复/重叠 | 是否在文件级、Requirement 级、Scenario 级重复描述同一能力 |
| 错位 | 内容是否放错能力域;横切规则是否混入实体规范;平台实现是否混入通用能力规范 |
| 粒度 | 是否过大导致难检索,或过碎导致回答一个问题必须同时打开多个 spec |
| 术语 | 同一概念是否混用多个名字;旧名、别名、缩写是否需要归一并保留检索入口 |
| 命名/检索 | 目录名、Purpose、Requirement 标题是否准确;是否能被 README、API、业务术语直接命中 |
| 规范性 | 是否使用 SHALL/WHEN/THEN是否混入变更记录、迁移说明、内部实现或 UI/代码细节 |
| 完整性 | Purpose 是否明确;是否存在空目录、非 spec 噪音文件、无清晰归属的孤立规范 |
重构判定规则:
- 若两个 spec 回答的是同一个核心问题,或其中一个只是另一个的子集,优先合并
- 若一个 spec 混合多个独立检索意图,或同时包含横切规则与业务流程,优先拆分
- 若内容正确但目录名、Purpose 或 Requirement 标题不利于检索,优先重命名或改写标题
- 若多个术语指向同一概念,统一到一个标准术语,并在 Purpose 或 Requirement 中保留必要别名以支持搜索
- 若某段内容只是内部实现细节,且不影响外部行为理解,删除该段而不是为其单独保留 spec
- 若某个具体值同时属于外部契约与内部实现,按“是否对调用方可见、是否影响兼容性”判断是否保留
### 命名约定
命名优先复用仓库已存在的稳定术语,如 `provider``model``stats``protocol``proxy``logging``validation``migration``frontend``desktop``mysql`
| 类型 | 模式 | 示例 |
| ------------ | ---------------------------------------------------------- | -------------------------------------------------- |
| 实体生命周期 | `{entity}-management` | `provider-management``model-management` |
| 横切能力 | `{concern}``{concern}-{qualifier}` | `error-handling``structured-logging` |
| 协议/适配 | `{protocol}-{capability}``protocol-adapter-{protocol}` | `openai-protocol-proxy``protocol-adapter-openai` |
| 运行面/入口 | `{surface}``{surface}-{capability}` | `frontend``desktop-app` |
| 基础设施 | `{resource}-{operation}` | `database-migration``mysql-driver` |
命名原则:
- 1-4 个词,保持单一主题
- 优先使用业务名词,不使用 `basic``general``misc``info``data` 等泛化词
- 不使用 `crud``list``table``display` 等实现模式词,除非它本身就是外部契约的一部分
- 同一主题的命名模式保持一致,不同时混用多套前后缀
## 3. 报告
输出分析结果:
1. **问题总览表**:问题类型 × 涉及规范数
2. **规范关系表**:每个 spec 的主主题、重叠对象、冲突对象、建议动作
3. **术语归一表**:旧术语 / 别名 / 缩写 → 推荐标准术语
4. **逐项分析**:每个有问题的规范说明位置、问题、影响、建议和目标规范
5. **待确认清单**代码、README、现有 spec 冲突且无法自动定夺的事项
6. **重构方案**:按优先级分批
7. **重构后目录结构**:预期的新 `openspec/specs/` 目录树
优先级建议:
- P0删除空目录、非 spec 噪音文件、占位内容
- P1删除完全冗余规范将其内容映射到主规范
- P2合并重复/子集规范;拆分错位或过大规范
- P3重命名目录、改写 Purpose 和 Requirement 标题以提升检索性
- P4修正过时描述清理实现细节、迁移说明和变更记录
若所有问题清单为空,输出“审查通过,未发现问题”,跳至步骤 5。
## 4. 计划(用户确认)
先针对 `待确认清单` 用提问工具逐项向用户确认。
再按批次展示完整重构计划,每批必须包含:
- 操作类型:删除、重命名、迁移、合并、拆分、改写
- 路径变化:源路径 → 目标路径
- 内容映射:源 spec 的 Requirement / Scenario 将迁移到哪里
- 术语处理:哪些旧词保留为检索入口,哪些词统一替换
- 执行原因:为什么这样做更利于检索、去重和边界清晰
- 验证方式:如何确认没有丢失约束或引入新的冲突
用提问工具获得当前批次确认后再执行。
## 5. 执行
按 P0 → P4 逐批执行已确认的重构。
执行要求:
- 合并或拆分时先写目标 spec再删除或重命名源 spec
- 删除前确认其 Requirement 和 Scenario 已被完整保留、迁移或判定为纯冗余
- 每批执行后重新读取受影响的 spec并复核结构和内容
每批执行后至少验证:
- 目录结构完整,`openspec/specs/*/spec.md` 可正常读取
- 不存在未承接的 Requirement 或 Scenario
- Purpose、Requirement 标题、目录名可以直接表达主能力
- 不再包含 `TBD`、变更记录、迁移说明、内部实现细节或噪音文件
- 若本批涉及代码对照项,相关外部契约描述与当前仓库现状一致,或已列入残留待确认
收尾时输出:修改文件清单、备份文件清单、最终目录树、残留待确认事项和整理摘要。

35
docs/user/README.md Normal file
View File

@@ -0,0 +1,35 @@
# 用户文档
本文档是 DiAL 的用户使用入口说明如何阅读配置、部署、expect 规则、故障排查和各 checker 参考。
适用场景:编写 YAML 配置、部署 DiAL、理解拨测结果、排查运行问题、查询某个 checker 的字段和示例。
## 文档索引
| 文档 | 内容 |
| ---------------------------------------- | ------------------------------------------------- |
| [configuration.md](configuration.md) | YAML 顶层结构、变量、server、targets 通用字段 |
| [deployment.md](deployment.md) | 生产构建、Docker、ICMP 权限、发布包运行方式 |
| [expectations.md](expectations.md) | expect 规则、状态判定、failure、observation |
| [troubleshooting.md](troubleshooting.md) | 配置校验、变量、ICMP、CMD、Docker、证书和正则问题 |
| [checkers/README.md](checkers/README.md) | 各 checker 的配置项、expect 字段和示例 |
## 按任务阅读
| 任务 | 建议阅读 |
| --------------------- | ---------------------------------------------------------------------- |
| 首次运行 | [项目快速开始](../../README.md#快速开始)、[配置文件](configuration.md) |
| 编写配置 | [配置文件](configuration.md)、[Checker 参考](checkers/README.md) |
| 编写 expect | [校验规则](expectations.md)、对应 checker 文档 |
| 容器或生产部署 | [部署](deployment.md)、[故障排查](troubleshooting.md) |
| 排查启动或运行问题 | [故障排查](troubleshooting.md)、相关 checker 文档 |
| 查询 checker 专属字段 | [Checker 参考](checkers/README.md) |
## 用户文档更新规则
- 配置结构、变量、server、probes、targets 通用字段变化时,更新 [configuration.md](configuration.md)。
- checker 配置项、expect 字段、示例或运行行为变化时,更新 `checkers/<type>.md` 和 [checkers/README.md](checkers/README.md)。
- expect 模型、状态判定、failure、observation 或快速失败顺序变化时,更新 [expectations.md](expectations.md)。
- 构建产物运行方式、Docker 参数、镜像内置依赖、发布包结构变化时,更新 [deployment.md](deployment.md)。
- 常见错误、运行依赖、权限、证书或配置校验排查方式变化时,更新 [troubleshooting.md](troubleshooting.md)。
- 用户文档只解释“如何使用”和“用户能观察到什么”,实现细节放入 [`../development/`](../development/README.md)。

View File

@@ -0,0 +1,49 @@
# Checker 参考
Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一个 checker并配置对应的专属字段和 `expect` 规则。
适用场景:查询 checker 类型选择、专属配置、expect 字段、示例和各 checker 文档入口。
## 支持的类型
| 类型 | 用途 | 文档 |
| -------- | -------------------------------------- | ------------------- |
| `http` | HTTP/HTTPS 应用层健康检查 | [HTTP](http.md) |
| `cmd` | 执行本地命令或脚本 | [Cmd](cmd.md) |
| `db` | PostgreSQL/MySQL/SQLite 连接和查询检查 | [DB](db.md) |
| `tcp` | TCP 端口可达性和 banner 探测 | [TCP](tcp.md) |
| `udp` | UDP payload 请求-响应检查 | [UDP](udp.md) |
| `icmp` | 基于系统 `ping` 的存活、延迟、丢包检查 | [ICMP](icmp.md) |
| `dns` | 本机解析或指定 DNS server 协议级检查 | [DNS](dns.md) |
| `llm` | 大模型服务应用层健康检查 | [LLM](llm.md) |
| `ws` | WebSocket 可达性和消息交互检查 | [WS](ws.md) |
| `cpu` | 本机 CPU 使用率健康检查 | [CPU](cpu.md) |
| `memory` | 本机系统内存使用状况检查 | [Memory](memory.md) |
## 选择建议
| 目标 | 推荐 checker |
| ---------------------------------- | ------------ |
| Web API、网页、HTTP 状态码或响应体 | `http` |
| 本机脚本、外部命令、CLI 工具 | `cmd` |
| 数据库连接或查询结果 | `db` |
| 端口是否可连接、服务 banner | `tcp` |
| UDP 服务响应或简单心跳 | `udp` |
| 主机可达性、延迟、丢包率 | `icmp` |
| 域名解析值、DNS RCODE、TTL、flags | `dns` |
| LLM API 是否可用、输出是否符合预期 | `llm` |
| WebSocket 可达性或消息交互验证 | `ws` |
| 本机 CPU 使用率健康检查 | `cpu` |
| 本机系统内存使用状况检查 | `memory` |
## 通用字段
所有 checker 都共享 target 通用字段,见 [配置文件](../configuration.md#targets-通用字段)。
## 通用断言模型
各 checker 的 `expect` 字段复用 `ValueMatcher``ContentExpectations``KeyedExpectations`。详情见 [校验规则](../expectations.md)。
## 更新触发条件
新增、移除或修改 checker 类型、用途、选择建议、通用字段或通用断言模型时必须更新本文档。checker 专属字段变化还必须同步更新对应 `checkers/<type>.md`

38
docs/user/checkers/cmd.md Normal file
View File

@@ -0,0 +1,38 @@
# Cmd Checker
`type: cmd` 用于执行本地命令或脚本并校验退出码、stdout、stderr 和耗时。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| ---------- | ------------------------------------ | ---- | ------ |
| `cmd.exec` | 可执行文件名或路径 | 是 | 无 |
| `cmd.args` | 命令行参数列表 | 否 | `[]` |
| `cmd.env` | 环境变量覆盖,继承进程环境变量并合并 | 否 | 无 |
| `cmd.cwd` | 工作目录,相对于配置文件所在目录 | 否 | 无 |
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | --------------------------------------------- | ---- | ------ |
| `exitCode` | 可接受的退出码列表 | 否 | `[0]` |
| `stdout` | 标准输出校验,使用 `ContentExpectations` 数组 | 否 | 无 |
| `stderr` | 标准错误校验,使用 `ContentExpectations` 数组 | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
```yaml
- id: "bun-script"
name: "Bun 脚本检查"
type: cmd
cmd:
exec: "bun"
args: ["-e", "console.log('ok')"]
expect:
exitCode: [0]
stdout:
- contains: "ok"
```
Docker 官方镜像不内置常见外部命令。容器内使用 CMD checker 时,按需通过派生镜像安装依赖命令。

74
docs/user/checkers/cpu.md Normal file
View File

@@ -0,0 +1,74 @@
# CPU Checker
`type: cpu` 用于检查本机 CPU 使用率,基于两次系统快照计算总体和每核心的忙碌比例。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| -------------------- | -------------------------------- | ---- | ------- |
| `cpu.sampleDuration` | CPU 采样窗口,支持时长格式 | 否 | `1s` |
| `cpu.includePerCore` | 是否在结果中输出每核心使用率数组 | 否 | `false` |
`sampleDuration` 必须小于 target 的 `timeout`
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| --------------------- | ----------------------------------------------------------------------------------------------- | ---- | ------ |
| `usagePercent` | 总体 CPU 使用率,范围 `0-100`,使用 `ValueMatcher` | 否 | 无 |
| `idlePercent` | 总体 CPU 空闲率,与 `usagePercent` 互补,两者之和恒为 100`idlePercent + usagePercent = 100` | 否 | 无 |
| `maxCoreUsagePercent` | 单核心最高使用率,使用 `ValueMatcher` | 否 | 无 |
| `minCoreUsagePercent` | 单核心最低使用率,使用 `ValueMatcher` | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
所有百分比字段范围为 `0-100`,表示所有可见逻辑 CPU 的总体比例,不是"核心数 × 100"。
## 示例
```yaml
- id: "local-cpu"
name: "本机 CPU"
type: cpu
interval: "30s"
timeout: "5s"
cpu:
sampleDuration: "1s"
expect:
usagePercent:
lte: 85
maxCoreUsagePercent:
lte: 95
```
输出每核心使用率:
```yaml
- id: "local-cpu-detail"
name: "本机 CPU 详细"
type: cpu
cpu:
sampleDuration: "2s"
includePerCore: true
expect:
usagePercent:
lte: 80
```
## 语义说明
CPU checker 采集的是 DiAL 进程运行环境通过系统 API`os.cpus()`)可见的 CPU 视图。在容器中,它可能不等于严格的 cgroup quota 使用率。
`usagePercent``idlePercent` 互补,恒等于 100。`sampleDuration` 决定了两次快照之间的等待时间,窗口越长结果越稳定,但会增加 checker 执行耗时。
## 不支持的功能
- CPU 温度、电源状态、频率
- `userPercent` / `systemPercent`(用户态/系统态占比)
- `loadAverage`(系统负载均值)
- 进程级 CPU 使用率
- Linux cgroup 精确 CPU 计算
- `logicalCoreCount` 作为 expect 字段(仅在 observation 中输出)
## 更新触发条件
修改 CPU checker 配置、expect 字段、行为或语义时,必须更新本文档。

38
docs/user/checkers/db.md Normal file
View File

@@ -0,0 +1,38 @@
# DB Checker
`type: db` 用于数据库连接和查询结果检查,支持 PostgreSQL、MySQL 和 SQLite。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| ---------- | ------------------------------------------------------------- | ---- | ------ |
| `db.url` | 数据库连接字符串,支持 `postgres://``mysql://``sqlite://` | 是 | 无 |
| `db.query` | SQL 查询语句,不配置时仅测试连接 | 否 | 无 |
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | ----------------------------------------------------------------------- | ---- | ------ |
| `rowCount` | 查询返回行数校验,使用 `ValueMatcher` | 否 | 无 |
| `rows` | 查询结果逐行校验,数组内每行为列名到 `KeyedExpectations` 的映射 | 否 | 无 |
| `result` | 完整查询结果 `{ rows, rowCount }` 校验,使用 `ContentExpectations` 数组 | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
```yaml
- id: "sqlite-query"
name: "SQLite 数据库检查"
type: db
db:
url: "sqlite:///path/to/db.sqlite"
query: "SELECT COUNT(*) as cnt FROM users WHERE status = 'active'"
expect:
durationMs:
lte: 5000
rowCount:
gte: 1
rows:
- cnt:
gte: 0
```

104
docs/user/checkers/dns.md Normal file
View File

@@ -0,0 +1,104 @@
# DNS Checker
`type: dns` 支持两种解析模式:本机解析器检查和指定 DNS server 协议级检查。
## resolver 模式
| 模式 | 说明 |
| -------- | ----------------------------------------------------------------------------- |
| `system` | 使用本机 DNS 解析器检查域名是否能解析到预期地址 |
| `server` | 直接向指定 DNS server 发起 UDP/TCP 深度拨测,检查 RCODE、TTL、flags、记录值等 |
## `dns.resolver: system` 配置项
| 字段 | 说明 | 必填 | 默认值 |
| -------------- | ----------------------------- | ---- | -------- |
| `dns.resolver` | 解析模式 | 是 | `system` |
| `dns.name` | 待解析域名 | 是 | 无 |
| `dns.family` | 地址族:`any``ipv4``ipv6` | 否 | `any` |
### system 模式 expect
| 字段 | 说明 | 断言模型 |
| ------------ | -------------------- | --------------------------------- |
| `values` | 解析结果地址集合断言 | DNS 集合include/exclude/exact |
| `valueCount` | 解析结果数量 | ValueMatcher |
| `durationMs` | 解析耗时 | ValueMatcher |
```yaml
- id: "dns-system-api"
name: "本机 DNS 解析"
type: dns
dns:
resolver: system
name: "api.example.com"
family: any
expect:
values:
exact:
- "203.0.113.10"
durationMs:
lte: 500
```
## `dns.resolver: server` 配置项
| 字段 | 说明 | 必填 | 默认值 |
| ---------------------- | --------------------------------- | ---- | -------- |
| `dns.resolver` | 解析模式 | 是 | `server` |
| `dns.server` | DNS server 地址 | 是 | 无 |
| `dns.name` | 查询域名 | 是 | 无 |
| `dns.port` | DNS server 端口 | 否 | `53` |
| `dns.protocol` | 传输协议:`udp``tcp` | 否 | `udp` |
| `dns.recordType` | DNS 记录类型 | 否 | `A` |
| `dns.recursionDesired` | 是否设置 RD flag | 否 | `true` |
| `dns.tcpFallback` | UDP 响应 TC=1 时是否 TCP fallback | 否 | `true` |
| `dns.maxResponseBytes` | 响应最大字节数 | 否 | `4KB` |
`recordType` 可选值:`A``AAAA``CNAME``NS``MX``TXT``SOA``SRV``CAA``PTR`
### server 模式 expect
| 字段 | 说明 | 断言模型 |
| -------------------- | ---------------------------------- | --------------------------------- |
| `responded` | 是否收到 DNS response | boolean |
| `rcode` | 期望 RCODE 列表,如 `NOERROR` | string[] |
| `values` | 目标类型记录值集合断言 | DNS 集合include/exclude/exact |
| `valueCount` | 目标类型记录数量 | ValueMatcher |
| `answerCount` | answer section 总记录数 | ValueMatcher |
| `ttlMin` | answer 中最小 TTL | ValueMatcher |
| `ttlMax` | answer 中最大 TTL | ValueMatcher |
| `authoritative` | AA flag | boolean |
| `recursionAvailable` | RA flag | boolean |
| `truncated` | TC flag | boolean |
| `authenticatedData` | AD flag | boolean |
| `result` | 完整结构化响应的 JSONPath 兜底断言 | ContentExpectations |
| `durationMs` | 完整查询耗时 | ValueMatcher |
```yaml
- id: "dns-server-api"
name: "Cloudflare DNS A 记录"
type: dns
dns:
resolver: server
server: "1.1.1.1"
name: "api.example.com"
recordType: A
expect:
rcode: ["NOERROR"]
values:
include:
- "203.0.113.10"
ttlMin:
gte: 60
durationMs:
lte: 200
```
## 注意事项
- 未配置 expect 时,`system` 模式默认要求解析成功且 `valueCount > 0``server` 模式默认要求 `NOERROR + valueCount > 0`
- 显式配置非 `NOERROR` rcode`NXDOMAIN`)时,不自动要求 `valueCount > 0`
- `values.exact` 忽略返回顺序。
- 对 A/AAAA 查询CNAME 链不计入 `values`,单独放入 `cnameChain`
- `values` 按记录类型规范化为字符串,例如 MX 为 `"10 mail.example.com"`SRV 为 `"10 60 443 server.example.com"`

View File

@@ -0,0 +1,48 @@
# HTTP Checker
`type: http` 用于 HTTP/HTTPS 应用层健康检查。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| ------------------- | ------------------- | ---- | ------- |
| `http.url` | 目标 URL | 是 | 无 |
| `http.method` | HTTP 方法 | 否 | `GET` |
| `http.headers` | 请求头 | 否 | 无 |
| `http.body` | 请求体 | 否 | 无 |
| `http.ignoreSSL` | 忽略 HTTPS 证书校验 | 否 | `false` |
| `http.maxRedirects` | 最大重定向跟随次数 | 否 | `0` |
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | -------------------------------------------------- | ---- | ------- |
| `status` | 可接受的状态码列表,支持精确码和范围(如 `"2xx"` | 否 | `[200]` |
| `headers` | 响应头校验,使用 `KeyedExpectations` | 否 | 无 |
| `body` | 响应体校验,使用 `ContentExpectations` 数组 | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
```yaml
- id: "json-api"
name: "JSON API 示例"
type: http
http:
url: "https://httpbin.org/json"
headers:
Authorization: "Bearer token"
expect:
status: [200]
headers:
Content-Type:
contains: "application/json"
body:
- json:
path: "$.slideshow.title"
equals: "Sample Slide Show"
durationMs:
lte: 10000
```
HTTP checker 的 `durationMs` 覆盖完整执行,包括重定向、按需响应体读取、解码和 expect 校验。未配置 body expectation、status 失败或 headers 失败时不会读取 body。

View File

@@ -0,0 +1,45 @@
# ICMP Checker
`type: icmp` 使用系统 `ping` 命令执行 ICMP 探测,支持 Linux、macOS 和 Windows 输出解析。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| ----------------- | ------------------------- | ---- | ------ |
| `icmp.host` | 目标主机地址 | 是 | 无 |
| `icmp.count` | ICMP 包数量,范围 `1-100` | 否 | `3` |
| `icmp.packetSize` | ICMP 包大小bytes | 否 | `56` |
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| ------------------- | --------------------------------------------------- | ---- | ------ |
| `alive` | 期望主机可达性 | 否 | `true` |
| `packetLossPercent` | 丢包率百分比校验,范围 `0-100`,使用 `ValueMatcher` | 否 | 无 |
| `avgLatencyMs` | 平均延迟校验,使用 `ValueMatcher` | 否 | 无 |
| `maxLatencyMs` | 最大单次延迟校验,使用 `ValueMatcher` | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
```yaml
- id: "gateway-icmp"
name: "网关 ICMP 可达"
type: icmp
icmp:
host: "10.0.0.1"
count: 3
packetSize: 56
expect:
alive: true
packetLossPercent:
lte: 10
avgLatencyMs:
lte: 100
maxLatencyMs:
lte: 300
durationMs:
lte: 5000
```
容器中运行 ICMP checker 通常需要 `--cap-add=NET_RAW`,详情见 [部署文档](../deployment.md#icmp-权限)。

53
docs/user/checkers/llm.md Normal file
View File

@@ -0,0 +1,53 @@
# LLM Checker
`type: llm` 用于大模型服务应用层健康检查。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| --------------------- | ----------------------------------------------------- | ---- | ------- |
| `llm.provider` | 模型提供方:`openai``openai-responses``anthropic` | 是 | 无 |
| `llm.url` | API base URL | 是 | 无 |
| `llm.model` | 模型名称 | 是 | 无 |
| `llm.prompt` | 单轮 prompt | 是 | 无 |
| `llm.mode` | 调用模式:`http``stream` | 否 | `http` |
| `llm.key` | API key支持 `${VAR}` 变量替换 | 否 | `""` |
| `llm.authToken` | Bearer token`anthropic` provider`key` 互斥 | 否 | 无 |
| `llm.headers` | 附加请求头 | 否 | 无 |
| `llm.ignoreSSL` | 忽略 HTTPS 证书校验 | 否 | `false` |
| `llm.options` | 生成选项 | 否 | 无 |
| `llm.providerOptions` | Provider 专属选项 | 否 | 无 |
`llm.options` 支持 `maxOutputTokens`(默认 `16`)、`temperature`(默认 `0`)、`topP``topK``presencePenalty``frequencyPenalty``stopSequences``seed`
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| ----------------- | --------------------------------------------------------------------------- | ---- | ------- |
| `status` | 可接受的状态码列表,支持精确码和范围(如 `"2xx"` | 否 | `[200]` |
| `headers` | 响应头校验,使用 `KeyedExpectations` | 否 | 无 |
| `output` | 模型输出校验,使用 `ContentExpectations` 数组 | 否 | 无 |
| `finishReason` | finish reason 校验,使用 `ValueMatcher` | 否 | 无 |
| `rawFinishReason` | 原始 finish reason 校验,使用 `ValueMatcher` | 否 | 无 |
| `usage` | Token usage 校验,支持 `inputTokens``outputTokens``totalTokens` matcher | 否 | 无 |
| `stream` | 流式断言,支持 `completed``firstTokenMs` matcher`mode: stream` | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
```yaml
- id: "llm-openai-probe"
name: "OpenAI 健康检查"
type: llm
llm:
provider: openai
url: "https://api.openai.com/v1"
model: "gpt-4o-mini"
prompt: "Say OK"
key: "${OPENAI_API_KEY}"
expect:
status: [200]
finishReason: "stop"
output:
- contains: "OK"
```

119
docs/user/checkers/mem.md Normal file
View File

@@ -0,0 +1,119 @@
# Mem Checker
`type: mem` 用于检查本机系统级内存使用状况,包括物理内存和交换空间的使用率及字节数。
## 配置项
Mem checker 配置为空对象,无需额外参数:
```yaml
mem: {}
```
## expect 校验项
### 百分比字段
| 字段 | 说明 | 必填 | 默认值 |
| ------------------ | -------------------------------------------------------------------------- | ---- | ------ |
| `usagePercent` | 真实内存使用率 = `activeBytes / totalBytes × 100`,不含 buffers/cache 假象 | 否 | 无 |
| `usedPercent` | 原始已用百分比 = `usedBytes / totalBytes × 100`,包含 buffers/cache | 否 | 无 |
| `freePercent` | 空闲百分比 = `freeBytes / totalBytes × 100` | 否 | 无 |
| `activePercent` | 活跃内存百分比 = `activeBytes / totalBytes × 100` | 否 | 无 |
| `availablePercent` | 可用内存百分比 = `availableBytes / totalBytes × 100` | 否 | 无 |
| `swapUsagePercent` | 交换空间使用率,当系统无交换分区时为 `null` | 否 | 无 |
所有百分比字段范围为 `0-100`,使用 `ValueMatcher`
### 字节字段
| 字段 | 说明 | 必填 | 默认值 |
| ---------------- | ----------------------------------------- | ---- | ------ |
| `activeBytes` | 活跃内存字节数 | 否 | 无 |
| `usedBytes` | 已用内存字节数(含 buffers/cache | 否 | 无 |
| `freeBytes` | 空闲内存字节数 | 否 | 无 |
| `availableBytes` | 可用内存字节数 | 否 | 无 |
| `totalBytes` | 物理内存总字节数 | 否 | 无 |
| `swapUsedBytes` | 交换空间已用字节数,无交换分区时为 `null` | 否 | 无 |
| `swapFreeBytes` | 交换空间空闲字节数,无交换分区时为 `null` | 否 | 无 |
| `swapTotalBytes` | 交换空间总字节数,无交换分区时为 `0` | 否 | 无 |
| `buffcacheBytes` | 缓冲缓存字节数,部分平台可能为 `null` | 否 | 无 |
字节字段支持数字(字节数)或大小字符串(如 `"512MB"``"1GB"`),使用 `ValueMatcher`
### 通用字段
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | ------------------------------------- | ---- | ------ |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
检查内存使用率不超过 85%
```yaml
- id: "local-memory"
name: "本机内存"
type: mem
interval: "30s"
timeout: "5s"
mem: {}
expect:
usagePercent:
lte: 85
```
检查可用内存不低于 4GB
```yaml
- id: "local-memory-available"
name: "可用内存检查"
type: mem
mem: {}
expect:
availableBytes:
gte: "4GB"
```
同时检查内存和交换空间:
```yaml
- id: "local-memory-swap"
name: "内存和交换空间"
type: mem
mem: {}
expect:
usagePercent:
lte: 80
swapUsagePercent:
lte: 50
```
## 语义说明
Mem checker 通过 `systeminformation` 库读取系统内存数据,在 Linux、macOS 和 Windows 上均可运行。
- **`usagePercent`** 使用 `activeBytes / totalBytes` 计算,反映真实的内存压力,不受 Linux buffers/cache 缓存影响。推荐使用此字段进行内存健康检查。
- **`usedPercent`** 使用 `usedBytes / totalBytes` 计算,包含 buffers/cache。在 Linux 上此值通常高于 `usagePercent`
- **Swap 字段**:当系统未配置交换分区时,`swapTotalBytes``0``swapUsagePercent``null`(非 `0`)。
- **`buffcacheBytes`**:反映 Linux 的 buffers + cache 用量,在其他平台上可能为 `null`
Mem checker 是即时读取(非采样),无需 `sampleDuration`,执行速度远快于 CPU checker。虽然读取本身很快但仍受 target `timeout` 约束——若底层系统调用悬挂或阻塞超过 `timeout`checker 会返回 `mem/timeout` failure。
## 跨平台注意事项
- Windows 环境依赖 PowerShell 5+ 获取部分内存指标
- `buffcacheBytes` 在非 Linux 平台上可能返回 `null`
- 容器环境中内存数据可能不反映 cgroup 内存限制
## 不支持的功能
- 进程级内存使用(如 RSS、VSZ
- cgroup/container 内存限制精度
- 内存趋势采样和历史记录
- 内存条物理布局信息
- 详细内存分类slab、reclaimable、dirty 等)作为 expect 字段
## 更新触发条件
修改 Mem checker 配置、expect 字段、行为或语义时,必须更新本文档。

35
docs/user/checkers/tcp.md Normal file
View File

@@ -0,0 +1,35 @@
# TCP Checker
`type: tcp` 用于 TCP 端口可达性和可选 banner 探测。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| ----------------------- | --------------------------------------------- | ---- | ------- |
| `tcp.host` | 目标主机地址 | 是 | 无 |
| `tcp.port` | 目标端口,范围 `1-65535` | 是 | 无 |
| `tcp.readBanner` | 是否读取服务端 banner | 否 | `false` |
| `tcp.bannerReadTimeout` | banner 读取超时,毫秒 | 否 | `2000` |
| `tcp.maxBannerBytes` | banner 最大字节数,支持 `KB``MB``GB` 单位 | 否 | `4KB` |
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| ------------ | ------------------------------------------------------------------------- | ---- | ------ |
| `connected` | 期望连接结果,`true` 可达或 `false` 期望不可达 | 否 | `true` |
| `banner` | Banner 内容校验,使用 `ContentExpectations` 数组,需开启 `tcp.readBanner` | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
```yaml
- id: "redis-port"
name: "Redis 端口可达"
type: tcp
tcp:
host: "127.0.0.1"
port: 6379
expect:
durationMs:
lte: 3000
```

43
docs/user/checkers/udp.md Normal file
View File

@@ -0,0 +1,43 @@
# UDP Checker
`type: udp` 用于 UDP payload 请求-响应检查。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| ---------------------- | ------------------------------------------ | ---- | ------ |
| `udp.host` | 目标主机地址 | 是 | 无 |
| `udp.port` | 目标端口,范围 `1-65535` | 是 | 无 |
| `udp.payload` | 发送数据 | 否 | `""` |
| `udp.encoding` | payload 编码:`text``hex``base64` | 否 | `text` |
| `udp.responseEncoding` | 响应解码:`text``hex``base64` | 否 | `text` |
| `udp.maxResponseBytes` | 响应最大字节数,支持 `KB``MB``GB` 单位 | 否 | `4KB` |
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| -------------- | --------------------------------------------- | ---- | ------ |
| `responded` | 期望是否收到响应 | 否 | `true` |
| `response` | 响应内容校验,使用 `ContentExpectations` 数组 | 否 | 无 |
| `responseSize` | 响应字节数校验,使用 `ValueMatcher` | 否 | 无 |
| `sourceHost` | 响应来源地址校验,使用 `ValueMatcher` | 否 | 无 |
| `sourcePort` | 响应来源端口校验,使用 `ValueMatcher` | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 示例
```yaml
- id: "udp-heartbeat"
name: "UDP 心跳检测"
type: udp
udp:
host: "127.0.0.1"
port: 9000
payload: "PING"
expect:
responded: true
response:
- contains: "PONG"
durationMs:
lte: 100
```

81
docs/user/checkers/ws.md Normal file
View File

@@ -0,0 +1,81 @@
# WS Checker
`type: ws` 用于 WebSocket 服务可达性检查和消息交互验证。
## 配置项
| 字段 | 说明 | 必填 | 默认值 |
| -------------------- | ---------------------------------------------- | ---- | ------- |
| `ws.url` | 目标 URL必须以 `ws://``wss://` 开头 | 是 | 无 |
| `ws.headers` | 握手 HTTP 头 | 否 | `{}` |
| `ws.subprotocols` | 子协议协商 | 否 | `[]` |
| `ws.ignoreSSL` | 忽略 TLS 证书校验 | 否 | `false` |
| `ws.send` | 发送的 text 消息,配置后进入请求-响应模式 | 否 | 无 |
| `ws.receiveTimeout` | 等待响应超时,毫秒 | 否 | `5000` |
| `ws.maxMessageBytes` | 单条消息最大字节数,支持 `KB``MB``GB` 单位 | 否 | `4KB` |
## expect 校验项
| 字段 | 说明 | 必填 | 默认值 |
| ------------------ | --------------------------------------------------------------------- | ---- | ------ |
| `connected` | 期望连接结果,`true` 可达或 `false` 期望不可达 | 否 | `true` |
| `handshakeHeaders` | 握手响应头校验,使用 `KeyedExpectations` | 否 | 无 |
| `message` | 收到的消息内容校验,使用 `ContentExpectations` 数组,需配置 `ws.send` | 否 | 无 |
| `connectTimeMs` | 连接建立耗时校验,使用 `ValueMatcher` | 否 | 无 |
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
## 两种模式
不配置 `ws.send` 时只做可达性检查(连接后立即关闭),配置 `ws.send` 后进入请求-响应模式(发送消息并等待首条响应)。
## 示例
可达性检查:
```yaml
- id: "ws-reachability"
name: "WebSocket 服务可达"
type: ws
ws:
url: "wss://api.example.com/ws"
expect:
durationMs:
lte: 3000
```
带鉴权的请求-响应:
```yaml
- id: "ws-echo"
name: "WebSocket Echo 检查"
type: ws
ws:
url: "wss://echo.example.com/ws"
headers:
Authorization: "Bearer ${TOKEN}"
subprotocols: ["json"]
send: '{"action":"ping"}'
receiveTimeout: 3000
expect:
handshakeHeaders:
Sec-WebSocket-Protocol:
equals: "json"
message:
- json:
path: "$.action"
equals: "pong"
durationMs:
lte: 5000
```
期望不可达:
```yaml
- id: "ws-internal-down"
name: "内部服务已下线"
type: ws
ws:
url: "ws://internal.monitor:9443/ws"
expect:
connected: false
```

135
docs/user/configuration.md Normal file
View File

@@ -0,0 +1,135 @@
# 配置文件
DiAL 通过 YAML 配置文件定义运行参数和拨测目标。完整可运行示例参见 [`../../probes.example.yaml`](../../probes.example.yaml)。配置 JSON Schema 位于 [`../../probe-config.schema.json`](../../probe-config.schema.json)。
## 配置结构
```yaml
# yaml-language-server: $schema=./probe-config.schema.json
server:
listen:
host: "127.0.0.1"
port: "${server_port}"
storage:
dataDir: "/tmp/probes_data"
retention: "${retention}"
logging:
level: "${log_level|info}"
file:
path: "<dataDir>/logs/dial.log"
probes:
execution:
maxConcurrentChecks: "${max_checks}"
variables:
server_port: 3000
retention: "7d"
max_checks: 20
default_interval: "30s"
default_timeout: "10s"
targets:
- id: "baidu-home"
name: "Baidu"
type: http
interval: "${default_interval}"
timeout: "${default_timeout}"
http:
url: "https://www.baidu.com"
expect:
status: [200]
```
## server.listen
| 字段 | 说明 | 必填 | 默认值 |
| ------ | -------- | ---- | ----------- |
| `host` | 监听地址 | 否 | `127.0.0.1` |
| `port` | 监听端口 | 否 | `3000` |
## server.storage
| 字段 | 说明 | 必填 | 默认值 |
| ----------- | ---------------------------------------------------- | ---- | -------- |
| `dataDir` | 数据目录,相对路径基于配置文件所在目录解析 | 否 | `./data` |
| `retention` | 历史数据保留时长,支持 `ms``s``m``h``d` 单位 | 否 | `7d` |
## probes.execution
| 字段 | 说明 | 必填 | 默认值 |
| --------------------- | -------------- | ---- | ------ |
| `maxConcurrentChecks` | 最大并发拨测数 | 否 | `20` |
## server.logging
| 字段 | 说明 | 必填 | 默认值 |
| ---------------------------------------- | ---------------------------------------------- | ---- | ------------------------- |
| `server.logging.level` | 全局日志等级console 和 file 未指定时继承此值 | 否 | `info` |
| `server.logging.console.level` | 控制台日志等级 | 否 | 继承 `level` |
| `server.logging.file.level` | 文件日志等级 | 否 | 继承 `level` |
| `server.logging.file.path` | 日志文件路径,相对路径基于配置文件目录解析 | 否 | `<dataDir>/logs/dial.log` |
| `server.logging.file.rotation.size` | 按大小滚动,支持 `KB``MB``GB` 单位 | 否 | `50MB` |
| `server.logging.file.rotation.frequency` | 按时间滚动:`hourly``daily``weekly` | 否 | `daily` |
| `server.logging.file.rotation.maxFiles` | 保留的归档文件数量,不含活跃日志 | 否 | `14` |
日志等级支持:`trace``debug``info``warn``error``fatal`
控制台始终输出 pretty 格式,文件始终输出 JSONL 格式并支持滚动。`rotation.size``rotation.frequency` 任一条件触发即滚动。
## 内置默认值
| 字段 | 默认值 | 约束 |
| ---------- | ------ | ----------------------- |
| `interval` | `30s` | 最小 `10s` |
| `timeout` | `10s` | 必须小于等于 `interval` |
各 checker 专属默认值见 [Checker 参考](checkers/README.md)。
## variables
`variables` 是顶层动态键值表key 必须符合 `[a-zA-Z_][a-zA-Z0-9_]*`value 仅支持 string、number、boolean。`server``probes``targets` 中的字符串值可引用变量。
| 语法 | 说明 |
| ----------------- | ------------------------------------------ |
| `${key}` | 引用 variables 或环境变量 |
| `${key\|default}` | variables 和环境变量都不存在时使用默认值 |
| `${key\|}` | variables 和环境变量都不存在时使用空字符串 |
| `$${key}` | 转义输出字面量 `${key}` |
解析优先级为 `variables -> process.env -> 默认值`。三者均不存在时配置校验失败。字段值完整等于单个变量引用时会保留 number、boolean、string 类型;部分拼接时统一转为字符串。
变量替换作用于 `server``probes``targets`,不作用于 `variables` 段自身,且不会替换 `targets[].id``targets[].type` 字段;对象 key 不参与替换。
## 配置加载形态
配置加载内部区分三层形态:
| 形态 | 说明 |
| ----------------- | ------------------------------------------------------------------------------------- |
| Authoring Config | 用户 YAML 可书写形态,允许变量引用和 expect 简写 |
| Normalized Config | `normalizeAuthoringConfig()` 完成变量替换、expect 简写展开并移除 `variables` 后的形态 |
| ResolvedConfig | checker `resolve()` 补默认值并解析 duration、size、路径和运行期环境后的形态 |
根目录 `probe-config.schema.json` 面向 Authoring Config因此 VSCode 校验会接受 `server.listen.port: "${server_port|3000}"``http.maxRedirects: "${MAX|5}"``expect.durationMs: 5000` 这类写法。
## targets 通用字段
| 字段 | 说明 | 必填 | 默认值 |
| ------------- | ------------------------------------------------------------------------------------ | ---- | --------- |
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 | 无 |
| `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null前端展示时 null 回退到 `id` | 否 | 无 |
| `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null允许空字符串 | 否 | 无 |
| `type` | 目标类型:`http``cmd``db``tcp``udp``dns``icmp``llm``ws``cpu` | 是 | 无 |
| `group` | 分组名称 | 否 | `default` |
| `interval` | 拨测间隔,最小 `10s` | 否 | `30s` |
| `timeout` | 超时时间,必须小于等于 `interval` | 否 | `10s` |
## Checker 专属配置
每个 target 必须根据 `type` 配置对应的 checker 专属字段。详情见 [Checker 参考](checkers/README.md)。
## 校验规则
`expect` 字段按 checker 类型不同而变化。通用断言模型见 [校验规则](expectations.md)。

109
docs/user/deployment.md Normal file
View File

@@ -0,0 +1,109 @@
# 部署
本文档说明如何构建、运行、容器化和发布 DiAL。开发环境运行见 [README 快速开始](../../README.md#快速开始)。
## 生产构建和运行
```bash
bun run build
./dist/dial-server ./probes.yaml
```
构建产物为独立可执行文件,只需要一个 YAML 配置文件即可运行。
启动后:
| 地址 | 行为 |
| ------------------------------ | ------------------ |
| `http://127.0.0.1:3000/` | 返回前端 Dashboard |
| `http://127.0.0.1:3000/api/*` | 返回后端 API |
| `http://127.0.0.1:3000/health` | 返回健康检查 |
## Docker 部署
DiAL 提供基于 Alpine 的多阶段镜像。构建阶段使用 Bun 生成 musl 目标单可执行文件,运行阶段只包含 `dial-server`、基础证书、`ping`、Bun musl executable 必需运行库、时区数据和容器运行目录。
```bash
docker build -t dial:alpine .
docker run --rm -p 3000:3000 -v dial-data:/data/dial dial:alpine
```
容器默认读取 `/etc/dial/probes.yaml`,推荐将数据卷挂载到 `/data/dial`
使用自定义配置文件:
```bash
docker run --rm -p 3000:3000 \
-v "$PWD/docker/probes.yaml:/etc/dial/probes.yaml:ro" \
-v dial-data:/data/dial \
dial:alpine
```
容器专用示例配置位于 [`../../docker/probes.yaml`](../../docker/probes.yaml),默认监听 `0.0.0.0:3000`,并将 SQLite 数据和日志写入 `/data/dial`
## ICMP 权限
如需在容器中运行 ICMP checker除镜像内置 `iputils-ping` 外,还需要授予 `NET_RAW` capability
```bash
docker run --rm --cap-add=NET_RAW -p 3000:3000 -v dial-data:/data/dial dial:alpine
```
## CMD checker 额外命令
官方镜像不内置 `bun``node``curl``dig``psql``mysql``redis-cli` 等 CMD checker 可能需要的额外命令。需要这些命令时请使用派生镜像自行安装:
```dockerfile
FROM dial:alpine
USER root
RUN apk add --no-cache curl bind-tools postgresql-client
USER dial
```
## 多架构镜像
```bash
docker buildx build --platform linux/amd64,linux/arm64 -t dial:alpine .
```
Dockerfile 通过 Docker 提供的 `TARGETARCH` 选择 Bun compile target。
| `TARGETARCH` | `BUN_TARGET` |
| ------------ | ---------------------- |
| `amd64` | `bun-linux-x64-musl` |
| `arm64` | `bun-linux-arm64-musl` |
## 跨平台发布包
```bash
bun run release
bun run release --target linux-x64
bun run release --target linux-x64,windows-x64,darwin-arm64
```
支持的目标平台:
| CLI 参数 | Bun CompileTarget |
| ------------------ | ---------------------- |
| `linux-x64` | `bun-linux-x64` |
| `linux-arm64` | `bun-linux-arm64` |
| `linux-x64-musl` | `bun-linux-x64-musl` |
| `linux-arm64-musl` | `bun-linux-arm64-musl` |
| `windows-x64` | `bun-windows-x64` |
| `darwin-x64` | `bun-darwin-x64` |
| `darwin-arm64` | `bun-darwin-arm64` |
产出物结构:
```text
dist/release/
├── binaries/
│ ├── dial-server-0.1.0-linux-x64
│ └── dial-server-0.1.0-windows-x64.exe
└── packages/
├── dial-server_0.1.0_linux_x64.tar.gz
└── dial-server_0.1.0_linux_x64.tar.gz.sha256
```
压缩包内含可执行文件、`probes.example.yaml``LICENSE`,解压后可直接使用。

161
docs/user/expectations.md Normal file
View File

@@ -0,0 +1,161 @@
# 校验规则
本文档说明 `expect` 规则、状态判定、failure、observation 和各 checker 的快速失败顺序。
适用场景:编写 `expect`、理解 UP/DOWN、排查 mismatch/error、查看返回结果中的 `failure``observation`
`expect` 描述拨测结果必须满足的条件。不同 checker 暴露不同字段,但共享三类基础断言模型:`ValueMatcher``ContentExpectations``KeyedExpectations`
## 状态判定
DiAL 使用单层状态模型。
| 状态 | 含义 |
| ------ | ---------------------------------------- |
| `UP` | 拨测结果符合 `expect` 规则 |
| `DOWN` | 拨测结果不符合 `expect` 规则,或执行失败 |
执行失败(网络错误、超时、进程崩溃)和 expect 不匹配都统一为 `DOWN`,通过 `failure.kind` 区分原因。
| `failure.kind` | 含义 |
| -------------- | ---------------------------------------- |
| `error` | 网络、超时、进程、协议解析或内部执行错误 |
| `mismatch` | 拨测完成,但结果不满足 expect |
## API 结果字段
API 返回的检查结果包含 `detail``observation`
| 字段 | 说明 |
| ------------- | ------------------------------------------------------------ |
| `detail` | 后端按 checker 类型从结构化 observation 动态生成的人可读摘要 |
| `observation` | 保存该次检查的结构化观测数据 |
| `failure` | 保存首个错误或不匹配原因 |
| `matched` | 是否符合 expect |
| `durationMs` | 本次检查耗时 |
| `timestamp` | 本次检查时间 |
`detail` 不写入 SQLite。存储层仅持久化 `observation` JSON、`failure` JSON、匹配状态、耗时和时间戳。
## observation 示例
不同 checker 的 observation 字段不同,常见信息包括:
| Checker | observation 内容示例 |
| ------- | ------------------------------------------------------------------ |
| HTTP | 状态码、响应头、按需读取的 body 预览 |
| Cmd | exit code、stdout/stderr 预览 |
| TCP | 连接结果、banner 摘要 |
| UDP | 响应内容、来源地址、响应大小 |
| ICMP | 存活结果、丢包率、平均延迟、最大延迟 |
| DNS | RCODE、记录值、TTL、flags、CNAME 链 |
| LLM | HTTP 状态、模型输出、finish reason、token usage、流式首 token 时间 |
| WS | 连接结果、连接耗时、握手头、消息内容、消息大小 |
Dashboard 基于存储的检查结果计算实时状态、可用率、耗时趋势、P95、状态条和故障段等指标。指标语义由后端应用层实现SQLite 主要负责存储、筛选、排序、分页和基础聚合。
## ContentExpectations
`body``stdout``stderr``banner``response``output``result``message` 等返回内容字段均使用数组。
| 规则 | 说明 |
| ---------- | ------------------------------------------------------ |
| `contains` | 内容包含指定文本 |
| `regex` | 正则匹配,启动期会拒绝存在 ReDoS 风险的模式 |
| `json` | JSONPath 提取值比较,`path` 必填 |
| `css` | CSS 选择器提取 HTML 元素,`selector` 必填,`attr` 可选 |
| `xpath` | XPath 提取 XML/HTML 节点,`path` 必填 |
示例:
```yaml
expect:
body:
- contains: "ok"
- json:
path: "$.status"
equals: "ready"
```
ContentExpectations 数组按顺序快速失败。数组项可以是直接 matcher也可以是 `json``css``xpath` 提取器规则。一条规则不能混用直接 matcher 和 extractor多个 extractor 也不能共存。Extractor 未配置 matcher 时等价于 `exists: true`
## ValueMatcher
`ValueMatcher` 用于单个标量值、数字指标和字符串元数据。
| 字段 | 说明 |
| ---------- | ------------------------------- |
| `equals` | 精确匹配,支持 JSON 深度相等 |
| `contains` | 字符串包含 |
| `regex` | 正则匹配,固定使用无 flags 正则 |
| `empty` | 判断是否为空 |
| `exists` | 判断是否存在 |
| `gte` | 大于等于 |
| `lte` | 小于等于 |
| `gt` | 大于 |
| `lt` | 小于 |
一个 matcher 对象内多个字段为 AND 语义。`exists: false` 不能和其他 matcher 组合。
ValueMatcher expect 字段可直接写 string、number、boolean 或 null等价于 `{ equals: value }`。数组和对象必须显式写成 `{ equals: ... }`
```yaml
expect:
durationMs:
lte: 5000
finishReason: "stop"
```
## KeyedExpectations
`headers`、DB `rows[]` 中的列值等动态键值对象使用 `KeyedExpectations`。每个键的值支持 `ValueMatcher` 的全部字段,字面量值自动等价于 `{ equals: value }`
```yaml
expect:
headers:
Content-Type:
contains: "application/json"
```
## 大小和时长格式
| 类型 | 示例 |
| ---- | -------------------------------- |
| 大小 | `4KB``10MB``1GB`、直接数字 |
| 时长 | `500ms``30s``5m``2h``7d` |
`maxBodyBytes``maxOutputBytes``maxResponseBytes``maxBannerBytes` 等大小字段支持 `KB``MB``GB` 单位。
## 快速失败顺序
| Checker | 顺序 |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| HTTP | `status -> headers -> body -> durationMs` |
| Cmd | `exitCode -> durationMs -> stdout -> stderr` |
| DB | `durationMs -> rowCount -> rows -> result` |
| TCP | `connected -> banner -> durationMs` |
| UDP | `responded -> responseSize -> response -> sourceHost -> sourcePort -> durationMs` |
| ICMP | `alive -> packetLossPercent -> avgLatencyMs -> maxLatencyMs -> durationMs` |
| DNS system | `values -> valueCount -> durationMs` |
| DNS server | `responded -> rcode -> values -> valueCount -> answerCount -> ttlMin -> ttlMax -> authoritative -> recursionAvailable -> truncated -> authenticatedData -> result -> durationMs` |
| LLM http | `status -> headers -> output -> finishReason -> rawFinishReason -> usage -> durationMs` |
| LLM stream | `status -> headers -> stream.completed -> stream.firstTokenMs -> output -> finishReason -> rawFinishReason -> usage -> durationMs` |
| WS | `connected -> handshakeHeaders -> message -> connectTimeMs -> durationMs` |
## JSON Schema
仓库根目录导出 `probe-config.schema.json`。在 YAML 文件顶部添加以下注释可在编辑器中获得提示和校验:
```yaml
# yaml-language-server: $schema=./probe-config.schema.json
```
## 已移除字段
旧字段 `maxDurationMs``maxPacketLoss``maxAvgLatencyMs``maxMaxLatencyMs` 和旧正则字段 `match` 已移除,请分别改用 `durationMs`、ICMP matcher 字段和 `regex`
非法配置会阻止启动并输出错误信息。除动态键值表(`headers``env``variables`)外,未知字段会导致启动失败,请使用 YAML 注释表达说明。
## 更新触发条件
修改 expect 断言模型、状态判定、failure 字段、observation 字段、快速失败顺序或已移除字段说明时,必须更新本文档。

View File

@@ -0,0 +1,73 @@
# 故障排查
本文档记录常见运行问题和排查入口。
## 配置校验失败
DiAL 启动时会校验 YAML 配置。除动态键值表(`headers``env``variables`)外,未知字段会导致启动失败。
排查顺序:
1. 在 YAML 顶部添加 `# yaml-language-server: $schema=./probe-config.schema.json`
2. 对照 [配置文件](configuration.md) 检查顶层结构和通用字段。
3. 对照 [Checker 参考](checkers/README.md) 检查 checker 专属字段。
4. 对照 [校验规则](expectations.md) 检查 expect 写法。
## 变量无法解析
变量解析优先级为 `variables -> process.env -> 默认值`。如果三者均不存在,配置校验会失败。
常见修复:
| 问题 | 修复 |
| -------------- | ----------------------------------- |
| 环境变量未设置 | 设置环境变量或在 `variables` 中声明 |
| 希望允许空值 | 使用 `${key\|}` |
| 希望提供默认值 | 使用 `${key\|default}` |
| 希望输出字面量 | 使用 `$${key}` |
## ICMP checker 无法运行
ICMP checker 依赖系统 `ping` 命令。
| 环境 | 处理 |
| ------------------- | -------------------------------------- |
| Alpine 或精简镜像 | 安装 `iputils-ping` |
| Docker 容器 | 运行容器时增加 `--cap-add=NET_RAW` |
| Windows/macOS/Linux | 确认系统 `ping` 可执行且输出格式受支持 |
Docker 示例:
```bash
docker run --rm --cap-add=NET_RAW -p 3000:3000 -v dial-data:/data/dial dial:alpine
```
## CMD checker 找不到命令
官方 Docker 镜像不内置 `bun``node``curl``dig``psql``mysql``redis-cli` 等额外命令。需要这些命令时请使用派生镜像安装。
```dockerfile
FROM dial:alpine
USER root
RUN apk add --no-cache curl bind-tools postgresql-client
USER dial
```
## Docker 数据或日志丢失
推荐将数据卷挂载到 `/data/dial`,并在配置中使用该目录作为 storage dataDir。
```bash
docker run --rm -p 3000:3000 -v dial-data:/data/dial dial:alpine
```
容器示例配置位于 [`../../docker/probes.yaml`](../../docker/probes.yaml)。
## HTTP 或 LLM 证书问题
HTTP 和 LLM checker 支持 `ignoreSSL`。该选项适合内网、自签名证书或测试环境;生产环境应优先修复证书链。
## 正则规则被拒绝
`regex` 启动期会执行 ReDoS 风险检测。被拒绝时应改写为更明确、回溯风险更低的表达式。

View File

@@ -6,6 +6,9 @@ import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
const noDirectConsoleMessage =
"后端运行时代码禁止直接使用 console.*;请通过注入的 Logger 实例输出日志,配置加载失败前使用 createConsoleFallback()。";
export default tseslint.config(
{
ignores: [
@@ -44,6 +47,8 @@ export default tseslint.config(
"@typescript-eslint/array-type": ["error", { default: "array-simple" }],
"@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "as" }],
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
"@typescript-eslint/no-empty-function": ["error", { allow: ["private-constructors", "protected-constructors"] }],
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/only-throw-error": "error",
"@typescript-eslint/prefer-nullish-coalescing": "error",
"@typescript-eslint/prefer-optional-chain": "error",
@@ -51,6 +56,19 @@ export default tseslint.config(
"no-undef": "off",
},
},
{
files: ["src/server/**/*.ts"],
ignores: ["src/server/logger.ts"],
rules: {
"no-restricted-syntax": [
"error",
{
message: noDirectConsoleMessage,
selector: "MemberExpression[object.name='console']",
},
],
},
},
{
files: ["eslint.config.js"],
rules: {

View File

@@ -6,5 +6,16 @@
"type": "local",
"command": ["bunx", "tdesign-mcp-server@latest"]
}
},
"permission": {
"bash": {
"npm *": "deny",
"npx *": "deny",
"pnpm *": "deny",
"pnpx *": "deny"
},
"external_directory": {
"/tmp/**": "allow"
}
}
}

View File

@@ -1,31 +1,39 @@
schema: spec-driven
schema: fast-drive
context: |
- 使用中文(注释、文档、交流),面向中文开发者
- openspec文档的关键字按openspec规范使用不要翻译为中文
- **优先阅读README.md和DEVELOPMENT.md**获取项目概览与开发规范所有代码风格、命名、注解、依赖、API等规范以DEVELOPMENT.md为准
- 涉及模块结构、API、实体等变更时同步更新README.md
- 本项目openspec使用fast-drive自定义schema变更文档只包含design.md和tasks.md无proposal.md和specs
- **优先阅读docs/README.md**判断文档归属和本次任务需要读取的专题文档
- README.md用于项目概览、快速开始和顶层文档引导docs/user/README.md用于用户使用入口docs/development/README.md用于开发入口、全局规则和质量门禁
- 所有代码风格、命名、注解、依赖、API等开发规范以docs/development/README.md和docs/development/下对应专题文档为准
- 新增或修改checker时必须阅读docs/development/checker.md、docs/user/checkers/README.md和相近checker用户文档
- 每次代码变更都必须执行文档影响分析判断是否影响用户可见行为、配置格式、checker行为、expect规则、API、部署方式、开发流程、架构边界、测试规范或构建发布流程
- 若影响用户使用方式、配置格式、checker行为、expect规则、部署方式或运行行为必须同步更新docs/user/下对应文档README.md仅在项目定位、快速开始、核心能力列表或文档导航变化时更新
- 若影响开发流程、架构边界、质量门禁、测试规范、构建发布流程或checker开发机制必须同步更新docs/development/README.md或docs/development/下对应专题文档
- 若影响文档同步规则或文档归属矩阵必须同步更新docs/README.md和openspec/config.yaml
- 若无需更新文档,必须在收尾说明中说明原因
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
- 新增的逻辑必须编写完善的测试,并保证测试的正确性,不允许跳过任何测试
- 这是基于bun实现的前端后一体化项目使用bun作为唯一包管理器严禁使用pnpm、npm使用bunx运行工具严禁使用npx、pnpx
- src/server目录下是基于bun实现的后端代码
- 后端库使用优先级Bun 内置 API > es-toolkit > 主流三方库 > 项目公共工具 > 自行实现
- 后端库使用优先级Bun 内置 API > es-toolkit > 标准 Web API > 主流三方库 > 项目公共工具 > 自行实现
- src/web目录下是基于Bun HTML import、React、TDesign实现的前端代码
- 前端样式开发优先级TDesign组件 > 组件props > TDesign CSS tokens(--td-*) > styles.css CSS类 > 自行开发组件
- 前端严禁组件内联style属性、CSS覆盖TD内部类名、使用!important、硬编码色值
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
- 禁止创建git操作task
- 积极使用subagents精心设计并行任务,节省上下文空间,加速任务执行
- 使用subagents处理计算密集或多步骤的并行任务如代码实现、测试执行文件读取直接使用Read工具并行调用禁止用subagent转发文件内容
- 优先使用提问工具对用户进行提问
- (当前项目未上线,不需要考虑向前兼容)
rules:
proposal:
- 仔细审查每一个过往spec判断是否存在Modified Capabilities
design:
- 先前的讨论技术方案要尽可能体现在设计文档中,便于指导实现阶段不偏离已定的技术路线
tasks:
- 一行一个任务,严禁任务内容跨行
- 如果是代码存在更新必须
- 执行完整的测试、代码检查、格式检查等质量保障手段
- 更新 README.md 和/或 DEVELOPMENT.md
- 执行文档影响分析,并按影响范围更新对应文档;若无需更新文档,必须在任务或收尾说明中明确写出原因
- 新增或修改checker时必须更新docs/user/checkers/下对应用户文档并在checker开发机制变化时更新docs/development/checker.md
- 新增或修改配置字段时必须更新probe-config.schema.json、probes.example.yaml、docs/user/configuration.md或对应checker文档

View File

@@ -0,0 +1,181 @@
name: fast-drive
version: 1
description: Fast OpenSpec workflow - design -> tasks -> apply
artifacts:
- id: design
generates: design.md
description: Self-contained solution brief and execution plan
template: design.md
instruction: |
Create design.md as the self-contained source of truth for what will
change, why it is changing, and how the work will be executed.
This workflow does not use proposal or specs artifacts. design.md MUST
preserve the important outcomes from prior exploration and user
discussion so a later apply phase can proceed correctly even after
context compression or in a new session.
Write for someone who cannot see the earlier conversation. Keep simple
changes concise, but include enough detail to make execution
unambiguous. Add more detail when any apply:
- Cross-cutting change across multiple systems, teams, workstreams, or
artifacts
- New dependency, integration, vendor, tool, policy, or external input
- Significant information model, process model, data model, or ownership
changes
- Security, privacy, compliance, performance, operational, or migration
complexity
- Ambiguity that benefits from decisions before execution
- Prior discussion settled non-obvious requirements, constraints, or
rejected alternatives
Required sections:
- **Context**: Problem, current state, relevant references, and the user
request that triggered this change
- **Discussion Notes**: Key points from exploration or prior discussion
that must not be lost. Include agreed conclusions, user preferences,
constraints, and important rejected ideas.
- **Requirements**: Expected outcomes, behavior/process/interface/content
changes, continuity expectations, and acceptance criteria.
- **Goals / Non-Goals**: What this change will achieve and what is
explicitly out of scope.
- **Execution Guardrails**: Must-follow constraints, forbidden approaches,
preserved behavior/processes, dependency limits, and project- or
workflow-specific boundaries.
- **Affected Areas**: Concrete artifacts, references, stakeholders,
systems, workstreams, documents, configurations, assets, or handoffs that
are relevant to the change.
- **Decisions**: Key choices with rationale (why X over Y?). For each
important decision, include alternatives considered and why they were not
chosen.
- **Execution Plan**: Main workstreams or artifacts to change, integration
or handoff points, sequencing, and any rollout notes.
- **Verification Plan**: Validation checks, reviews, approvals,
acceptance checks, documentation checks, communication checks, and manual
checks needed to prove the change is complete.
- **Risks / Trade-offs**: Known limitations and things that could go
wrong.
Format: [Risk] -> Mitigation
- **Open Questions**: Outstanding decisions, assumptions, or unknowns to
resolve before execution. Separate blocking questions that must pause
apply from non-blocking follow-ups. Use "None" if there are no open
questions.
Optional sections when relevant:
- **Migration / Rollout Plan**: Rollout steps, communication, ownership,
rollback, or continuity strategy.
Focus on preserving requirements, rationale, constraints, and approach.
Avoid line-by-line or step-by-step details unless a detail is a deliberate
decision from the discussion.
Prefer durable summaries over chat transcripts. Use concrete artifact
names, data/information shapes, examples, stakeholders, ownership, and
edge cases when they affect execution.
Do not use task checkboxes in design.md; checkboxes belong only in
tasks.md.
Final design.md must not contain unresolved template comments, empty
table rows, or placeholder text.
If information is missing, state assumptions and open questions instead
of inventing hidden requirements. Do not rely on unstated chat context.
requires: []
- id: tasks
generates: tasks.md
description: Trackable execution checklist derived from design.md
template: tasks.md
instruction: |
Create tasks.md by breaking design.md into executable work.
**IMPORTANT: Follow the template below exactly.** The apply phase parses
checkbox format to track progress. Tasks not using `- [ ]` will not be
tracked.
Guidelines:
- Derive tasks from design.md. Do not depend on proposal.md or specs
artifacts; any relevant prior discussion must already be captured in
design.md.
- Group related tasks under `##` numbered headings
- Each task MUST be a single-line checkbox: `- [ ] X.Y Task description`
- Tasks should be small enough to complete in one session
- Order tasks by dependency (what must be done first?)
- Start with context review tasks when execution depends on guardrails,
affected areas, or open questions
- Include validation tasks for checks, reviews, approvals, acceptance,
documentation, communication, and manual checks when required
- Do not include repository, version-control, or release operation tasks
unless they are explicitly part of the change scope
- Final tasks.md must not contain unresolved template comments, empty
table rows, or placeholder task text
Example:
```
## 1. Context Review
- [ ] 1.1 Read design.md and identify scope, requirements, decisions, guardrails, and open questions
- [ ] 1.2 Review relevant artifacts and references listed in Affected Areas
## 2. Execution
- [ ] 2.1 Execute first concrete work item from design.md
- [ ] 2.2 Execute next concrete work item from design.md
## 3. Validation
- [ ] 3.1 Run required validation from Verification Plan
- [ ] 3.2 Perform quality checks required by the project or workflow
- [ ] 3.3 Perform required manual review or acceptance checks from Verification Plan
## 4. Documentation / Communication
- [ ] 4.1 Update relevant documentation, runbooks, communication materials, or project references if behavior, process, interface, configuration, or usage changed
```
Reference design.md for scope, requirements, decisions, execution
direction, and verification expectations.
Each task should be verifiable: it must be clear when the task is done.
requires:
- design
apply:
requires:
- design
- tasks
tracks: tasks.md
instruction: |
Read design.md first, then tasks.md.
Also follow workflow context/configuration, such as openspec/config.yaml when available, and any relevant project or workflow documentation referenced by design.md.
Treat design.md as the source of truth for scope, requirements, decisions, guardrails, execution direction, and verification expectations.
Work through pending tasks in dependency order and mark complete as you go.
Mark a task complete only after its execution and required verification are done.
Pause if tasks conflict with design.md, if design.md has blocking open questions, or if clarification is needed.

View File

@@ -0,0 +1,77 @@
## Context
<!-- Problem, current state, relevant references, and triggering user request -->
## Discussion Notes
<!-- Key conclusions from exploration or prior discussion that apply must preserve -->
- Agreed conclusions:
- User preferences:
- Constraints:
- Rejected ideas:
## Requirements
<!-- Expected outcomes, behavior/process/interface/content changes, continuity expectations, and acceptance criteria -->
| Requirement | Acceptance Criteria |
| ----------- | ------------------- |
| | |
## Goals / Non-Goals
**Goals:**
<!-- What this design aims to achieve -->
**Non-Goals:**
<!-- What is explicitly out of scope -->
## Execution Guardrails
<!-- Must-follow constraints, forbidden approaches, preserved behavior/processes, dependency limits, and project- or workflow-specific boundaries -->
- Dependencies:
- Constraints:
- Quality Bar:
- Stakeholders:
- Documentation / Communication:
- Compatibility / Continuity:
## Affected Areas
<!-- Concrete artifacts, references, stakeholders, systems, workstreams, documents, configurations, assets, or handoffs relevant to this change -->
| Area | Artifacts / References | Expected Change | Notes |
| ---- | ---------------------- | --------------- | ----- |
| <!-- Area --> | <!-- Artifacts / References --> | <!-- Expected Change --> | <!-- Notes --> |
## Decisions
<!-- Key decisions, rationale, and alternatives considered -->
| Decision | Rationale | Alternatives Rejected |
| -------- | --------- | --------------------- |
| | | |
## Execution Plan
<!-- Main workstreams or artifacts to change, integration or handoff points, sequencing, and rollout notes -->
## Verification Plan
<!-- Validation checks, reviews, approvals, acceptance checks, documentation checks, communication checks, and manual checks needed -->
| Requirement / Risk | Verification |
| ------------------ | ------------ |
| | |
## Risks / Trade-offs
<!-- Format: [Risk] -> Mitigation -->
## Open Questions
| Status | Question | Decision Needed |
| ------ | -------- | --------------- |
| None | No open questions. | None |

View File

@@ -0,0 +1,19 @@
## 1. Context Review
- [ ] 1.1 Read design.md and identify scope, requirements, decisions, guardrails, and open questions
- [ ] 1.2 Review relevant artifacts and references listed in Affected Areas
## 2. Execution
- [ ] 2.1 Execute first concrete work item from design.md
- [ ] 2.2 Execute next concrete work item from design.md
## 3. Validation
- [ ] 3.1 Run required validation from Verification Plan
- [ ] 3.2 Perform quality checks required by the project or workflow
- [ ] 3.3 Perform required manual review or acceptance checks from Verification Plan
## 4. Documentation / Communication
- [ ] 4.1 Update relevant documentation, runbooks, communication materials, or project references if behavior, process, interface, configuration, or usage changed

View File

@@ -1,59 +0,0 @@
## Purpose
定义后端 API 路由的组织规范:按端点拆分为独立 handler、共享响应工具集中管理、路径参数由 Bun routes 解析,静态资源服务由 HTML import manifest 管理。
## Requirements
### Requirement: 路由按职责拆分
系统 SHALL 将 HTTP 路由处理逻辑按 API 端点拆分为独立模块,每个模块导出 route handler 函数供 routes 对象统一注册。
#### Scenario: health 端点独立路由
- **WHEN** 客户端请求 `GET /health`
- **THEN** `routes/health.ts` 导出的 handler 负责处理,返回 HealthResponse JSON
#### Scenario: summary 端点独立路由
- **WHEN** 客户端请求 `GET /api/summary`
- **THEN** `routes/summary.ts` 导出的 handler 负责处理,委托 store 查询并返回 SummaryResponse JSON
#### Scenario: targets 端点独立路由
- **WHEN** 客户端请求 `GET /api/targets`
- **THEN** `routes/targets.ts` 导出的 handler 负责处理,委托 store 查询并返回 TargetStatus[] JSON
#### Scenario: history 端点独立路由
- **WHEN** 客户端请求 `GET /api/targets/:id/history?from=ISO&to=ISO`
- **THEN** `routes/history.ts` 导出的 handler 负责处理,通过 `req.params.id` 获取路径参数包含参数校验、store 查询和 HistoryResponse 返回
#### Scenario: trend 端点独立路由
- **WHEN** 客户端请求 `GET /api/targets/:id/trend?from=ISO&to=ISO`
- **THEN** `routes/trend.ts` 导出的 handler 负责处理,通过 `req.params.id` 获取路径参数包含参数校验、store 查询和 TrendPoint[] 返回
### Requirement: 共享辅助函数集中管理
系统 SHALL 将跨路由共享的响应格式化函数抽取到 helpers.ts 模块,单一职责、集中管理。
#### Scenario: createApiError 集中定义
- **WHEN** 任意路由需要返回 API 错误响应
- **THEN** 从 `helpers.ts` 导入 `createApiError` 函数,提供错误信息和状态码
#### Scenario: jsonResponse 集中定义
- **WHEN** 任意路由需要返回 JSON 响应
- **THEN** 从 `helpers.ts` 导入 `jsonResponse` 函数,处理 HEAD 方法、Content-Type 和安全头
#### Scenario: mapCheckResult 集中定义
- **WHEN** 需要将 StoredCheckResult 映射为 API CheckResult
- **THEN** 从 `helpers.ts` 导入 `mapCheckResult` 函数,处理 failure JSON 解析和格式转换
### Requirement: 静态资源服务独立管理
系统 SHALL 将 SPA fallback 逻辑交给 routes 对象中的 HTML import 通配符处理,静态资源服务由 Bun 内置 manifest 机制自动处理。
#### Scenario: 根路径返回 index.html
- **WHEN** 客户端请求 `/`
- **THEN** routes 中注册的 HTML import 自动返回 index.html
#### Scenario: 资源文件返回正确 Content-Type
- **WHEN** 客户端请求构建后的静态资源
- **THEN** Bun 内置 manifest 机制自动返回正确的 Content-Type 和缓存头
#### Scenario: SPA fallback
- **WHEN** 客户端请求非 API、非资源的路径`/dashboard`
- **THEN** routes 中注册的 `"/*"` HTML import 通配符返回 index.html 实现 SPA 的客户端路由

View File

@@ -1,102 +0,0 @@
## Purpose
定义后端代码中 es-toolkit 和 Bun 内置 API 的使用规范:类型判断、空值检测、深度比较、错误判断、并发控制、集合分组和 Web API 标准方法,替代手写实现落实库使用优先级规则。
## Requirements
### Requirement: 使用 es-toolkit 进行类型判断
系统 SHALL 使用 es-toolkit 的 `isPlainObject` 替代手写的对象类型判断函数,用于 expect 校验中区分纯值(原始值)和操作符对象。
#### Scenario: 识别纯对象为操作符
- **WHEN** body 校验规则中 expected 配置为 `{ equals: "value" }`(纯对象操作符)
- **THEN** `isPlainObject(expected)` SHALL 返回 true系统按操作符语义处理
#### Scenario: 排除非纯对象作为操作符
- **WHEN** body 校验规则中 expected 为原始值如 `"value"` 或数字 `200`
- **THEN** `isPlainObject(expected)` SHALL 返回 false系统按 equals 默认操作符处理
### Requirement: 使用 es-toolkit 进行空值检测
系统 SHALL 使用 es-toolkit 的 `isNil` 替代手写的 `actual === null || actual === undefined` 检测,用于 expect 中 `empty` 操作符的空值判断。
#### Scenario: null 值判定为空
- **WHEN** 校验值为 null
- **THEN** `isNil(null)` SHALL 返回 true
#### Scenario: undefined 值判定为空
- **WHEN** 校验值为 undefined
- **THEN** `isNil(undefined)` SHALL 返回 true
#### Scenario: 非空值判定为非空
- **WHEN** 校验值为 0、"false"、空数组 `[]` 等非 nil 值
- **THEN** `isNil(value)` SHALL 返回 false
### Requirement: 使用 es-toolkit 进行空对象检测
系统 SHALL 使用 es-toolkit 的 `isEmptyObject` 替代手写的 `typeof actual === "object" && Object.keys(actual).length === 0` 检测,用于 expect 中 `empty` 操作符的空对象判断。
#### Scenario: 空对象判定为空
- **WHEN** 校验值为 `{}`
- **THEN** `isEmptyObject({})` SHALL 返回 true
#### Scenario: 非空对象判定为非空
- **WHEN** 校验值为 `{ key: "val" }`
- **THEN** `isEmptyObject({ key: "val" })` SHALL 返回 false
#### Scenario: null 不是空对象
- **WHEN** 校验值为 null
- **THEN** `isEmptyObject(null)` SHALL 返回 false空值由 isNil 前置处理)
### Requirement: 使用 es-toolkit 进行深度相等比较
系统 SHALL 使用 es-toolkit 的 `isEqual` 替代 `!==` 浅比较,用于 expect 中 `equals` 操作符的值比较,支持对象和数组的深度比较。
#### Scenario: 原始值浅比较
- **WHEN** expected 和 actual 均为原始值字符串、数字、布尔值、null
- **THEN** `isEqual(actual, expected)` 的行为 SHALL 与 `actual === expected` 一致
#### Scenario: 对象深度比较
- **WHEN** expected 和 actual 均为对象(如从 JSONPath 提取的结构化数据)
- **THEN** `isEqual(actual, expected)` SHALL 递归比较所有属性值,而非引用比较
### Requirement: 使用 es-toolkit 进行错误类型判断
系统 SHALL 使用 es-toolkit 的 `isError` 替代 `error instanceof Error`,用于 HTTP runner 和 cmd runner 中的错误类型判断。
#### Scenario: Error 实例识别
- **WHEN** 错误对象为 `new Error("msg")`
- **THEN** `isError(error)` SHALL 返回 true
#### Scenario: Error 子类识别
- **WHEN** 错误对象为继承 Error 的自定义类型
- **THEN** `isError(error)` SHALL 返回 true
#### Scenario: 非 Error 对象识别
- **WHEN** 错误对象为字符串或普通对象
- **THEN** `isError(error)` SHALL 返回 false
### Requirement: 使用 es-toolkit Semaphore 实现并发控制
系统 SHALL 使用 es-toolkit 的 `Semaphore` 类替代手写的信号量实现(计数器 + Promise 队列),用于 ProbeEngine 中的组内并发拨测控制。
#### Scenario: 获取并发槽位
- **WHEN** 当前并发数未达上限
- **THEN** `semaphore.acquire()` SHALL 立即返回,不阻塞
#### Scenario: 等待并发槽位
- **WHEN** 当前并发数已达上限 maxConcurrentChecks
- **THEN** `semaphore.acquire()` SHALL 阻塞等待,直到其他任务调用 `semaphore.release()`
#### Scenario: 释放并发槽位
- **WHEN** 调用 `semaphore.release()`
- **THEN** 系统 SHALL 唤醒一个等待中的 acquire() 调用
### Requirement: 使用 es-toolkit groupBy 实现 target 分组
系统 SHALL 使用 es-toolkit 的 `groupBy` 函数替代手写的 Map 循环分组,用于 ProbeEngine 中按 interval 分组拨测目标。
#### Scenario: 按 interval 分组
- **WHEN** 输入包含不同 intervalMs 值的多个 target
- **THEN** `groupBy(targets, t => t.intervalMs)` SHALL 返回 key 为 intervalMs 值的分组对象,值为对应 target 数组
### Requirement: 使用 Bun 内置 API 进行 Headers 转换
系统 SHALL 使用 `Object.fromEntries(headers)` 标准 Web API 替代手写的 `headersToRecord` 函数,用于将 Fetch API 的 Headers 对象转换为键值对。
#### Scenario: 转换响应头
- **WHEN** HTTP runner 获取到 response headers
- **THEN** `Object.fromEntries(response.headers)` SHALL 返回以 header 名称为 key、header 值为 value 的对象

View File

@@ -1,92 +0,0 @@
## Purpose
定义 ProbeStore 的批量查询方法getLatestChecksMap、getAllTargetStats以及 getSummary 和 createTargetsResponse 的 N+1 查询优化规范。同时约定单次查询操作使用 db.query() 利用内置缓存。
## Requirements
### Requirement: 批量查询最新检查结果
系统 SHALL 提供 `getLatestChecksMap` 方法,通过单次 SQL 查询获取所有 target 的最新一次 check 结果,返回 Map 结构供调用方按 target_id 索引。
#### Scenario: 获取所有目标的最新检查
- **WHEN** 调用 `getLatestChecksMap()`
- **THEN** 系统 SHALL 执行子查询找到每个 target_id 的 MAX(timestamp),再 JOIN 回 check_results 获取完整行,返回 `Map<number, StoredCheckResult | null>`
#### Scenario: 目标无历史记录
- **WHEN** 某 target 在 check_results 表中无任何记录
- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key
### Requirement: 批量查询目标统计
系统 SHALL 提供 `getAllTargetStats` 方法,通过单次 SQL GROUP BY 聚合查询获取所有 target 的拨测统计totalChecks 和 availability。availability 计算精度 SHALL 与 `getTargetStats` 一致,统一使用 `Math.round(value * 100) / 100` 保留两位小数。
#### Scenario: 获取所有目标的聚合统计
- **WHEN** 调用 `getAllTargetStats()`
- **THEN** 系统 SHALL 执行 `SELECT target_id, COUNT(*), SUM(CASE WHEN matched=1 THEN 1 ELSE 0 END) FROM check_results GROUP BY target_id`,在内存中计算 availability 并返回 `Map<number, { totalChecks, availability }>`
#### Scenario: 目标无历史记录
- **WHEN** 某 target 在 check_results 表中无任何记录
- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key
#### Scenario: availability 精度
- **WHEN** 计算 availabilityupCount / totalChecks * 100
- **THEN** 结果 SHALL 使用 `Math.round(value * 100) / 100` 四舍五入保留两位小数,与 `getTargetStats` 方法一致
### Requirement: summary 查询使用批量方法
`getSummary` 方法 SHALL 使用 `getLatestChecksMap` 一次性获取所有 target 的最新检查结果,而非对每个 target 逐条查询。
#### Scenario: 统计总览使用批量查询
- **WHEN** 调用 `store.getSummary()`
- **THEN** 系统 SHALL 调用 `getLatestChecksMap()` 一次获取所有最新结果,在内存中遍历统计 up/down 数量,而非循环 N 次调用 `getLatestCheck()`
### Requirement: targets 列表使用批量方法
`handleTargets`routes/targets.ts 中生成 TargetStatus[] 的逻辑SHALL 使用 `getLatestChecksMap``getAllTargetStats``getAllRecentSamples` 替代逐目标查询,消除 N+1 查询。
#### Scenario: 目标列表使用批量查询
- **WHEN** 处理 `GET /api/targets` 请求
- **THEN** 系统 SHALL 分别调用 `getLatestChecksMap()``getAllTargetStats()``getAllRecentSamples(30)` 批量获取数据,在内存中组装 TargetStatus 数组,而非对每个 target 逐条查询数据库
#### Scenario: 目标无采样数据
- **WHEN** 某 target 在 getAllRecentSamples 返回的 Map 中不存在
- **THEN** 该 target 的 recentSamples SHALL 为空数组
### Requirement: 批量查询所有目标的最近采样数据
系统 SHALL 提供 `getAllRecentSamples(limit: number)` 方法,通过单次 SQL 查询获取所有 target 的最近 N 条采样数据,返回 `Map<number, Array<{ timestamp: string; duration_ms: number | null; matched: number }>>` 结构。
#### Scenario: 获取所有目标的最近采样
- **WHEN** 调用 `getAllRecentSamples(30)`
- **THEN** 系统 SHALL 通过单次 SQL 查询获取每个 target 最近 30 条记录,返回按 target_id 索引的 Map
#### Scenario: 目标无历史记录
- **WHEN** 某 target 在 check_results 表中无任何记录
- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key
#### Scenario: 采样数据排序
- **WHEN** 获取采样数据
- **THEN** 每个 target 的记录 SHALL 按 timestamp 降序排列(最新在前)
### Requirement: prepared statement 使用 query() 缓存
ProbeStore 中不涉及事务内复用的单次读/写操作 SHALL 使用 `this.db.query()` 而非 `this.db.prepare()`,利用 bun:sqlite 内置的 statement 缓存机制。
#### Scenario: insertCheckResult 使用 query
- **WHEN** 写入一条检查结果
- **THEN** `insertCheckResult` SHALL 使用 `this.db.query("INSERT INTO ...").run(...)` 而非 `this.db.prepare("INSERT INTO ...").run(...)`
#### Scenario: getHistory 查询使用 query
- **WHEN** 查询历史记录(包括 COUNT 和分页查询)
- **THEN** `getHistory` SHALL 使用 `this.db.query(...)` 而非 `this.db.prepare(...)`
#### Scenario: getTargetStats 查询使用 query
- **WHEN** 查询单目标统计
- **THEN** `getTargetStats` SHALL 使用 `this.db.query(...)` 而非 `this.db.prepare(...)`
#### Scenario: getTrend 查询使用 query
- **WHEN** 查询趋势数据
- **THEN** `getTrend` SHALL 使用 `this.db.query(...)` 而非 `this.db.prepare(...)`
#### Scenario: getRecentSamples 查询使用 query
- **WHEN** 查询采样数据
- **THEN** `getRecentSamples` SHALL 使用 `this.db.query(...)` 而非 `this.db.prepare(...)`
#### Scenario: syncTargets 事务保持 prepare例外
- **WHEN** 同步 targets 配置(事务内多次复用 insertStmt/updateStmt/deleteStmt
- **THEN** `syncTargets` 方法 SHALL 保持使用 `this.db.prepare()`,因需要在事务闭包内持有引用

View File

@@ -1,49 +0,0 @@
## Purpose
定义基于 Bun.serve `routes` 对象的全栈声明式路由注册、路径参数、HTTP method 声明和 fallback 行为。
## Requirements
### Requirement: 声明式路由注册
系统 SHALL 使用 Bun.serve 的 `routes` 对象以声明式方式注册 API 端点路由,非 API 请求由 `fetch` fallback 处理。
#### Scenario: API 端点路由注册
- **WHEN** server 启动时
- **THEN** 系统 SHALL 将所有 API 端点以 method handler 对象形式注册到 `routes` 对象
#### Scenario: 非 API 请求处理
- **WHEN** 请求路径不匹配任何 `routes` 中注册的路由
- **THEN** `fetch` fallback SHALL 将请求交给静态资源服务处理production或返回提示文本development
### Requirement: 路径参数支持
系统 SHALL 使用 routes 对象的 `:param` 语法声明路径参数,替代手动 regex 匹配。
#### Scenario: 带路径参数的 API 路由
- **WHEN** 客户端请求 `/api/targets/123/history`
- **THEN** 系统 SHALL 通过 `routes` 中注册的 `/api/targets/:id/history` 匹配,并通过 `req.params.id` 获取参数值 `"123"`
#### Scenario: 路径参数类型
- **WHEN** route handler 接收到路径参数
- **THEN** 参数值 SHALL 为字符串类型handler 负责进行类型转换和校验
### Requirement: HTTP Method 声明
系统 SHALL 在 routes 对象中为每个 API 端点以 per-method handler 形式声明支持的 HTTP method未匹配 method 的 API 请求 SHALL 落入 `/api/*` 通配符并返回 JSON 404。
#### Scenario: 单 method 端点
- **WHEN** API 端点只支持 GET 方法
- **THEN** 该端点 SHALL 以 `{ GET(req) { ... } }` 形式注册
#### Scenario: 不支持的 method 请求
- **WHEN** 客户端使用未声明的 method 请求 API 端点
- **THEN** `/api/*` 通配符 SHALL 返回 JSON 格式的 404 错误响应
### Requirement: Fetch Fallback 处理
系统 SHALL 使用 `fetch` handler 作为非 API 请求的入口,负责静态资源服务和 SPA fallback。
#### Scenario: Production 模式 fetch fallback
- **WHEN** production 模式下请求未匹配 routes 中的 API 路由
- **THEN** `fetch` handler SHALL 调用 `serveStaticAsset` 返回对应静态资源或 SPA fallback
#### Scenario: Development 模式 fetch fallback
- **WHEN** development 模式下请求未匹配 routes 中的 API 路由
- **THEN** `fetch` handler SHALL 返回提示文本,引导开发者通过 Vite dev server 访问前端

View File

@@ -1,153 +0,0 @@
## Purpose
定义 checker 模块的内聚化组织结构,确保每个 checker 以独立目录形式存在包含其全部类型定义、schema 声明、语义校验、执行逻辑和断言逻辑。同时定义共享的 expect/ 和 schema/ 基础设施,以及严格的依赖方向约束。
## Requirements
### Requirement: Checker 目录内聚结构
每个 checker SHALL 以独立目录形式存在于 `src/server/checker/runner/<type>/`,目录内 SHALL 包含该 checker 的全部类型定义、schema 声明、语义校验、执行逻辑和断言逻辑。
#### Scenario: HTTP checker 目录完整性
- **WHEN** 开发者查看 `src/server/checker/runner/http/` 目录
- **THEN** 该目录 SHALL 包含 `index.ts``types.ts``schema.ts``execute.ts``expect.ts``body.ts``validate.ts`
#### Scenario: Cmd checker 目录完整性
- **WHEN** 开发者查看 `src/server/checker/runner/cmd/` 目录
- **THEN** 该目录 SHALL 包含 `index.ts``types.ts``schema.ts``execute.ts``expect.ts``text.ts``validate.ts`
#### Scenario: 新增 checker 最小改动
- **WHEN** 开发者新增一个 checker 类型(如 dns
- **THEN** 开发者 SHALL 只需创建 `src/server/checker/runner/dns/` 目录及其内部文件,并在 `runner/index.ts` 注册列表中添加一行 import 和一行数组项
### Requirement: Checker 目录文件职责
每个 checker 目录内的文件 SHALL 遵循统一的职责划分。
#### Scenario: index.ts 仅做 re-export
- **WHEN** 开发者查看某 checker 的 `index.ts`
- **THEN** 该文件 SHALL 仅包含对 `execute.ts` 中 Checker 类的 re-export不包含任何逻辑
#### Scenario: types.ts 包含该 checker 全部专属类型
- **WHEN** 开发者需要该 checker 的配置类型、resolved 类型或 expect 类型
- **THEN** 这些类型 SHALL 全部定义在该 checker 目录的 `types.ts` 中,不在顶层 `types.ts`
#### Scenario: schema.ts 包含 TypeBox schema 定义
- **WHEN** 开发者需要该 checker 的 config/defaults/expect schema
- **THEN** 这些 schema SHALL 定义在该 checker 目录的 `schema.ts`
#### Scenario: execute.ts 包含 Checker 类实现
- **WHEN** 开发者需要查看该 checker 的执行逻辑
- **THEN** Checker 类(实现 CheckerDefinition 接口SHALL 定义在 `execute.ts`
#### Scenario: validate.ts 包含该 checker 全部语义校验
- **WHEN** 开发者需要查看该 checker 的配置校验逻辑
- **THEN** 该 checker 专属的语义校验函数 SHALL 全部定义在 `validate.ts`
#### Scenario: expect.ts 包含该 checker 专属断言
- **WHEN** 开发者需要查看该 checker 的断言逻辑
- **THEN** 该 checker 专属的断言函数 SHALL 定义在 `expect.ts`
### Requirement: 断言基础设施目录
系统 SHALL 在 `src/server/checker/expect/` 目录中提供所有 checker 共享的断言基础设施。
#### Scenario: expect 共享类型位置
- **WHEN** 任何 checker 需要使用断言相关的共享类型(如 `ExpectResult`
- **THEN** 这些类型 SHALL 从 `src/server/checker/expect/types.ts` 导入
#### Scenario: operator 断言引擎位置
- **WHEN** 任何 checker 需要使用 `applyOperator``evaluateJsonPath``checkExpectValue`
- **THEN** 这些函数 SHALL 从 `src/server/checker/expect/operator.ts` 导入
#### Scenario: duration 断言位置
- **WHEN** 任何 checker 需要使用 `checkDuration`
- **THEN** 该函数 SHALL 从 `src/server/checker/expect/duration.ts` 导入
#### Scenario: failure 构造器位置
- **WHEN** 任何 checker 需要使用 `errorFailure``mismatchFailure`
- **THEN** 这些函数 SHALL 从 `src/server/checker/expect/failure.ts` 导入
#### Scenario: operator 校验位置
- **WHEN** 任何 checker 的 validate 需要使用 `validateOperatorObject`
- **THEN** 该函数 SHALL 从 `src/server/checker/expect/validate-operator.ts` 导入
#### Scenario: ExpectResult 类型位置
- **WHEN** 任何 checker 需要使用 `ExpectResult` 类型
- **THEN** 该类型 SHALL 从 `src/server/checker/expect/types.ts` 导入
### Requirement: Schema 目录结构
系统 SHALL 在 `src/server/checker/schema/` 目录中组织配置 schema 体系,替代原 `config-contract/` 目录。
#### Scenario: schema 目录包含 builder
- **WHEN** 系统需要从 registry 动态构建整体配置 schema
- **THEN** 该逻辑 SHALL 位于 `src/server/checker/schema/builder.ts`
#### Scenario: schema 目录包含 fragments
- **WHEN** checker 的 schema.ts 需要引用共享 schema 片段(如 durationSchema、sizeSchema
- **THEN** 这些片段 SHALL 从 `src/server/checker/schema/fragments.ts` 导入
#### Scenario: schema 目录包含 Ajv 校验入口
- **WHEN** config-loader 需要执行契约校验
- **THEN** 校验入口 SHALL 位于 `src/server/checker/schema/validate.ts`
#### Scenario: schema 目录包含 issue 工具
- **WHEN** 任何校验逻辑需要构造 ConfigValidationIssue
- **THEN** issue 类型和工具函数 SHALL 从 `src/server/checker/schema/issues.ts` 导入
### Requirement: 工具函数归集
系统 SHALL 在 `src/server/checker/utils.ts` 中提供纯工具函数。
#### Scenario: parseSize 位置
- **WHEN** 任何模块需要解析 size 字符串(如 "100MB"
- **THEN** `parseSize` SHALL 从 `src/server/checker/utils.ts` 导入
#### Scenario: parseDuration 位置
- **WHEN** 任何模块需要解析 duration 字符串(如 "30s"
- **THEN** `parseDuration` SHALL 从 `src/server/checker/utils.ts` 导入
### Requirement: 依赖方向约束
checker 系统内的模块依赖 SHALL 遵循严格的分层方向。
#### Scenario: checker 之间无横向依赖
- **WHEN** 开发者查看任何 checker 目录的 import 语句
- **THEN** 该 checker SHALL NOT 导入其他 checker 目录的任何模块
#### Scenario: expect/ 不依赖 runner/
- **WHEN** 开发者查看 `expect/` 目录的 import 语句
- **THEN** `expect/` 中的文件 SHALL NOT 导入 `runner/` 目录的任何模块
#### Scenario: schema/ 不依赖 runner/ 的具体 checker
- **WHEN** 开发者查看 `schema/` 目录的 import 语句
- **THEN** `schema/` 中的文件 SHALL 仅通过 `CheckerDefinition` 接口与 checker 交互SHALL NOT 直接导入具体 checker 目录
### Requirement: 显式注册列表
系统 SHALL 在 `src/server/checker/runner/index.ts` 中使用显式 import 列表注册所有 checker。
#### Scenario: 注册入口结构
- **WHEN** 开发者查看 `runner/index.ts`
- **THEN** 该文件 SHALL 包含所有 checker 的静态 import 和一个 checker 实例数组,通过循环调用 `registry.register()` 完成注册
#### Scenario: 新增 checker 注册
- **WHEN** 开发者新增一个 checker
- **THEN** 开发者 SHALL 在 `runner/index.ts` 中添加一行 import 和一行数组项,无需修改其他文件
### Requirement: 公共类型文件瘦身
顶层 `src/server/checker/types.ts` SHALL 仅保留跨 checker 共享的 base 类型和存储相关类型。
#### Scenario: types.ts 不包含 checker 专属类型
- **WHEN** 开发者查看顶层 `types.ts`
- **THEN** 该文件 SHALL NOT 包含 `HttpTargetConfig``ResolvedHttpTarget``CommandExpectConfig``BodyRule``TextRule` 等 checker 专属类型
#### Scenario: types.ts 保留 base 类型
- **WHEN** 开发者查看顶层 `types.ts`
- **THEN** 该文件 SHALL 包含 `ResolvedTargetBase``RawTargetConfig``DefaultsConfig``CheckResult``ExpectOperator``CheckFailure``StoredTarget``StoredCheckResult``JsonValue` 等公共类型
#### Scenario: ResolvedTargetBase 替代联合类型
- **WHEN** engine、store、config-loader 需要引用 resolved target 类型
- **THEN** 这些模块 SHALL 使用 `ResolvedTargetBase` interface不再使用硬编码联合类型
#### Scenario: DefaultsConfig 为宽松 base 形式
- **WHEN** 开发者查看顶层 `types.ts` 中的 `DefaultsConfig`
- **THEN** 该 interface SHALL 仅包含公共字段(`interval?``timeout?`)和 index signature`[checkerKey: string]: unknown`SHALL NOT 包含 `cmd?``http?` 等 checker 专属字段
#### Scenario: 各 checker validate 自行 narrow defaults
- **WHEN** checker 的 `validate()` 方法需要访问自身的 defaults 配置
- **THEN** checker SHALL 从 `DefaultsConfig` 中通过 `defaults[configKey]` 获取并自行 narrow 为具体类型

View File

@@ -1,198 +0,0 @@
## Purpose
定义 Checker 接口规范、注册机制、CheckerContext 上下文注入,以及共享 expect 断言函数的职责边界。此 capability 是 checker 系统的架构基础,不定义任何具体 checker 类型的业务行为。
## Requirements
### Requirement: Checker 配置契约片段
系统 SHALL 支持 checker 提供自身 TypeBox 配置契约片段,用于描述该 checker 的 defaults 分组、target 领域分组和 expect 分组。公共配置加载模块 SHALL 通过 registry 获取已注册 checker 的契约片段,并组合为启动期 Ajv 契约校验流程和外部 `probe-config.schema.json` 导出流程。
#### Scenario: HTTP checker 提供契约片段
- **WHEN** HTTP checker 被注册
- **THEN** registry SHALL 能提供 HTTP defaults、HTTP target 和 HTTP expect 的 TypeBox 契约片段
#### Scenario: Cmd checker 提供契约片段
- **WHEN** Cmd checker 被注册
- **THEN** registry SHALL 能提供 Cmd defaults、Cmd target 和 Cmd expect 的 TypeBox 契约片段
#### Scenario: 新 checker 只维护自身契约
- **WHEN** 开发者新增一个 checker 类型
- **THEN** 该 checker SHALL 提供自身 TypeBox 配置契约和语义 validator而不需要把 checker 专属字段写入中央手工校验逻辑
#### Scenario: 外部 schema 通过 registry 生成
- **WHEN** 系统生成 `probe-config.schema.json`
- **THEN** 生成流程 SHALL 从 registry 获取已注册 checker 的契约片段,并将其组合进完整配置 schema
#### Scenario: 契约组装不依赖全局 singleton
- **WHEN** 测试或 schema 生成流程需要组装配置契约
- **THEN** 系统 SHALL 支持传入 fresh CheckerRegistry 实例完成契约组装,避免重复注册或全局状态污染
### Requirement: Checker 启动期语义校验
系统 SHALL 支持 checker 提供启动期语义 validator用于校验 TypeBox/Ajv 契约不适合表达或需要 checker 业务知识判断的配置规则。语义 validator MUST 在 resolver 填充最终 ResolvedTarget 之前执行,并 MUST 返回 `ConfigValidationIssue[]`
#### Scenario: checker 语义校验先于 resolve
- **WHEN** config-loader 准备解析一个 target
- **THEN** 系统 SHALL 先完成该 target 的 checker 语义校验,再调用 checker.resolve()
#### Scenario: 语义校验失败阻止启动
- **WHEN** checker 语义 validator 发现非法配置
- **THEN** 系统 SHALL 以配置错误退出,不进入 checker 执行阶段
### Requirement: 结构化配置校验 issue
系统 SHALL 使用统一 `ConfigValidationIssue` 表示配置校验问题,至少包含 `code``path``message`,并支持可选 `targetName`。契约校验和 checker 语义校验都 SHALL 产出该结构,由配置加载模块统一渲染为中文错误。
#### Scenario: Ajv 错误转换为 issue
- **WHEN** Ajv 校验发现 required、type 或 additionalProperties 错误
- **THEN** 系统 SHALL 将该错误转换为 `ConfigValidationIssue`,保留配置路径和可读 message
#### Scenario: checker validator 返回 issue
- **WHEN** checker 语义 validator 发现非法 XPath 或正则表达式
- **THEN** checker SHALL 返回 `ConfigValidationIssue`,而不是直接抛出最终用户错误字符串
### Requirement: Checker 接口定义
系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的泛型 `CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase>`,包含 `type``configKey`、TypeBox 配置契约、启动期语义校验、`resolve``execute``serialize` 成员。泛型参数 SHALL 约束 `execute``serialize` 方法的 target 参数类型,使 checker 实现内部获得编译期类型安全。默认泛型参数 `= ResolvedTargetBase` 保证中间层registry、engine、config-loader无需指定泛型。
#### Scenario: Checker 接口包含必要方法
- **WHEN** 开发者实现一个新的 Checker
- **THEN** 该实现 MUST 提供 `type`(字符串标识)、`configKey`配置分组名、TypeBox 配置契约、启动期语义校验、`resolve(target, context)`(解析配置并填充默认值)、`execute(target, ctx)`(执行探测返回 CheckResult`serialize(target)`(返回 target 展示文本和 config JSON
#### Scenario: CheckerContext 注入 signal
- **WHEN** 引擎调用 `checker.execute(target, ctx)`
- **THEN** `ctx.signal` SHALL 是一个由引擎创建的 `AbortSignal`,在超时或引擎关闭时 abort
#### Scenario: resolve 不承担通用契约校验
- **WHEN** config-loader 调用 checker.resolve()
- **THEN** checker.resolve() SHALL 假定配置已经通过 TypeBox/Ajv 契约校验和启动期语义校验,只负责默认值填充、路径解析和领域配置转换
#### Scenario: type 与 configKey 默认一致
- **WHEN** checker 定义 `type: "tcp"`
- **THEN** checker 的 `configKey` SHALL 默认使用 `"tcp"`,对应 target 的 `tcp` 分组和 defaults.tcp 分组
#### Scenario: 接口方法使用泛型约束
- **WHEN** 开发者查看 `CheckerDefinition<TResolved>` 接口签名
- **THEN** `resolve` 的返回值 SHALL 为 `TResolved``execute` 的参数 SHALL 为 `TResolved``serialize` 的参数 SHALL 为 `TResolved`
#### Scenario: checker 实现无需手动断言
- **WHEN** HttpChecker 实现 `CheckerDefinition<ResolvedHttpTarget>`
- **THEN** `execute` 方法的 target 参数类型 SHALL 直接为 `ResolvedHttpTarget`,无需在方法内部使用 `as` 类型断言
#### Scenario: registry 使用默认泛型参数
- **WHEN** CheckerRegistry 存储和返回 checker 实例
- **THEN** registry 内部 SHALL 使用 `CheckerDefinition`(等价于 `CheckerDefinition<ResolvedTargetBase>`),实现类型擦除
### Requirement: CheckerRegistry 注册中心
系统 SHALL 在 `src/server/checker/runner/registry.ts` 中提供 `CheckerRegistry` 类,支持 `register(checker)``get(type)``supportedTypes`。重复注册同一 type SHALL 抛出错误。registry 内部 SHALL 存储 `CheckerDefinition`(使用默认泛型参数),对外提供类型擦除后的接口。
#### Scenario: 注册并获取 Checker
- **WHEN** 调用 `registry.register(new HttpChecker())` 后再调用 `registry.get("http")`
- **THEN** 返回的 SHALL 是之前注册的 HttpChecker 实例(类型为 `CheckerDefinition`
#### Scenario: 获取未注册的 type
- **WHEN** 调用 `registry.get("unknown")` 且未注册对应 type 的 checker
- **THEN** 系统 SHALL 抛出错误,提示不支持的 probe type
#### Scenario: 重复注册
- **WHEN** 同一 type 值被重复 `register()`
- **THEN** 系统 SHALL 抛出错误,提示该 type 已注册
#### Scenario: 查询支持的 type 列表
- **WHEN** 注册了 "http" 和 "cmd" 两个 checker 后查询 `registry.supportedTypes`
- **THEN** 返回的数组 SHALL 包含 `["http", "cmd"]`(按注册顺序)
### Requirement: 引擎通过 registry 调度 checker
系统 SHALL 在 `ProbeEngine.runCheck()` 中通过 `checkerRegistry.get(target.type).execute(target, ctx)` 调度检查,替代原有的 `switch/case` 分支。
#### Scenario: 引擎使用 registry 调度
- **WHEN** engine 需要执行一个 type 为 "http" 的 target
- **THEN** engine SHALL 从 `checkerRegistry` 中获取对应 checker 并调用其 `execute()` 方法,不再使用 `switch/case`
#### Scenario: 引擎注入超时 signal
- **WHEN** engine 调度一次 checker 执行
- **THEN** engine SHALL 创建 `AbortController`,设置超时定时器,将 `controller.signal` 注入 `CheckerContext`,执行完成后清理定时器
### Requirement: 配置解析通过 registry 委托 checker
系统 SHALL 在 `config-loader.ts` 的配置加载流程中通过 `checkerRegistry` 发现已注册 checker组合公共 TypeBox 契约与 checker 契约,并将 checker 专属语义校验和解析委托给对应 checker。`validateConfig()` SHALL 仅保留公共语义校验name 非空、name 不重复、group 类型、type 已注册等)和契约调度职责,不包含 checker 专属字段校验。
#### Scenario: 配置契约通过 registry 组合
- **WHEN** config-loader 校验配置文件
- **THEN** config-loader SHALL 从 `checkerRegistry` 获取已注册 checker 的契约片段,并用于校验 defaults 与 targets 中对应 checker 的配置形状
#### Scenario: 配置解析委托 checker
- **WHEN** config-loader 解析一个 type 为 "cmd" 的 target
- **THEN** config-loader SHALL 调用 `checkerRegistry.get("cmd")` 获取对应 checker并委托该 checker 执行语义校验和 resolve
#### Scenario: 通用字段校验保留在 config-loader
- **WHEN** YAML 配置中某个 target 缺少 name 或 type 字段
- **THEN** config-loader 的公共校验流程 SHALL 仍负责校验这些通用字段
#### Scenario: type 专属校验下沉到 checker
- **WHEN** YAML 配置中 HTTP target 缺少 `http.url`
- **THEN** HTTP checker 的契约或语义校验 SHALL 抛出校验错误,提示缺少必填字段
#### Scenario: HTTP method 非法校验
- **WHEN** YAML 配置中 HTTP target 的 `http.method` 不是大写合法方法枚举值
- **THEN** HTTP checker 契约或语义校验 SHALL 抛出校验错误,提示 method 不合法
#### Scenario: URL 格式校验
- **WHEN** YAML 配置中 HTTP target 的 `http.url` 不以 `http://``https://` 开头
- **THEN** HttpChecker 的语义校验 SHALL 抛出校验错误,提示 URL 格式不合法
### Requirement: 存储序列化通过 registry 获取展示格式
系统 SHALL 在 `ProbeStore.syncTargets()` 中通过 `checkerRegistry.get(t.type).serialize(t)` 获取每个 target 的展示摘要(`target` 列)和配置 JSON`config` 列),替代 `buildTargetDisplay()` / `buildTargetConfig()` 中的类型分支。
#### Scenario: 序列化委托 checker
- **WHEN** store 同步 targets 表
- **THEN** store SHALL 对每个 target 调用对应 checker 的 `serialize()` 方法获取 `{ target, config }`
### Requirement: 共享 expect 断言函数
系统 SHALL 在 `src/server/checker/expect/` 中提供可被多个 checker 复用的 expect 函数。checker 专用的 expect 函数 SHALL 保留在各自子包内。仅被单个 checker 使用的断言模块 SHALL 位于该 checker 目录内。
#### Scenario: 共享 duration 断言
- **WHEN** 任何 checker 需要校验执行耗时
- **THEN** SHALL 调用 `expect/duration.ts` 中的 `checkDuration(durationMs, maxDurationMs?)`,返回统一的 `ExpectResult`
#### Scenario: 共享 operator 断言
- **WHEN** 任何 checker 需要对值执行 operator 匹配
- **THEN** SHALL 调用 `expect/operator.ts` 中的 `applyOperator(actual, op)`
#### Scenario: 共享 failure 构造
- **WHEN** 任何 checker 需要构造 CheckFailure 对象
- **THEN** SHALL 调用 `expect/failure.ts` 中的 `errorFailure()``mismatchFailure()`
#### Scenario: HTTP body 断言位于 HTTP 目录
- **WHEN** HTTP checker 需要对响应体执行 contains/regex/json/css/xpath 规则校验
- **THEN** SHALL 调用 `runner/http/body.ts` 中的 `checkBodyExpect(body, rules)`
#### Scenario: Cmd text 断言位于 Cmd 目录
- **WHEN** Cmd checker 需要对 stdout/stderr 执行文本规则校验
- **THEN** SHALL 调用 `runner/cmd/text.ts` 中的 `checkTextRules(text, rules, phase)`
#### Scenario: HTTP 专用 expect
- **WHEN** HTTP checker 需要校验响应状态码和响应头
- **THEN** SHALL 调用 `runner/http/expect.ts` 中的 `checkStatus()``checkHeaders()`
#### Scenario: Cmd 专用 expect
- **WHEN** Cmd checker 需要校验退出码
- **THEN** SHALL 调用 `runner/cmd/expect.ts` 中的 `checkExitCode()`
### Requirement: 超时控制由引擎注入 signal
Checker 实现的 `execute()` MUST 使用 `ctx.signal` 感知超时,不得自行创建 `AbortController``setTimeout` 用于超时控制。仅 cmd checker 可在 signal abort 时 `proc.kill()` 以确保子进程被终止。
#### Scenario: HTTP checker 使用 signal
- **WHEN** HttpChecker 执行 HTTP 请求
- **THEN** SHALL 将 `ctx.signal` 传入 `fetch()``signal` 选项,不自行创建 `AbortController`
#### Scenario: Cmd checker 响应 signal
- **WHEN** CommandChecker 执行命令且 signal 被 abort
- **THEN** SHALL 调用 `proc.kill()` 终止子进程,并在 CheckResult 中记录超时错误
### Requirement: CheckFailure.phase 使用 string 类型
`shared/api.ts``CheckFailure.phase` 的类型 SHALL 定义为 `string`,替代原有的硬编码联合类型 `"status" | "duration" | "headers" | "body" | "exitCode" | "stdout" | "stderr"`
#### Scenario: phase 支持 checker 专用值
- **WHEN** cmd checker 在执行失败spawn error时生成 failure
- **THEN** `failure.phase` SHALL 可以是 `"spawn"` 等任意字符串值,类型系统 SHALL 不报错
#### Scenario: 前端展示 phase 不依赖硬编码类型
- **WHEN** 前端收到任意 phase 字符串值
- **THEN** 前端 SHALL 直接展示而不做类型判断

View File

@@ -1,141 +0,0 @@
## Purpose
定义 Cmd 类型拨测目标:通过 `type: cmd` 配置执行本地命令(如进程检查、脚本健康检测),捕获 exit code、stdout、stderr按 expect 规则校验并生成 matched 判定。
## Requirements
### Requirement: cmd target 配置
系统 SHALL 支持 `type: cmd` 的 target 配置,通过 `cmd.exec``cmd.args` 描述本地命令,并使用 cmd 专用字段配置工作目录、环境变量和输出限制。
#### Scenario: 解析 cmd target
- **WHEN** YAML 中 target 配置 `type: cmd``cmd.exec: "pgrep"``cmd.args: ["nginx"]`
- **THEN** 系统 SHALL 将其解析为 cmd checker并保留 exec、args、cwd、env、maxOutputBytes、interval、timeout 和 expect 配置
#### Scenario: cmd target 缺少 exec
- **WHEN** YAML 中 target 配置 `type: cmd` 但缺少 `cmd.exec`
- **THEN** 系统 SHALL 以配置错误退出,并提示该 target 缺少 cmd.exec 字段
#### Scenario: cwd 相对配置文件目录解析
- **WHEN** cmd target 配置 `cmd.cwd: "scripts"` 且配置文件位于 `/opt/checker/probes.yaml`
- **THEN** 系统 SHALL 将 cwd 解析为 `/opt/checker/scripts`
#### Scenario: cmd 不使用 shell
- **WHEN** cmd target 配置 `exec``args`
- **THEN** 系统 MUST 直接执行该程序和参数,不通过 shell 解释整段命令字符串
#### Scenario: env 默认继承并允许覆盖
- **WHEN** cmd target 配置 `cmd.env: {LANG: "C"}` 且当前进程环境包含 `PATH`
- **THEN** 系统 SHALL 继承当前进程的全部环境变量,并将 `LANG` 覆盖为 `"C"`
#### Scenario: 不支持 stdin
- **WHEN** cmd target 配置并执行命令
- **THEN** 系统 MUST NOT 向子进程 stdin 写入数据,避免命令因等待输入而阻塞
### Requirement: cmd checker 执行
系统 SHALL 按 cmd target 配置执行本地命令记录执行耗时、退出码、stdout 和 stderr并在执行失败时产生结构化错误信息。
#### Scenario: 命令正常退出
- **WHEN** cmd target 执行的进程正常退出且 exit code 为 0
- **THEN** 系统 SHALL 记录 `durationMs``statusDetail="exitCode=0"`,并进入 expect 校验
#### Scenario: 命令非零退出
- **WHEN** cmd target 执行的进程正常退出但 exit code 为 1
- **THEN** 系统 SHALL 记录 `statusDetail="exitCode=1"`,并由 expect.exitCode 决定 matched 结果
#### Scenario: 命令启动失败
- **WHEN** cmd target 的 exec 不存在或无法启动
- **THEN** 系统 SHALL 记录 `matched=false`,并在 failure 中写入 kind=`error` 和可读错误信息
#### Scenario: 命令超时
- **WHEN** cmd target 在 timeout 时间内未结束
- **THEN** 系统 MUST 终止该子进程,记录 `matched=false`,并在 failure 中写入命令超时信息
#### Scenario: 命令输出超限
- **WHEN** cmd target 的 stdout 和 stderr 合计输出超过 `maxOutputBytes`
- **THEN** 系统 MUST 停止收集输出并终止该检查,记录 `matched=false`,并在 failure 中写入输出超限信息
### Requirement: cmd expect 校验
系统 SHALL 支持 cmd 专用 expect包括 `exitCode``stdout``stderr`,并按 exitCode、duration、stdout、stderr 的阶段顺序快速失败。
#### Scenario: 默认 exitCode 成功语义
- **WHEN** cmd target 未显式配置 `expect.exitCode`
- **THEN** 系统 SHALL 使用默认 `expect.exitCode: [0]` 进行校验
#### Scenario: 显式 exitCode 校验
- **WHEN** cmd target 配置 `expect.exitCode: [0, 2]` 且实际 exit code 为 2
- **THEN** 系统 SHALL 判定 exitCode 阶段通过,并继续后续 expect 阶段
#### Scenario: exitCode 不匹配快速失败
- **WHEN** cmd target 配置 `expect.exitCode: [0]` 且实际 exit code 为 1
- **THEN** 系统 SHALL 立即返回 `matched=false`,并在 failure 中写入 phase=`exitCode`、path=`expect.exitCode`、expected 和 actual
#### Scenario: stdout 按配置顺序校验
- **WHEN** cmd target 配置 `expect.stdout` 为两个规则,第一条通过且第二条失败
- **THEN** 系统 SHALL 先执行第一条 stdout 规则,再执行第二条,并将 failure.path 指向失败的 `expect.stdout[1]`
#### Scenario: stderr 校验为空
- **WHEN** cmd target 配置 `expect.stderr: [{empty: true}]` 且实际 stderr 为空字符串
- **THEN** 系统 SHALL 判定 stderr 阶段通过
#### Scenario: stdout 失败后不检查 stderr
- **WHEN** cmd target 同时配置 stdout 和 stderr 规则,且 stdout 规则失败
- **THEN** 系统 SHALL 快速失败并 MUST NOT 继续执行 stderr 规则
### Requirement: cmd checker 启动期配置校验
系统 SHALL 在启动期对 cmd checker 的配置契约和语义执行严格校验。Cmd target 的 `cmd` 分组 SHALL 只允许 `exec``args``cwd``env``maxOutputBytes` 字段Cmd expect SHALL 只允许 `exitCode``maxDurationMs``stdout``stderr` 字段。未知字段、非法类型和不可编译正则 MUST 导致启动期配置错误。`expect.exitCode` SHALL 保留原有有限整数数组语义,不限制到特定平台范围。
#### Scenario: cmd args 类型非法
- **WHEN** YAML 中 cmd target 配置 `cmd.args` 不是字符串数组
- **THEN** 系统 SHALL 以配置错误退出,提示 cmd.args 格式错误
#### Scenario: cmd cwd 类型非法
- **WHEN** YAML 中 cmd target 配置 `cmd.cwd` 不是字符串
- **THEN** 系统 SHALL 以配置错误退出,提示 cmd.cwd 必须为字符串
#### Scenario: cmd env 值类型非法
- **WHEN** YAML 中 cmd target 配置 `cmd.env`,且任一环境变量值不是字符串
- **THEN** 系统 SHALL 以配置错误退出,提示 cmd.env 对应变量值必须为字符串
#### Scenario: cmd maxOutputBytes 非法
- **WHEN** YAML 中 cmd target 或 defaults.cmd 配置的 `maxOutputBytes` 不是合法 size 值
- **THEN** 系统 SHALL 以配置错误退出,提示 maxOutputBytes 格式错误
#### Scenario: cmd 分组未知字段失败
- **WHEN** YAML 中 cmd target 的 `cmd` 分组包含 `shell: true` 等未知字段
- **THEN** 系统 SHALL 以配置错误退出,提示 cmd 分组包含未知字段
#### Scenario: cmd expect exitCode 类型非法
- **WHEN** YAML 中 cmd target 配置 `expect.exitCode` 不是整数数组
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.exitCode 必须为整数数组
#### Scenario: cmd expect exitCode 不限制平台范围
- **WHEN** YAML 中 cmd target 配置 `expect.exitCode` 为有限整数数组
- **THEN** 系统 SHALL 接受该数组,不额外限制为 0-255 等平台相关范围
#### Scenario: cmd expect maxDurationMs 非法
- **WHEN** YAML 中 cmd target 配置 `expect.maxDurationMs` 不是非负有限数字
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.maxDurationMs 格式错误
#### Scenario: stdout 必须为规则数组
- **WHEN** YAML 中 cmd target 配置 `expect.stdout` 但其值不是数组
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.stdout 必须为数组
#### Scenario: stderr 必须为规则数组
- **WHEN** YAML 中 cmd target 配置 `expect.stderr` 但其值不是数组
- **THEN** 系统 SHALL 以配置错误退出,提示 expect.stderr 必须为数组
#### Scenario: stdout text rule 空对象非法
- **WHEN** YAML 中 cmd target 配置 `expect.stdout: [{}]`
- **THEN** 系统 SHALL 以配置错误退出,提示 stdout 规则必须包含至少一个合法 operator
#### Scenario: stderr text rule 未知字段非法
- **WHEN** YAML 中 cmd target 配置 `expect.stderr: [{foo: "bar"}]`
- **THEN** 系统 SHALL 以配置错误退出,提示 stderr 规则包含未知 operator
#### Scenario: stdout match 正则非法
- **WHEN** YAML 中 cmd target 配置 `expect.stdout: [{match: "[invalid"}]`
- **THEN** 系统 SHALL 在启动期配置校验失败,而不是延迟到运行期抛错
#### Scenario: cmd expect 未知字段失败
- **WHEN** YAML 中 cmd target 的 expect 包含 `status: [200]` 或其他非 cmd expect 字段
- **THEN** 系统 SHALL 以配置错误退出,提示 expect 包含未知字段

View File

@@ -1,126 +0,0 @@
## Purpose
定义项目代码质量门禁、格式化检查、快速检查和完整验证命令的行为要求,确保开发者可以通过文档化命令稳定验证源码质量、基础测试和生产构建。
## Requirements
### Requirement: ESLint 代码质量门禁
项目 SHALL 提供 ESLint 代码质量门禁,用于审查 TypeScript、React 前端、脚本和测试代码中的质量问题。ESLint 配置 SHALL 包括 `@eslint/js` recommended 规则、`typescript-eslint` recommended-type-checked 和 stylistic-type-checked 规则、`eslint-plugin-perfectionist` 导入排序规则、`eslint-plugin-import` 导入验证规则,以及精选的单项类型安全和风格规则。
#### Scenario: 运行 lint 检查
- **WHEN** 开发者运行文档化的 lint 命令
- **THEN** 系统 SHALL 使用 ESLint 检查项目源码、脚本和测试代码,并在发现违规时以非零状态退出
#### Scenario: 检查 React Hooks 规则
- **WHEN** 前端 React 代码违反 Hooks 调用规则
- **THEN** lint 命令 MUST 失败并报告对应违规
#### Scenario: 保护前后端边界
- **WHEN** `src/web` 前端代码导入 `src/server` 后端运行时实现
- **THEN** lint 命令 MUST 失败并报告前后端边界违规
#### Scenario: 检测类型安全违规
- **WHEN** 代码中存在浮动的 Promise 未 await、any 类型泄漏到明确类型位置、模板字符串中包含非字符串化对象等类型安全隐患
- **THEN** lint 命令 MUST 失败并报告对应 `@typescript-eslint` 规则违规
#### Scenario: 检测导入路径错误
- **WHEN** 代码中导入路径指向不存在的文件或已废弃的路径
- **THEN** lint 命令 MUST 失败并报告 `import/no-unresolved``import/no-deprecated` 错误
#### Scenario: 排除第三方模板目录
- **WHEN** ESLint 运行检查
- **THEN** 系统 MUST 排除 `.agents/` 等第三方模板目录,不检查其中的代码
#### Scenario: 排除生成产物和锁文件
- **WHEN** ESLint 运行检查
- **THEN** 系统 MUST 排除 `dist/``.build/``node_modules/``openspec/``.opencode/``.claude/``.codex/``*.bun-build``bun.lock``data/` 等非源码目录
### Requirement: Prettier 代码格式门禁
项目 SHALL 通过 `eslint-plugin-prettier` 将 Prettier 格式检查集成为 ESLint 规则,使 `lint` 命令同时覆盖代码质量和格式检查(原独立的 `format:check` 命令不再存在,格式检查统一通过 `lint` 完成)。项目仍保留独立的 `format` 命令(`prettier --write`用于快速格式化。Prettier 配置 SHALL 显式声明 `printWidth``semi``singleQuote``trailingComma``bracketSpacing``arrowParens``endOfLine``tabWidth``useTabs` 全部格式化参数。
#### Scenario: 检查代码格式
- **WHEN** 开发者运行 `bun run lint`
- **THEN** ESLint SHALL 通过 eslint-plugin-prettier 检查受管理文件格式,并在发现未格式化文件时以非零状态退出
#### Scenario: 自动格式化代码
- **WHEN** 开发者运行 `bun run format``eslint --fix`
- **THEN** 系统 SHALL 使用 Prettier 重写受管理文件的格式
#### Scenario: 排除 OpenSpec 文档和生成产物
- **WHEN** Prettier 格式化或格式检查运行(通过 ESLint 或独立 Prettier 命令)
- **THEN** 系统 MUST 排除 `openspec/``dist/``.build/``node_modules/``bun.lock``skills-lock.json``.agents/``data/``*.bun-build``.opencode/``.claude/``.codex/` 和临时构建产物
#### Scenario: 格式化配置一致性
- **WHEN** 不同开发者在不同操作系统上运行格式化(通过 ESLint 或独立 Prettier
- **THEN** 由于所有格式化参数均显式定义,产物 SHALL 完全一致
### Requirement: TypeScript 未使用变量检测
项目 SHALL 启用 TypeScript `noUnusedLocals` 编译选项,将未使用的局部变量检测为编译错误。
#### Scenario: 存在未使用的局部变量
- **WHEN** TypeScript 代码中存在声明但未被引用的局部变量
- **THEN** `tsc --noEmit` MUST 以非零状态退出并报告未使用变量
### Requirement: TypeScript 索引签名属性访问检测
项目 SHALL 启用 TypeScript `noPropertyAccessFromIndexSignature` 编译选项,禁止通过点号访问未显式声明的属性。
#### Scenario: 通过点号访问 Record 动态属性
- **WHEN** 代码通过 `.property` 点号语法访问 `Record<string, T>` 类型或索引签名类型的属性
- **THEN** `tsc --noEmit` MUST 以非零状态退出,强制使用 `["property"]` 括号语法显式访问
### Requirement: ESLint 导入自动排序
项目 SHALL 通过 `eslint-plugin-perfectionist` 对导入语句进行自动排序,确保导入顺序一致性。
#### Scenario: 导入语句无序排列
- **WHEN** 文件中导入语句未按要求排序
- **THEN** `eslint --fix` SHALL 自动重排 import 声明和 named imports 内部顺序
#### Scenario: type import 与 value import 混合
- **WHEN** 文件中同时存在 `import type``import` 语句
- **THEN** perfectionist SHALL 正确识别并分别排序,不将 type 和 value 导入混淆
### Requirement: ESLint 导入路径验证
项目 SHALL 通过 `eslint-plugin-import` 验证导入路径的有效性和一致性。
#### Scenario: 导入不存在的模块路径
- **WHEN** 代码中导入了不存在或路径错误的模块
- **THEN** lint 命令 MUST 失败并报告 `import/no-unresolved` 错误
#### Scenario: 存在重复导入
- **WHEN** 同一个模块在同一文件中被多次导入
- **THEN** `eslint --fix` SHALL 自动合并重复导入为目标模块的单条导入
#### Scenario: 存在循环依赖
- **WHEN** 模块 A 导入模块 B同时模块 B 导入模块 A
- **THEN** lint 命令 MUST 报告 `import/no-cycle` 警告
### Requirement: 快速检查命令
项目 SHALL 提供快速 `check` 命令,用于日常开发期间验证代码质量和基础行为。
#### Scenario: 运行快速检查
- **WHEN** 开发者运行 `bun run check`
- **THEN** 系统 SHALL 依次执行 schema 检查、类型检查、lint含格式和单元/组件测试(`bun test`
#### Scenario: 快速检查失败
- **WHEN** `check` 中任一子检查失败
- **THEN** `check` MUST 以非零状态退出且不静默忽略失败
### Requirement: 分层测试运行命令
项目 SHALL 提供分层的测试运行命令,支持按需执行不同层级的测试。
#### Scenario: 运行全部单元和组件测试
- **WHEN** 开发者运行 `bun test`
- **THEN** 系统 SHALL 执行 `tests/` 目录下所有 `*.test.ts``*.test.tsx` 文件
### Requirement: 完整验证命令
项目 SHALL 提供完整 `verify` 命令,用于提交前或发布前验证当前源码、测试和生产构建。原 executable smoke test 暂时移除,后续通过独立变更重新设计。
#### Scenario: 运行完整验证
- **WHEN** 开发者运行 `bun run verify`
- **THEN** 系统 SHALL 先运行 `check`,再运行生产构建
#### Scenario: 完整验证失败
- **WHEN** `verify` 中任一阶段失败
- **THEN** `verify` MUST 以非零状态退出且不能继续声明验证成功

View File

@@ -1,51 +0,0 @@
## Purpose
定义 Git hooks 自动化质量门禁行为,在 pre-commit 阶段自动运行代码检查和格式化,在 commit-msg 阶段校验提交信息格式。
## Requirements
### Requirement: pre-commit 自动质量检查
项目 SHALL 通过 husky 和 lint-staged 在 git commit 前自动对变更文件运行 ESLint含 Prettier 格式)检查。由于 eslint-plugin-prettier 已集成格式检查lint-staged SHALL 仅运行 `eslint --fix` 即可同时修复代码质量和格式问题。
#### Scenario: 变更 TypeScript 文件后提交
- **WHEN** 开发者 stage 了 `.ts``.tsx` 文件并执行 `git commit`
- **THEN** lint-staged SHALL 自动对变更文件运行 `eslint --fix`(含格式修复),修复后继续提交
#### Scenario: 变更 Markdown 或 JSON 文件后提交
- **WHEN** 开发者 stage 了 `.md``.json``.yaml``.yml` 文件并执行 `git commit`
- **THEN** lint-staged SHALL 自动对变更文件运行 `prettier --write`
#### Scenario: lint 检查失败阻止提交
- **WHEN** 变更文件存在无法自动修复的 ESLint 错误(含格式错误)
- **THEN** pre-commit hook MUST 以非零状态退出,阻止提交
#### Scenario: 无变更文件提交
- **WHEN** 开发者执行 `git commit` 但无 stage 文件
- **THEN** lint-staged SHALL 正常通过,不阻止提交
### Requirement: 提交信息格式校验
项目 SHALL 通过 commitlint 在 git commit 时校验提交信息必须符合 "类型: 简短描述" 格式,类型限定为 feat/fix/refactor/docs/style/test/chore。
#### Scenario: 有效的中文提交信息
- **WHEN** 开发者提交信息为 "feat: 新增导入排序功能"
- **THEN** commit-msg hook SHALL 通过校验
#### Scenario: 缺少类型前缀的提交信息
- **WHEN** 开发者提交信息为 "新增导入排序功能"(无 "feat:" 前缀)
- **THEN** commit-msg hook MUST 以非零状态退出,提示正确格式
#### Scenario: 无效的提交类型
- **WHEN** 开发者提交信息使用不在允许列表中的类型(如 "update: 修改配置"
- **THEN** commit-msg hook MUST 以非零状态退出,提示可用类型
### Requirement: husky 初始化自动化
项目 SHALL 通过 `prepare` 生命周期脚本在 `bun install` 时自动初始化 husky。
#### Scenario: 首次安装依赖
- **WHEN** 开发者运行 `bun install`
- **THEN** husky SHALL 自动初始化,安装 pre-commit 和 commit-msg hooks
#### Scenario: 已有 husky 配置时安装
- **WHEN** 开发者运行 `bun install` 且 husky 已初始化
- **THEN** husky 初始化 SHALL 跳过,不覆盖已有配置

View File

@@ -1,72 +0,0 @@
## Purpose
定义前端组件测试基础设施和覆盖要求,确保所有 React 组件的渲染、交互和状态流转行为经过验证。
## Requirements
### Requirement: jsdom 测试环境配置
项目 SHALL 通过 `tests/setup.ts` preload 脚本为组件测试提供 jsdom DOM 环境,包含 TDesign 组件所需的浏览器 API polyfill。
#### Scenario: 组件测试可以渲染 React 组件
- **WHEN** 组件测试文件使用 `@testing-library/react``render` 函数渲染组件
- **THEN** 组件 SHALL 在 jsdom 环境中正常渲染,可通过 `screen` 查询 DOM 元素
#### Scenario: TDesign 组件依赖的浏览器 API 可用
- **WHEN** 组件测试渲染使用了 TDesign 组件Table、Drawer、Skeleton 等)的业务组件
- **THEN** jsdom 环境 SHALL 提供 `ResizeObserver``IntersectionObserver``window.matchMedia` 等 polyfill不因缺失 API 而抛错
#### Scenario: recharts 图表组件被 mock
- **WHEN** 组件测试渲染包含 recharts 图表的组件
- **THEN** recharts 模块 SHALL 被 mock 为简单占位元素,不依赖 SVG 渲染能力
### Requirement: 组件测试使用 @testing-library/react
项目 SHALL 使用 `@testing-library/react` 作为组件测试工具,遵循"测试用户行为而非实现细节"的原则。
#### Scenario: 通过用户可见内容查询元素
- **WHEN** 测试需要查找页面元素
- **THEN** 测试 SHALL 优先使用 `getByText``getByRole``getByLabelText` 等语义化查询,而非 CSS 选择器或 testId
#### Scenario: 通过用户交互触发行为
- **WHEN** 测试需要模拟用户操作
- **THEN** 测试 SHALL 使用 `fireEvent``userEvent` 模拟点击、输入等操作,而非直接调用组件内部方法
### Requirement: 所有前端组件 SHALL 有组件测试覆盖
项目 SHALL 为 `src/web/components/` 下的每个组件和 `src/web/app.tsx` 提供对应的组件测试文件。
#### Scenario: 纯展示组件测试
- **WHEN** 组件为纯展示组件(如 StatusDot、SummaryCards
- **THEN** 测试 SHALL 验证给定 props 时渲染正确的文本和结构,以及 null/空数据时的条件渲染
#### Scenario: 交互组件测试
- **WHEN** 组件包含用户交互(如 TargetDetailDrawer 的 Tab 切换、RefreshCountdown 的按钮点击)
- **THEN** 测试 SHALL 验证交互触发正确的回调函数调用和参数传递
#### Scenario: 条件渲染测试
- **WHEN** 组件根据 loading/error/empty 状态展示不同内容(如 OverviewTab
- **THEN** 测试 SHALL 覆盖所有条件分支loading skeleton、正常数据渲染、空数据占位
#### Scenario: 数据驱动组件测试
- **WHEN** 组件接收列表数据渲染(如 TargetBoard 的分组、HistoryTab 的表格)
- **THEN** 测试 SHALL 验证数据正确映射到 UI 元素,包括空列表和多项数据的情况
### Requirement: 组件测试的 Mock 边界
组件测试 SHALL 只 mock 系统边界(网络请求),不 mock 内部实现。
#### Scenario: Mock fetch 而非 React Query
- **WHEN** 组件通过 `@tanstack/react-query` 发起数据请求
- **THEN** 测试 SHALL mock `globalThis.fetch` 返回预设响应,使用真实的 `QueryClientProvider` 包裹组件
#### Scenario: 不 mock TDesign 组件
- **WHEN** 业务组件使用 TDesign 组件
- **THEN** 测试 SHALL 真实渲染 TDesign 组件,验证 props 传递和集成行为的正确性
### Requirement: 组件测试文件组织
组件测试文件 SHALL 位于 `tests/web/components/` 目录下,文件名与组件名对应。
#### Scenario: 测试文件命名
- **WHEN** 为 `src/web/components/TargetBoard.tsx` 编写组件测试
- **THEN** 测试文件 SHALL 位于 `tests/web/components/TargetBoard.test.tsx`
#### Scenario: App 组件测试位置
- **WHEN** 为 `src/web/app.tsx` 编写组件测试
- **THEN** 测试文件 SHALL 位于 `tests/web/components/App.test.tsx`

View File

@@ -1,117 +0,0 @@
## Purpose
定义 styles.css 中集中管理的前端样式工具类和 CSS 自定义属性,供 TDesign 组件之外的自定义组件StatusDot、StatusBar 等)引用。
## Requirements
### Requirement: 状态色 CSS 类
styles.css SHALL 定义状态指示相关的 CSS 类,颜色使用 TDesign tokens。
#### Scenario: StatusDot 颜色类
- **WHEN** StatusDot 组件渲染
- **THEN** 组件 SHALL 使用 `.status-dot` 基础类 + `.status-dot--up`background: `--td-success-color`)或 `.status-dot--down`background: `--td-error-color`)修饰类,不使用内联 style
#### Scenario: StatusDot 发光阴影
- **WHEN** StatusDot 组件渲染
- **THEN** `.status-dot--up` SHALL 定义 `box-shadow` 使用 `--td-success-color``.status-dot--down` SHALL 定义 `box-shadow` 使用 `--td-error-color`
#### Scenario: StatusBar 色块类
- **WHEN** StatusBar 组件渲染色块
- **THEN** 组件 SHALL 使用 `.status-bar-block` 基础类 + `.status-bar-block--up`background: `--td-success-color`)、`.status-bar-block--down`background: `--td-error-color`)或 `.status-bar-block--empty`background: `--td-bg-color-component-disabled`)修饰类,不使用内联 style
### Requirement: 可用率色阶 CSS 变量
styles.css SHALL 定义 10 级可用率色阶 CSS 自定义属性,使用项目自定义色值。
#### Scenario: 色阶变量定义
- **WHEN** 可用率进度条渲染
- **THEN** 色阶 SHALL 通过 CSS 自定义属性 `--avail-0``--avail-9` 定义,值为项目自定义色值(`#d54941``#3dba60`
#### Scenario: 色阶渐变方向
- **WHEN** 色阶变量被引用
- **THEN** 色阶 SHALL 从红色0-30%经橙色30-60%过渡到绿色60-100%
### Requirement: 辅助工具类
styles.css SHALL 定义前端组件复用的工具类,包含页面布局相关类。
#### Scenario: 文本禁用色类
- **WHEN** 延迟列无数据需要显示占位符
- **THEN** 组件 SHALL 使用 `.text-disabled`color: `--td-text-color-disabled`
#### Scenario: 等宽数字类
- **WHEN** 数值需要等宽显示
- **THEN** 组件 SHALL 使用 `.tabular-nums`font-variant-numeric: tabular-nums
#### Scenario: 延迟色值类
- **WHEN** 延迟数值渲染
- **THEN** 组件 SHALL 使用 `.latency-ok``.latency-warn``.latency-error`
#### Scenario: 延迟值容器类
- **WHEN** 延迟数值需要固定宽度对齐
- **THEN** 组件 SHALL 使用 `.latency-value`display: inline-block; min-width: 7ch; white-space: nowrap
#### Scenario: 全宽布局类
- **WHEN** 组件需要占满父容器宽度
- **THEN** 组件 SHALL 使用 `.full-width`width: 100%
#### Scenario: 可点击表格类
- **WHEN** PrimaryTable 行支持点击交互
- **THEN** 表格 SHALL 使用 `.clickable-table`cursor: pointer
#### Scenario: Tab 面板内边距类
- **WHEN** Drawer 内 Tabs 面板需要内边距
- **THEN** TabPanel SHALL 使用 `className="tab-panel-padded"` prop 传入类名
#### Scenario: 内容区居中类
- **WHEN** Dashboard 内容区需要居中且限制最大宽度
- **THEN** 内容区 SHALL 使用 `.dashboard-content`max-width: 1400px; margin: 0 auto; padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl)
#### Scenario: 页面背景色
- **WHEN** Dashboard 页面渲染
- **THEN** `.dashboard` 类 SHALL 设置 background: var(--td-bg-color-page)min-height: 100vhwidth: 100%
#### Scenario: 品牌标识类
- **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: 倒计时文本类
- **WHEN** 倒计时文本或刷新按钮渲染
- **THEN** 容器 SHALL 使用 `.dashboard-countdown`display: inline-flex; align-items: center; font-variant-numeric: tabular-nums; min-width: 5ch确保数字等宽且格式切换不抖动
#### Scenario: SummaryCard 居中类
- **WHEN** SummaryCards 内 Statistic 需要居中
- **THEN** Statistic 所在的 Col SHALL 使用 `.summary-stat-col`text-align: center
#### Scenario: SummaryCards 行间距类
- **WHEN** SummaryCards 容器需要与下方内容保持间距
- **THEN** 容器 SHALL 使用 `.summary-cards-row`margin-bottom: var(--td-comp-margin-xl)
#### Scenario: Drawer 时间控件单行类
- **WHEN** Drawer 时间选择器需要单行布局
- **THEN** 控件容器 SHALL 使用 `.drawer-time-controls`display: flex; align-items: center; gap: var(--td-comp-margin-m); width: 100%),日期选择器 SHALL 使用 `.drawer-date-range`flex: 1; min-width: 360px
#### Scenario: Drawer 时间控件响应式
- **WHEN** 视口宽度 ≤ 768px
- **THEN** `.drawer-time-controls` SHALL 启用 flex-wrap`.drawer-date-range` min-width 改为 100%
#### 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
### Requirement: 异常行背景类
styles.css SHALL 定义 DOWN 行的背景色和左侧竖线,使用安全选择器且不使用 `!important`
#### Scenario: DOWN 行背景色
- **WHEN** 表格行标记为 DOWN 状态
- **THEN** 行 SHALL 通过 `.t-table tr.row-down` 选择器获得浅红色背景
#### Scenario: DOWN 行左侧竖线
- **WHEN** 表格行标记为 DOWN 状态
- **THEN** 行 SHALL 通过 `.t-table tr.row-down` 选择器获得 `border-left: 3px solid var(--td-error-color)`
#### Scenario: DOWN 行 hover 状态
- **WHEN** 鼠标悬停在 DOWN 行上
- **THEN** 行背景 SHALL 通过 `.t-table--hoverable tbody tr.row-down:hover` 选择器显示 hover 状态色

View File

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

View File

@@ -1,43 +0,0 @@
## Purpose
定义历史拨测数据的自动清理机制:可配置的保留时长和定时清理调度。
## Requirements
### Requirement: 数据保留配置
系统 SHALL 支持通过 `runtime.retention` 配置项指定历史数据保留时长,格式为持续时间字符串(`<数字><单位>`,单位支持 `d`/`h`/`m`)。
#### Scenario: 配置 7 天保留
- **WHEN** 配置文件中 `runtime.retention` 设置为 `"7d"`
- **THEN** 系统 SHALL 保留最近 7 天的检查结果,清理更早的数据
#### Scenario: 配置小时级保留
- **WHEN** 配置文件中 `runtime.retention` 设置为 `"24h"`
- **THEN** 系统 SHALL 保留最近 24 小时的检查结果
#### Scenario: 未配置 retention
- **WHEN** 配置文件中未指定 `runtime.retention`
- **THEN** 系统 SHALL 使用默认值 `"7d"`
#### Scenario: 无效 retention 格式
- **WHEN** 配置文件中 `runtime.retention` 格式不合法(如 `"abc"``"7x"`
- **THEN** 系统 SHALL 在配置校验阶段报错,拒绝启动
### Requirement: 定时清理调度
系统 SHALL 以固定间隔1 小时)定期执行数据清理,删除超过保留时长的历史检查结果。
#### Scenario: 引擎启动后首次清理
- **WHEN** ProbeEngine 启动
- **THEN** 系统 SHALL 立即执行一次清理,然后每隔 1 小时再次执行
#### Scenario: 清理执行
- **WHEN** 清理定时器触发
- **THEN** 系统 SHALL 删除 `check_results` 表中 `timestamp` 早于 `now - retentionMs` 的所有记录
#### Scenario: 引擎停止时清除定时器
- **WHEN** ProbeEngine.stop() 被调用
- **THEN** 系统 SHALL 清除清理定时器,不再执行后续清理
#### Scenario: retention 为 0 时不清理
- **WHEN** 配置的 retention 解析为 0 毫秒
- **THEN** 系统 SHALL 不注册清理定时器,数据永久保留

View File

@@ -1,25 +0,0 @@
## Purpose
定义 ESLint 与 Prettier 集成方案,通过 eslint-plugin-prettier 将 Prettier 格式检查统一纳入 ESLint 工作流,减少独立工具调用,提升开发体验。
## Requirements
### Requirement: ESLint 集成 Prettier 格式检查
项目 SHALL 通过 `eslint-plugin-prettier` 将 Prettier 格式规则集成为 ESLint 规则,使单次 `eslint .` 运行同时报告代码质量问题和格式问题。ESLint 配置 SHALL 注册 `plugin:prettier/recommended` 规则集,该规则集自动加载 Prettier 配置并禁用与 Prettier 冲突的 ESLint 规则。
#### Scenario: lint 运行同时检查格式
- **WHEN** 开发者运行 `bun run lint`
- **THEN** ESLint SHALL 同时报告代码质量违规和 Prettier 格式违规
#### Scenario: lint --fix 自动格式化
- **WHEN** 开发者运行 `eslint --fix` 或 lint-staged 自动触发 `eslint --fix`
- **THEN** ESLint SHALL 对可自动修复的代码质量问题和格式问题一并修正,包括调用 Prettier 重写文件格式
#### Scenario: Prettier 配置被正确读取
- **WHEN** ESLint 通过 eslint-plugin-prettier 检查代码格式
- **THEN** 检查结果 SHALL 与独立运行 `prettier --check` 的结果完全一致
#### Scenario: 不因集成降低 lint 性能
- **WHEN** 开发者运行 `bun run lint`
- **THEN** 运行时间 SHOULD 不超过原 `eslint .` + `prettier --check` 总耗时的 120%

View File

@@ -1,265 +0,0 @@
## Purpose
定义 HTTP 拨测中响应体校验方法集contains/regex/json/css/xpath、操作符系统和响应头校验的行为规范。
## Requirements
### Requirement: 响应体多种校验方法
系统 SHALL 支持对 HTTP 响应体进行五种可组合的校验方法contains子串、regex正则、jsonJSONPath、cssCSS 选择器、xpathXPath。这些方法 MUST 配置在 `expect.body` 有序数组中。
#### Scenario: contains 子串匹配
- **WHEN** HTTP target 配置 `expect.body: [{contains: "healthy"}]`,且响应体包含 `"healthy"`
- **THEN** 系统 SHALL 判定该 body 规则通过
#### Scenario: contains 不匹配
- **WHEN** HTTP target 配置 `expect.body: [{contains: "healthy"}]`,且响应体不包含该文本
- **THEN** 系统 SHALL 判定 matched 为 false并记录该规则的 failure.path
#### Scenario: regex 正则匹配
- **WHEN** HTTP target 配置 `expect.body: [{regex: '"status"\\s*:\\s*"ok"'}]`,且响应体匹配该正则
- **THEN** 系统 SHALL 判定该 body 规则通过
#### Scenario: regex 不匹配
- **WHEN** HTTP target 配置 regex body 规则,且响应体不匹配该正则
- **THEN** 系统 SHALL 判定 matched 为 false并记录该规则的 failure.path
#### Scenario: json JSONPath 等值匹配
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.status", equals: "ok"}}]`,且响应 JSON 中 `$.status` 值为 `"ok"`
- **THEN** 系统 SHALL 判定该 body 规则通过
#### Scenario: json JSONPath 值不匹配
- **WHEN** HTTP target 配置 json body 规则,且提取值不符合期望
- **THEN** 系统 SHALL 判定 matched 为 false并记录包含 JSONPath 的 failure.path
#### Scenario: json 解析失败
- **WHEN** HTTP target 配置了 json body 规则但响应体不是合法 JSON
- **THEN** 系统 SHALL 判定 matched 为 false
#### Scenario: css 选择器匹配
- **WHEN** HTTP target 配置 `expect.body: [{css: {selector: "div#health", equals: "OK"}}]`,且 HTML 中存在 `div#health` 元素文本为 `"OK"`
- **THEN** 系统 SHALL 判定该 body 规则通过
#### Scenario: css 选择器匹配属性值
- **WHEN** HTTP target 配置 css 规则带 `attr: "content"` 用于提取属性,且属性值匹配期望
- **THEN** 系统 SHALL 判定该 body 规则通过
#### Scenario: css 选择器无匹配元素
- **WHEN** HTTP target 配置了 css 选择器但 HTML 中无匹配元素
- **THEN** 系统 SHALL 判定 matched 为 false
#### Scenario: xpath 表达式匹配
- **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/root/status/text()", equals: "ok"}}]`,且 XML 中 `/root/status` 节点文本为 `"ok"`
- **THEN** 系统 SHALL 判定该 body 规则通过
#### Scenario: xpath 表达式无匹配节点
- **WHEN** HTTP target 配置了 xpath 表达式但 XML 中无匹配节点
- **THEN** 系统 SHALL 判定 matched 为 false
### Requirement: 多种 body 校验方法 AND 组合
系统 SHALL 支持在 `expect.body` 数组中同时配置多种 body 校验方法,所有方法均通过时 matched 方为 true。
#### Scenario: 多种方法全部通过
- **WHEN** HTTP target 的 `expect.body` 数组依次配置 contains、json、regex且全部通过
- **THEN** 系统 SHALL 判定 matched 为 true
#### Scenario: 多种方法任一失败
- **WHEN** HTTP target 的 `expect.body` 数组第一条 contains 不通过,后续还有 json 规则
- **THEN** 系统 SHALL 判定 matched 为 false且不再检查后续 json 规则
### Requirement: 操作符系统
系统 SHALL 支持对提取值和文本值使用以下操作符进行比较equals默认等值、contains子串包含、match正则匹配、empty空值判断、exists存在性判断、gte/lte/gt/lt数值比较
#### Scenario: 标量值隐式 equals
- **WHEN** 配置的期望值为标量(字符串/数字/布尔/null`equals: "ok"`
- **THEN** 系统 SHALL 使用 equals 操作符,对实际值做严格相等比较
#### Scenario: 显式 contains 操作符
- **WHEN** 配置 `{contains: "success"}`,且实际值包含 `"success"`
- **THEN** 系统 SHALL 判定该规则通过
#### Scenario: 显式 match 操作符
- **WHEN** 配置 `{match: '\\d+\\.\\d+\\.\\d+'}`,且实际值匹配该正则
- **THEN** 系统 SHALL 判定该规则通过
#### Scenario: empty 操作符判断为空
- **WHEN** 配置 `{empty: true}`,且实际值为空数组 `[]`
- **THEN** 系统 SHALL 判定该规则通过
#### Scenario: empty 操作符判断非空
- **WHEN** 配置 `{empty: false}`,且实际值为 `[1, 2]`
- **THEN** 系统 SHALL 判定该规则通过
#### Scenario: exists 操作符判断存在
- **WHEN** 配置 `{exists: false}`,且实际值不存在
- **THEN** 系统 SHALL 判定该规则通过
#### Scenario: gte 数值比较
- **WHEN** 配置 `{gte: 10}`,且实际值为 `15`(数字)
- **THEN** 系统 SHALL 判定该规则通过
#### Scenario: gt/lt 数值比较
- **WHEN** 配置 `{gt: 0, lt: 1000}`,且实际值为 `500`
- **THEN** 系统 SHALL 对同一字段进行多操作符复合比较,全部通过则该规则通过
### Requirement: 响应头校验
系统 SHALL 支持通过 `expect.headers` 配置对 HTTP 响应头进行键值规则校验header 名称匹配 MUST 不区分大小写。
#### Scenario: 响应头匹配
- **WHEN** HTTP target 配置 `expect.headers: {"Content-Type": {contains: "application/json"}}`,且响应包含该 header 且值匹配
- **THEN** 系统 SHALL 判定 headers 阶段通过
#### Scenario: 响应头不匹配
- **WHEN** HTTP target 配置 `expect.headers: {"Content-Type": {equals: "application/json"}}`,且响应 header 值为 `"text/html"`
- **THEN** 系统 SHALL 判定 matched 为 false
#### Scenario: 响应头缺失
- **WHEN** HTTP target 配置了某个 header 但响应中不存在该 header
- **THEN** 系统 SHALL 判定 matched 为 false
### Requirement: 结构化 expect 失败信息
系统 SHALL 在任一 expect 规则失败时生成结构化 failure用于标识失败阶段、规则路径、期望值、实际值和可读错误信息。actual 值 SHALL 在构造时截断至不超过 200 字符超出部分以省略标记和总字符数替代。expected 值不截断。
#### Scenario: body 规则失败信息
- **WHEN** HTTP target 的 `expect.body[1].json` 规则失败
- **THEN** failure SHALL 包含 kind=`mismatch`、phase=`body`、path 指向 `expect.body[1]`,并包含 message
#### Scenario: actual 值截断
- **WHEN** 失败规则的实际值为字符串且长度超过 200 字符
- **THEN** failure.actual SHALL 为前 200 字符加 `…(共 N 字符)` 后缀,其中 N 为原始总字符数
#### Scenario: actual 值未超限
- **WHEN** 失败规则的实际值为字符串且长度不超过 200 字符
- **THEN** failure.actual SHALL 保留完整原始值,不做截断
#### Scenario: actual 值为对象或数组
- **WHEN** 失败规则的实际值为对象或数组,且 JSON 序列化后长度超过 200 字符
- **THEN** failure.actual SHALL 为序列化后前 200 字符加 `…(共 N 字符)` 后缀
#### Scenario: actual 值为标量
- **WHEN** 失败规则的实际值为 number、boolean、null 或 undefined
- **THEN** failure.actual SHALL 保留原始值,不做截断
### Requirement: 状态码范围匹配
系统 SHALL 支持在 `expect.status` 数组中使用范围模式字符串(`"1xx"``"2xx"``"3xx"``"4xx"``"5xx"`),与精确数字混合使用。范围模式 SHALL 匹配对应百位段内的所有状态码。其他范围模式 SHALL 在启动期配置校验失败。
#### Scenario: 1xx 范围匹配
- **WHEN** HTTP target 配置 `expect.status: ["1xx"]`,且响应状态码为 101
- **THEN** 系统 SHALL 判定状态码匹配
#### Scenario: 2xx 范围匹配
- **WHEN** HTTP target 配置 `expect.status: ["2xx"]`,且响应状态码为 200
- **THEN** 系统 SHALL 判定状态码匹配
#### Scenario: 2xx 范围匹配 204
- **WHEN** HTTP target 配置 `expect.status: ["2xx"]`,且响应状态码为 204
- **THEN** 系统 SHALL 判定状态码匹配
#### Scenario: 2xx 范围不匹配 301
- **WHEN** HTTP target 配置 `expect.status: ["2xx"]`,且响应状态码为 301
- **THEN** 系统 SHALL 判定状态码不匹配
#### Scenario: 混合精确值与范围模式
- **WHEN** HTTP target 配置 `expect.status: ["2xx", 301]`,且响应状态码为 301
- **THEN** 系统 SHALL 判定状态码匹配(精确值 301 匹配)
#### Scenario: 混合精确值与范围模式范围命中
- **WHEN** HTTP target 配置 `expect.status: ["2xx", 301]`,且响应状态码为 204
- **THEN** 系统 SHALL 判定状态码匹配2xx 范围命中)
#### Scenario: 5xx 范围匹配
- **WHEN** HTTP target 配置 `expect.status: ["5xx"]`,且响应状态码为 503
- **THEN** 系统 SHALL 判定状态码匹配
#### Scenario: 非 HTTP 范围模式启动失败
- **WHEN** HTTP target 配置 `expect.status: ["6xx"]`
- **THEN** 系统 SHALL 在启动期配置校验失败
### Requirement: HTTP expect 规则启动期校验
系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式、未知字段和可编译表达式。HTTP expect、body rule、json/css/xpath rule 和 operator 对象中的未知字段 SHALL 导致启动期配置失败。每个 body rule 对象 MUST 恰好包含 contains、regex、json、css、xpath 中的一种规则类型。纯 operator 对象 MUST 至少包含一个已知 operatorbody 提取规则可以不配置 operator并以路径、元素或节点存在作为通过语义。`equals` operator SHALL 支持任意 JSON value包括数组和对象。系统 SHALL 在启动期对 regex body 规则和 match operator 的正则表达式进行 ReDoS 安全检测,含有嵌套量词等危险模式的正则 SHALL 导致启动期配置失败。
#### Scenario: body rule 使用 regex 字段
- **WHEN** HTTP target 配置 `expect.body: [{regex: "ok|healthy"}]` 且 regex 可编译且无 ReDoS 风险
- **THEN** 系统 SHALL 接受该配置,并在运行期按 regex body 规则匹配响应体
#### Scenario: body rule 不支持 match 字段
- **WHEN** HTTP target 配置 `expect.body: [{match: "ok"}]` 且该规则没有 contains、regex、json、css、xpath 任一支持字段
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: body rule 忽略未知字段 → body rule 未知字段启动失败
- **WHEN** HTTP target 配置 `expect.body: [{contains: "ok", note: "ignored"}]`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示 `note` 是未知字段
#### Scenario: body rule 多支持字段非法
- **WHEN** HTTP target 的同一条 body rule 同时配置 contains 和 regex
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: operator match 正则非法
- **WHEN** HTTP target 的 expect.headers、json、css 或 xpath operator 配置了不可编译的 match 正则
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: operator 数值比较类型非法
- **WHEN** HTTP target 的 expect operator 配置 gt、gte、lt 或 lte且对应值不是有限数字
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: operator 布尔类型非法
- **WHEN** HTTP target 的 expect operator 配置 empty 或 exists且对应值不是布尔值
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: JSONPath 子集非法
- **WHEN** HTTP target 的 json body rule path 不符合系统支持的 JSONPath 子集
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: operator 未知字段非法
- **WHEN** HTTP target 的 expect operator 配置了 `foo: "bar"` 等未知 operator 字段
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: equals 支持对象
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.payload", equals: {status: "ok"}}}]`
- **THEN** 系统 SHALL 接受该配置,并在运行期使用深度相等比较提取值和对象期望
#### Scenario: equals 支持数组
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.items", equals: ["a", "b"]}}]`
- **THEN** 系统 SHALL 接受该配置,并在运行期使用深度相等比较提取值和数组期望
#### Scenario: 纯 operator 对象不能为空
- **WHEN** HTTP target 的 `expect.headers` 中某个 header 期望配置为空对象 `{}`
- **THEN** 系统 SHALL 在启动期配置校验失败,要求显式配置至少一个 operator
#### Scenario: json rule 允许存在性语义
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.status"}}]`
- **THEN** 系统 SHALL 接受该配置,并在运行期以 JSONPath 值存在作为通过语义
#### Scenario: css rule 未知字段非法
- **WHEN** HTTP target 配置 `expect.body: [{css: {selector: "h1", unknown: true}}]`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段
#### Scenario: xpath rule 未知字段非法
- **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/html/body", unknown: true}}]`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段
#### Scenario: regex body 规则含嵌套量词启动失败
- **WHEN** HTTP target 配置 `expect.body: [{regex: "(a+)+$"}]`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险
#### Scenario: match operator 含嵌套量词启动失败
- **WHEN** HTTP target 的 expect operator 配置 `{match: "(\\d+)*x"}`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险
#### Scenario: 安全正则通过校验
- **WHEN** HTTP target 配置 `expect.body: [{regex: "\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}"}]`
- **THEN** 系统 SHALL 接受该配置(无嵌套量词,无 ReDoS 风险)
### Requirement: HTTP body 运行期失败结构化
系统 SHALL 将 HTTP body 运行期失败记录为结构化 CheckFailure并保留与具体规则相关的 phase 和 path。响应内容不符合配置 SHALL 记录为 mismatch响应内容无法按配置解析或解码 SHALL 记录为 error。
#### Scenario: JSON 响应不是合法 JSON
- **WHEN** HTTP target 配置 json body rule但响应体不是合法 JSON
- **THEN** 系统 SHALL 记录 `failure.kind="error"``failure.phase="body"`,且 failure.path SHALL 指向对应 json 规则
#### Scenario: CSS selector 无匹配元素
- **WHEN** HTTP target 配置 css body rule但响应 HTML 中无匹配元素
- **THEN** 系统 SHALL 记录 `failure.kind="mismatch"``failure.phase="body"`,且 failure.path SHALL 指向对应 css 规则
#### Scenario: XPath 无匹配节点
- **WHEN** HTTP target 配置 xpath body rule但响应 XML/HTML 中无匹配节点
- **THEN** 系统 SHALL 记录 `failure.kind="mismatch"``failure.phase="body"`,且 failure.path SHALL 指向对应 xpath 规则

View File

@@ -1,71 +0,0 @@
## Purpose
定义基于 Vite dev server + Bun API server 的前端开发工作流、开发期 API 访问和共享契约的行为要求。
## Requirements
### Requirement: Vite React 开发服务器
系统 SHALL 提供基于 Vite dev server 的前端开发工作流,支持热模块替换和 React Fast Refresh。
#### Scenario: 启动前端开发服务器
- **WHEN** 开发者启动开发命令
- **THEN** 前端 SHALL 由 Vite dev server 提供服务,支持 HMR 和 React Fast Refresh监听 :5173 端口
#### Scenario: 构建前端静态资源
- **WHEN** 开发者运行前端生产构建命令
- **THEN** 系统 SHALL 通过 Vite buildRolldown产出优化的前端静态资源到 `dist/web/`
### Requirement: 前端开发期 API 代理
前端开发服务器 SHALL 通过 Vite proxy 配置将 API 请求转发到后端 server。
#### Scenario: 前端开发期调用拨测 API
- **WHEN** 浏览器从 Vite dev server 请求 `/api/*` 路径
- **THEN** Vite SHALL 通过 proxy 将请求转发到 Bun API server默认 :3000
#### Scenario: 前端开发期访问健康检查
- **WHEN** 浏览器从 Vite dev server 请求 `/health`
- **THEN** Vite SHALL 通过 proxy 将请求转发到 Bun API server
#### Scenario: 开发期访问非 API 前端路由
- **WHEN** 浏览器从 Vite dev server 请求非 API 前端路由
- **THEN** Vite SHALL 将该请求作为前端应用流量处理SPA fallback 返回 HTML
### Requirement: 开发期双进程运行
项目 SHALL 在开发命令中同时启动 Vite dev server 和 Bun API server 两个进程。
#### Scenario: 使用默认开发端口
- **WHEN** 开发者运行开发命令
- **THEN** Vite dev server SHALL 监听 :5173Bun API server SHALL 监听配置文件指定的端口(默认 :3000
#### Scenario: 开发者访问前端
- **WHEN** 开发者打开浏览器
- **THEN** 开发者 SHALL 访问 Vite dev server 地址(:5173获取前端页面
### Requirement: 前端使用相对 API 路径
除非有文档化的部署配置覆盖该行为,前端代码 MUST 通过相对 `/api/*` URL 调用后端 API。
#### Scenario: 前端获取后端数据
- **WHEN** 前端代码调用后端 API
- **THEN** 请求 URL 默认 MUST 使用相对 `/api/*` 路径
#### Scenario: 运行环境变化
- **WHEN** host 或 port 在开发环境和生产环境之间变化
- **THEN** 前端 API 调用 SHALL 无需修改源码即可继续工作(开发期通过 Vite proxy生产期通过同源请求
### Requirement: 集成开发命令
项目 SHALL 提供一个文档化命令,用于在开发期间同时运行 Vite dev server 和 Bun API server。
#### Scenario: 启动全栈开发
- **WHEN** 开发者运行文档化的全栈开发命令
- **THEN** 系统 SHALL 同时启动 Vite dev server 和 Bun API server任一进程异常退出时终止另一个
### Requirement: 共享 TypeScript 契约
项目 SHALL 为前端和后端共同使用的请求与响应类型提供共享 TypeScript 边界。
#### Scenario: 定义 API 响应结构
- **WHEN** 前端和后端都需要某个 API 响应类型
- **THEN** 该类型 SHALL 定义在 shared 模块中,而不是在两端重复定义
#### Scenario: 前端导入共享类型
- **WHEN** 前端代码导入共享 API 类型
- **THEN** 该导入 SHALL 不要求将后端运行时实现打包进前端

View File

@@ -1,31 +0,0 @@
## Purpose
定义前端全局错误边界:捕获渲染错误防止白屏,展示友好的错误兜底 UI。
## Requirements
### Requirement: 全局渲染错误捕获
前端应用 SHALL 在最外层包裹 ErrorBoundary 组件,捕获所有子组件树的渲染错误,防止白屏。
#### Scenario: 子组件渲染抛出异常
- **WHEN** 任意子组件在渲染过程中抛出 JavaScript 异常
- **THEN** ErrorBoundary SHALL 捕获该异常,展示错误兜底 UI而非白屏
#### Scenario: 错误兜底 UI 内容
- **WHEN** ErrorBoundary 捕获到渲染错误
- **THEN** 系统 SHALL 使用 TDesign Result 组件type="500")展示错误提示,并提供"刷新页面"按钮
#### Scenario: 刷新页面恢复
- **WHEN** 用户点击错误兜底 UI 中的"刷新页面"按钮
- **THEN** 系统 SHALL 调用 `window.location.reload()` 重新加载页面
#### Scenario: 错误信息记录
- **WHEN** ErrorBoundary 捕获到渲染错误
- **THEN** 系统 SHALL 通过 `console.error` 输出错误信息和组件堆栈
### Requirement: ErrorBoundary 包裹位置
ErrorBoundary SHALL 包裹在 QueryClientProvider 外层,确保 React Query 相关的渲染错误也能被捕获。
#### Scenario: 包裹层级
- **WHEN** 应用渲染树构建
- **THEN** 层级 SHALL 为 StrictMode > ErrorBoundary > QueryClientProvider > App

View File

@@ -1,115 +0,0 @@
## Purpose
定义基于 Vite + Bun 的全栈应用运行时,包括 HTTP server、API 命名空间、健康检查、生产静态资源服务和 SPA fallback 行为。
## Requirements
### Requirement: Bun HTTP 运行时
系统 SHALL 运行一个 Bun HTTP server使用 `routes` 对象声明式注册 HTML 页面路由和 API 端点,由单个进程提供后端 API、健康检查和前端服务。
#### Scenario: 启动运行时服务器
- **WHEN** server 进程成功启动
- **THEN** 它 SHALL 监听配置文件中指定的 host 和 port通过 routes 对象注册所有路由,并记录实际 server URL
#### Scenario: 通过 YAML 配置提供运行时参数
- **WHEN** 通过 YAML 配置文件提供 host、port、数据目录等参数
- **THEN** server SHALL 使用该值,且不需要重新构建
#### Scenario: CLI 只接受配置文件路径
- **WHEN** 用户通过命令行启动程序
- **THEN** 系统 SHALL 只接受一个命令行参数作为 YAML 配置文件路径
#### Scenario: 提供拨测相关 API
- **WHEN** server 启动完成
- **THEN** 系统 SHALL 通过 routes 对象提供 `/api/summary``/api/targets``/api/targets/:id/history``/api/targets/:id/trend` 端点
### Requirement: HTTP method 语义
系统 SHALL 只为运行时端点声明实际支持的 GET handler不支持的 API method SHALL 按未匹配 API 路由处理,不再保留自定义 405 和 Allow header 语义。
#### Scenario: GET 请求访问运行时端点
- **WHEN** 客户端使用 `GET` 请求 `/health``/api/*` 端点
- **THEN** Bun server SHALL 返回对应端点的成功响应
#### Scenario: 不支持的 API method 请求
- **WHEN** 客户端使用不支持的 method 请求已存在的 `/api/*` 端点
- **THEN** `/api/*` 通配符 SHALL 返回包含 `error``status` 字段的 JSON 404 响应
### Requirement: API 路由命名空间
系统 MUST 将 `/api/*` 保留给后端 API 路由。
#### Scenario: API 路由匹配
- **WHEN** 请求匹配已注册的 `/api/*` 路由
- **THEN** Bun server SHALL 返回 API handler 的响应
#### Scenario: API 路由未命中
- **WHEN** 请求访问未注册的 `/api/*` 路由
- **THEN** Bun server MUST 返回 JSON 404 响应,而不是前端 HTML 文档
### Requirement: API 错误响应一致性
系统 SHALL 为 API 命名空间内的未匹配路由和未匹配 method 返回机器可读 JSON 404 响应。
#### Scenario: 未知 API 路由
- **WHEN** 客户端请求未知的 `/api/*` 路由
- **THEN** Bun server MUST 返回包含 `error``status` 字段的 JSON 404 响应,而不是前端 HTML 文档
#### Scenario: API method 不匹配
- **WHEN** 客户端使用不支持的 method 请求已存在的 API 路由
- **THEN** Bun server MUST 返回包含 `error``status` 字段的 JSON 404 响应
### Requirement: 健康检查端点
系统 SHALL 在前端 SPA fallback 之外暴露健康检查端点。
#### Scenario: 健康检查成功
- **WHEN** 客户端请求 `/health`
- **THEN** Bun server SHALL 返回成功的、机器可读的健康检查响应
### Requirement: 生产静态资源服务
系统 SHALL 在生产模式下通过自定义 `serveStaticAsset` 函数服务嵌入的 Vite 前端产出。
#### Scenario: 请求构建后的资源
- **WHEN** 客户端请求 `/assets/*` 路径下的前端资源
- **THEN** 系统 SHALL 从 StaticAssets 的 files map 中查找并返回对应资源Content-Type 根据扩展名推断
#### Scenario: 请求前端根路径
- **WHEN** 客户端请求 `/`
- **THEN** 系统 SHALL 返回 StaticAssets 中的 indexHtmlContent-Type 为 `text/html; charset=utf-8`
### Requirement: 生产缓存策略
系统 SHALL 为生产静态资源提供基于文件名 content hash 的缓存策略。
#### Scenario: 请求前端入口 HTML
- **WHEN** 生产 server 返回前端入口 HTML 文档
- **THEN** 响应 SHALL 包含 `Cache-Control: no-cache` header
#### Scenario: 请求构建后的静态资源
- **WHEN** 生产 server 返回 `/assets/*` 路径下的静态资源
- **THEN** 响应 SHALL 包含 `Cache-Control: public, max-age=31536000, immutable` header
### Requirement: 低风险安全响应头
系统 SHALL 在生产运行时的 JSON API 响应中附加低风险安全响应头HTML 和静态资源响应由 Bun HTML import manifest 返回其内置 headers。
#### Scenario: 生产 JSON 响应包含安全头
- **WHEN** 生产 Bun server 返回 `/health``/api/*` JSON 响应
- **THEN** 响应 SHALL 包含 `X-Content-Type-Options: nosniff``Referrer-Policy` headers
#### Scenario: 生产静态资源响应
- **WHEN** 生产 server 返回前端 HTML 文档或构建后的静态资源
- **THEN** 响应 SHALL 不要求附加自定义安全 headers仅需 Content-Type 和 Cache-Control
### Requirement: SPA fallback 行为
系统 SHALL 通过 fetch fallback 为非 API、非静态资源路径返回前端入口 HTML 文档。
#### Scenario: 刷新前端路由
- **WHEN** 客户端请求不包含文件扩展名的非 API 路径(如 `/dashboard`
- **THEN** fetch fallback SHALL 返回前端入口 HTML 文档
#### Scenario: 保留 API 错误语义
- **WHEN** 客户端请求未知的 `/api/*` 路由
- **THEN** routes 中的 `/api/*` 通配符 MUST 返回 JSON 404 响应,而不是前端入口 HTML 文档
### Requirement: 优雅关机
系统 SHALL 在收到终止信号时正确清理资源。
#### Scenario: SIGINT/SIGTERM 处理
- **WHEN** 开发模式进程收到 SIGINT 或 SIGTERM 信号
- **THEN** 系统 SHALL 调用 engine.stop() 停止调度、调用 store.close() 关闭数据库连接后退出进程

View File

@@ -1,27 +0,0 @@
## Purpose
定义系统运行时元数据 APIchecker 类型列表等元信息的对外暴露方式。
## Requirements
### Requirement: Meta 信息 API
系统 SHALL 提供 `GET /api/meta` 端点,返回系统运行时元数据。未匹配 method SHALL 按 API 通配符处理为 JSON 404不再保留自定义 HEAD/405 语义。
#### Scenario: 获取 checker 类型列表
- **WHEN** 客户端请求 `GET /api/meta`
- **THEN** 系统 SHALL 返回 JSON `{ checkerTypes: string[] }`,包含所有已注册的 checker 类型标识符(如 `["http", "cmd"]`
#### Scenario: 类型列表来源
- **WHEN** 系统启动并注册了 checker
- **THEN** `/api/meta` 返回的 `checkerTypes` SHALL 与 `CheckerRegistry.supportedTypes` 完全一致
#### Scenario: 不支持的 method 请求
- **WHEN** 客户端使用 POST/PUT/DELETE/HEAD 等未声明 method 请求 `/api/meta`
- **THEN** `/api/*` 通配符 SHALL 返回 JSON 404 响应
### Requirement: MetaResponse 共享类型
系统 SHALL 在 `src/shared/api.ts` 中定义 `MetaResponse` 类型。
#### Scenario: MetaResponse 类型定义
- **WHEN** 前后端引用 `MetaResponse` 类型
- **THEN** 该类型 SHALL 包含 `checkerTypes: string[]` 字段

View File

@@ -1,199 +0,0 @@
## Purpose
定义拨测系统的 REST API 端点Dashboard 聚合 API、单目标指标 API、带时间范围和分页的历史记录、共享类型定义和 API 错误处理。
## Requirements
### Requirement: Dashboard 聚合 API
系统 SHALL 提供 `GET /api/dashboard` 端点,返回 Dashboard 首屏所需的总览统计和目标列表数据。
#### Scenario: 获取 Dashboard 数据
- **WHEN** 客户端请求 `GET /api/dashboard?window=24h&recentLimit=30`
- **THEN** 系统 SHALL 返回 JSON 包含 summary 和 targets 字段
#### Scenario: summary 字段
- **WHEN** Dashboard 响应包含 summary
- **THEN** summary SHALL 包含 total总目标数、up当前正常目标数、down当前异常目标数、lastCheckTime最近一次检查时间、incidents指定窗口内异常事件数、windowfrom/to/label字段
#### Scenario: targets 字段
- **WHEN** Dashboard 响应包含 targets
- **THEN** targets 数组中每个元素 SHALL 包含目标基本信息id、name、group、type、target、interval、latestCheck、stats、currentStreak 和 recentSamples 字段
#### Scenario: window 参数缺失
- **WHEN** 客户端请求 `GET /api/dashboard` 未提供 window 参数
- **THEN** 系统 SHALL 默认使用 window=`24h`
#### Scenario: window 参数语义
- **WHEN** 系统处理 Dashboard 请求
- **THEN** 系统 SHALL 以服务端当前时间作为 window.to以 window 参数换算 window.from并在响应中回显 window.from、window.to、window.label
#### Scenario: window 参数有效值
- **WHEN** 客户端请求 Dashboard 端点并指定 window 参数
- **THEN** 系统 SHALL 接受 `24h` 作为有效值;其他值 SHALL 返回 400 状态码和错误信息
#### Scenario: 不支持的 window 参数
- **WHEN** 客户端请求 `GET /api/dashboard?window=abc`
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
#### Scenario: recentLimit 参数缺失
- **WHEN** 客户端请求 `GET /api/dashboard?window=24h` 未提供 recentLimit 参数
- **THEN** 系统 SHALL 默认使用 recentLimit=30
#### Scenario: 不支持的 recentLimit 参数
- **WHEN** 客户端请求 `GET /api/dashboard?recentLimit=0` 或超过系统上限的 recentLimit
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
#### Scenario: 目标无历史记录
- **WHEN** 某目标尚未执行过任何拨测
- **THEN** 其 latestCheck 为 nullrecentSamples 为空数组stats.totalChecks 为 0stats.availability 为 0currentStreak 为 null
### Requirement: Dashboard 指标字段
Dashboard API SHALL 返回基于时间窗口计算的目标统计和连续状态字段。
#### Scenario: 目标 stats 字段
- **WHEN** Dashboard 响应包含目标 stats
- **THEN** stats SHALL 包含 totalChecks、upChecks、downChecks、availability 字段,且这些字段 SHALL 基于请求 window 对应的时间范围计算
#### Scenario: 目标 currentStreak 字段
- **WHEN** Dashboard 响应包含目标 currentStreak
- **THEN** currentStreak SHALL 为 `{ up: boolean, count: number, capped?: boolean }` 或 null
#### Scenario: currentStreak 达到 recentLimit
- **WHEN** 连续状态次数达到 recentLimit 上限
- **THEN** currentStreak.capped SHALL 为 true
#### Scenario: recentSamples 字段
- **WHEN** Dashboard 响应包含 recentSamples
- **THEN** 每个 recentSamples 元素 SHALL 包含 timestamp、durationMs、up 字段,其中 up 为 boolean 且等于 matched
### Requirement: 单目标指标 API
系统 SHALL 提供 `GET /api/targets/:id/metrics` 端点,返回 Drawer 概览所需的单目标统计和趋势数据。端点的详细计算规则P95/P99、MTTR、故障分析、趋势分桶等定义在 `target-metrics-api` 能力中。
#### Scenario: 指定时间范围查询指标
- **WHEN** 客户端请求 `GET /api/targets/1/metrics?from=ISO&to=ISO&bucket=1h`
- **THEN** 系统 SHALL 返回 targetId、window、stats、trend 字段
#### Scenario: from 或 to 参数缺失
- **WHEN** 客户端请求 `GET /api/targets/1/metrics` 未提供 from 或 to 参数
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
#### Scenario: bucket 参数缺失
- **WHEN** 客户端请求 `GET /api/targets/1/metrics?from=ISO&to=ISO` 未提供 bucket 参数
- **THEN** 系统 SHALL 默认使用 bucket=`1h`
### Requirement: 历史记录 API
系统 SHALL 保留 `GET /api/targets/:id/history` 端点,支持时间范围筛选和分页返回指定目标的拨测记录。
#### Scenario: 获取指定时间范围内的历史记录
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&page=1&pageSize=20`
- **THEN** 系统 SHALL 返回带分页信息的历史记录,包含 items、total、page、pageSize按时间倒序排列
#### Scenario: 使用默认分页参数
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO`(未指定 page 或 pageSize
- **THEN** 系统 SHALL 使用默认 page=1, pageSize=20
#### Scenario: from 或 to 参数缺失
- **WHEN** 客户端请求 `GET /api/targets/1/history` 未提供 from 或 to 参数
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
### Requirement: 新增共享类型
系统 SHALL 在 `src/shared/api.ts` 中定义 Dashboard 和 Metrics 相关共享类型。
#### Scenario: DashboardResponse 类型
- **WHEN** 前后端共享 `DashboardResponse` 类型
- **THEN** 该类型 SHALL 包含 summary 和 targets 字段
#### Scenario: TargetStatus 类型
- **WHEN** 前后端共享 `TargetStatus` 类型
- **THEN** 该类型 SHALL 包含 statstotalChecks、upChecks、downChecks、availability、currentStreak 和 recentSamples 字段
#### Scenario: TargetMetricsResponse 类型
- **WHEN** 前后端共享 `TargetMetricsResponse` 类型
- **THEN** 该类型 SHALL 包含 targetId、window、stats 和 trend 字段
#### Scenario: TrendPoint 类型
- **WHEN** 前后端共享 `TrendPoint` 类型
- **THEN** 该类型 SHALL 包含 bucketStart、avgDurationMs、minDurationMs、maxDurationMs、availability、totalChecks、upChecks、downChecks 字段
#### Scenario: CheckResult 类型
- **WHEN** 前后端共享 `CheckResult` 类型
- **THEN** 该类型 SHALL 包含 `timestamp: string``matched: boolean``durationMs: number | null``statusDetail: string | null``failure` 字段,不包含 success 字段
#### Scenario: RecentSample 类型
- **WHEN** 前后端共享 `RecentSample` 类型
- **THEN** 该类型 SHALL 包含 `timestamp: string``durationMs: number | null``up: boolean` 字段,其中 up 为 boolean 且等于 matched
#### Scenario: HistoryResponse 类型
- **WHEN** 前后端共享 `HistoryResponse` 类型
- **THEN** 该类型 SHALL 包含 `items: CheckResult[]``total: number``page: number``pageSize: number` 字段
### Requirement: 保留健康检查端点
系统 SHALL 保留 `GET /health` 端点,不受拨测功能影响。
#### Scenario: 访问健康检查
- **WHEN** 客户端请求 `GET /health`
- **THEN** 系统 SHALL 返回与之前格式一致的健康检查响应
### Requirement: API 错误处理
系统 SHALL 对不存在的目标 ID、无效参数和超出范围的分页参数返回适当的 HTTP 错误响应。
#### Scenario: 查询不存在的目标
- **WHEN** 客户端请求 `GET /api/targets/999/metrics?from=ISO&to=ISO`
- **THEN** 系统 SHALL 返回 404 状态码和错误信息
#### Scenario: 无效的 from/to 参数
- **WHEN** 客户端请求 `GET /api/targets/1/metrics?from=invalid&to=ISO`
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
#### Scenario: from 晚于 to
- **WHEN** 客户端请求 `GET /api/targets/1/metrics?from=<较晚时间>&to=<较早时间>`
- **THEN** 系统 SHALL 返回 400 状态码和错误信息,提示 from 必须早于 to
#### Scenario: 无效的分页参数
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&page=abc`
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
#### Scenario: pageSize 超过上限
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&pageSize=201`
- **THEN** 系统 SHALL 返回 400 状态码和错误信息,提示 pageSize 不能超过 200
#### Scenario: pageSize 等于上限
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&pageSize=200`
- **THEN** 系统 SHALL 正常返回数据
#### Scenario: 无效的目标 ID
- **WHEN** 客户端请求 `GET /api/targets/abc/metrics?from=ISO&to=ISO`
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
### Requirement: 失败信息 API 契约
系统 SHALL 通过 API 返回结构化 failure 信息,供 Dashboard 展示和后续排查。
#### Scenario: 返回 expect 不匹配信息
- **WHEN** 最近一次检查结果包含 failure.kind=`mismatch`
- **THEN** `/api/targets``/api/targets/:id/history` SHALL 返回该 failure 的 kind、phase、path、expected、actual、message 字段
#### Scenario: 无失败信息
- **WHEN** 检查结果 matched=true
- **THEN** API SHALL 返回 failure 为 null
### Requirement: Meta 信息 API
系统 SHALL 提供 `GET /api/meta` 端点,返回系统运行时元数据。未匹配 method SHALL 按 API 通配符处理为 JSON 404不再保留自定义 HEAD/405 语义。
#### Scenario: 获取 checker 类型列表
- **WHEN** 客户端请求 `GET /api/meta`
- **THEN** 系统 SHALL 返回 JSON `{ checkerTypes: string[] }`,包含所有已注册的 checker 类型标识符
#### Scenario: 类型列表来源
- **WHEN** 系统启动并注册了 checker
- **THEN** `/api/meta` 返回的 `checkerTypes` SHALL 与 `CheckerRegistry.supportedTypes` 完全一致
#### Scenario: 不支持的 method 请求
- **WHEN** 客户端使用 POST/PUT/DELETE/HEAD 等未声明 method 请求 `/api/meta`
- **THEN** `/api/*` 通配符 SHALL 返回 JSON 404 响应
### Requirement: MetaResponse 共享类型
系统 SHALL 在 `src/shared/api.ts` 中定义 `MetaResponse` 类型。
#### Scenario: MetaResponse 类型定义
- **WHEN** 前后端引用 `MetaResponse` 类型
- **THEN** 该类型 SHALL 包含 `checkerTypes: string[]` 字段

View File

@@ -1,308 +0,0 @@
## Purpose
定义 HTTP 拨测工具的 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`(非负整数)字段。
#### Scenario: 完整配置文件解析
- **WHEN** 系统启动并读取包含 server、runtime、defaults、targets含 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"
#### Scenario: 最简 cmd 配置文件解析
- **WHEN** 系统读取只包含一个 `type: cmd` target 和 `cmd.exec` 的 YAML 配置文件
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, cmd.cwd 为配置文件所在目录, cmd.maxOutputBytes=100MB
#### Scenario: per-target 配置覆盖全局默认值
- **WHEN** 某个 target 指定 interval、timeout 或对应领域分组中的默认字段
- **THEN** 该 target SHALL 使用其自身的值,不受 defaults 中对应字段影响
#### Scenario: HTTP target 配置 ignoreSSL
- **WHEN** YAML 配置中 HTTP target 设置 `http.ignoreSSL: true`
- **THEN** 系统 SHALL 解析该字段并在执行时跳过 SSL 证书校验
#### Scenario: HTTP target 配置 maxRedirects
- **WHEN** YAML 配置中 HTTP target 设置 `http.maxRedirects: 5`
- **THEN** 系统 SHALL 解析该字段并在执行时允许最多跟随 5 次重定向
### Requirement: CLI 参数
系统 SHALL 通过单一命令行参数接受 YAML 配置文件路径。
#### Scenario: 指定配置文件启动
- **WHEN** 用户执行 `./dial-server ./probes.yaml`
- **THEN** 系统 SHALL 读取并解析指定路径的 YAML 文件作为配置
#### Scenario: 未提供配置文件路径
- **WHEN** 用户启动程序时未提供任何命令行参数
- **THEN** 系统 SHALL 以错误退出并提示需要指定配置文件路径
#### Scenario: 配置文件不存在
- **WHEN** 用户指定的配置文件路径不存在
- **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 可编译。
契约校验和语义 validator SHALL 统一产出 `ConfigValidationIssue`,最终由配置加载流程统一渲染为中文错误信息。
系统 SHALL 导出完整 `probe-config.schema.json`,该文件 SHALL 与运行期 TypeBox fragments 生成的 JSON Schema 保持一致,用于用户配置引用和编辑器提示。
`headers``env` 等明确声明为动态键值表的对象外,配置中的未知字段 SHALL 导致启动期配置错误。系统 MUST NOT 静默忽略未知字段。
#### Scenario: target 缺少必填字段
- **WHEN** YAML 中某个 target 缺少 name 或 type 字段
- **THEN** 系统 SHALL 以错误退出,提示哪个 target 缺少哪个字段
#### Scenario: HTTP target 缺少 url
- **WHEN** YAML 中某个 target 配置 `type: http` 但缺少 `http.url`
- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 http.url 字段
#### Scenario: cmd target 缺少 exec
- **WHEN** YAML 中某个 target 配置 `type: cmd` 但缺少 `cmd.exec`
- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 cmd.exec 字段
#### Scenario: target type 非法
- **WHEN** YAML 中某个 target 的 type 不是已注册 checker 类型
- **THEN** 系统 SHALL 以错误退出,提示不支持的 target type 和当前支持的 type 列表
#### Scenario: target name 重复
- **WHEN** YAML 中存在两个 name 相同的 target
- **THEN** 系统 SHALL 以错误退出,提示重复的 name
#### Scenario: group 字段类型校验
- **WHEN** YAML 中某个 target 的 `group` 字段不是字符串
- **THEN** 系统 SHALL 以错误退出并提示 group 字段类型错误
#### Scenario: interval 格式非法
- **WHEN** interval 或 timeout 值不是有效的时长格式(如 `30s``5m``500ms`
- **THEN** 系统 SHALL 以错误退出并提示格式错误
#### Scenario: maxConcurrentChecks 非法
- **WHEN** runtime.maxConcurrentChecks 不是正整数
- **THEN** 系统 SHALL 以错误退出并提示 runtime.maxConcurrentChecks 格式错误
#### Scenario: interval 或 timeout 解析结果非法
- **WHEN** interval 或 timeout 解析结果不是正整数毫秒(如 `0ms``1.5ms`
- **THEN** 系统 SHALL 以错误退出并提示必须为正整数毫秒
#### Scenario: size 格式非法
- **WHEN** maxBodyBytes 或 maxOutputBytes 值不是有效的 size 格式
- **THEN** 系统 SHALL 以错误退出并提示支持 B、KB、MB、GB 格式
#### Scenario: size 解析结果非法
- **WHEN** maxBodyBytes 或 maxOutputBytes 解析结果不是非负安全整数字节数(如 `1.5B`
- **THEN** 系统 SHALL 以错误退出并提示必须为非负安全整数字节数
#### Scenario: HTTP method 非法
- **WHEN** YAML 中某个 HTTP target 的 `http.method` 不是 GET、HEAD、POST、PUT、PATCH、DELETE、OPTIONS 之一
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 method 不合法
#### Scenario: HTTP method 小写非法
- **WHEN** YAML 中某个 HTTP target 配置 `http.method: get`
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 method 必须为大写枚举值
#### Scenario: URL 格式非法
- **WHEN** YAML 中某个 HTTP target 的 `http.url` 不是合法 URL或协议不是 `http:` / `https:`
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 URL 格式不合法
#### Scenario: maxRedirects 非法
- **WHEN** YAML 中某个 HTTP target 的 `http.maxRedirects` 为负数
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 maxRedirects 必须为非负整数
#### Scenario: maxRedirects 非整数非法
- **WHEN** YAML 中某个 HTTP target 的 `http.maxRedirects` 不是非负整数(如 `1.5``"5"`
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 maxRedirects 必须为非负整数
#### Scenario: ignoreSSL 类型非法
- **WHEN** YAML 中某个 HTTP target 的 `http.ignoreSSL` 不是布尔值
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 ignoreSSL 必须为布尔值
#### Scenario: HTTP headers 类型非法
- **WHEN** YAML 中某个 HTTP target 的 `http.headers` 不是对象,或任一 header 值不是字符串
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 http.headers 格式错误
#### Scenario: HTTP body 类型非法
- **WHEN** YAML 中某个 HTTP target 的 `http.body` 已配置但不是字符串
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 http.body 必须为字符串
#### Scenario: maxBodyBytes 数字非法
- **WHEN** YAML 中某个 HTTP target 的 `http.maxBodyBytes` 或 defaults.http.maxBodyBytes 是负数、非整数或非安全整数
- **THEN** 系统 SHALL 以错误退出,提示 maxBodyBytes 必须为非负安全整数字节数或合法 size 字符串
#### Scenario: status 模式非法
- **WHEN** YAML 中某个 HTTP target 的 `expect.status` 包含不符合 `1xx``5xx` 格式的字符串(如 `"abc"``"2x"``"6xx"`
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 status 模式不合法
#### Scenario: status 数字非法
- **WHEN** YAML 中某个 HTTP target 的 `expect.status` 包含非整数或不在 100-599 范围内的数字
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 status 数字不合法
#### Scenario: maxDurationMs 非法
- **WHEN** YAML 中某个 target 的 `expect.maxDurationMs` 不是非负有限数字
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 expect.maxDurationMs 格式错误
#### Scenario: HTTP expect headers 非法
- **WHEN** YAML 中某个 HTTP target 的 `expect.headers` 不是对象,或某个 header 期望既不是字符串也不是合法 operator
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 expect.headers 格式错误
#### Scenario: HTTP expect body 必须为数组
- **WHEN** YAML 中某个 HTTP target 的 `expect.body` 已配置但不是数组
- **THEN** 系统 SHALL 以错误退出,提示该 target 的 expect.body 必须为数组
#### Scenario: HTTP body rule 缺少支持字段
- **WHEN** YAML 中某个 HTTP target 的 `expect.body` 数组项未包含 contains、regex、json、css、xpath 任一支持字段
- **THEN** 系统 SHALL 以错误退出,提示该 body rule 缺少支持的规则类型
#### Scenario: HTTP body rule 同时配置多个支持字段
- **WHEN** YAML 中某个 HTTP target 的同一条 body rule 同时包含 contains、regex、json、css、xpath 中的多个支持字段
- **THEN** 系统 SHALL 以错误退出,提示每条 body rule 只能配置一种规则类型
#### Scenario: HTTP body regex 非法
- **WHEN** YAML 中某个 HTTP target 的 body regex 规则不是字符串或不是可编译正则表达式
- **THEN** 系统 SHALL 以错误退出,提示该 body regex 不合法
#### Scenario: HTTP body json path 非法
- **WHEN** YAML 中某个 HTTP target 的 body json 规则缺少 path或 path 不符合系统支持的 JSONPath 子集
- **THEN** 系统 SHALL 以错误退出,提示该 body json path 不合法
#### Scenario: HTTP body css selector 非法
- **WHEN** YAML 中某个 HTTP target 的 body css 规则缺少 selector或 selector 不是非空字符串
- **THEN** 系统 SHALL 以错误退出,提示该 body css selector 不合法
#### Scenario: HTTP body xpath path 非法
- **WHEN** YAML 中某个 HTTP target 的 body xpath 规则缺少 path或 path 不是非空字符串,或可被现有 XPath 库静态判定为语法错误
- **THEN** 系统 SHALL 以错误退出,提示该 body xpath path 不合法
#### Scenario: expect operator 类型非法
- **WHEN** YAML 中某个 HTTP expect operator 的 match 不是可编译正则字符串empty/exists 不是布尔值,或 gt/gte/lt/lte 不是有限数字
- **THEN** 系统 SHALL 以错误退出,提示对应 operator 配置不合法
#### Scenario: expect operator 类型非法
- **WHEN** YAML 中某个 expect operator 的 match 不是可编译正则字符串empty/exists 不是布尔值,或 gt/gte/lt/lte 不是有限数字
- **THEN** 系统 SHALL 以错误退出,提示对应 operator 配置不合法
#### Scenario: unknown 字段失败
- **WHEN** YAML 中任一结构化配置对象包含契约未声明的字段,且该对象不是明确允许动态键的对象
- **THEN** 系统 SHALL 以错误退出,提示未知字段所在路径
#### Scenario: 动态 headers 字段允许
- **WHEN** YAML 中 `http.headers``defaults.http.headers``expect.headers` 包含任意 header 名称,且对应值符合契约
- **THEN** 系统 SHALL 接受这些动态 header 名称
#### Scenario: 动态 env 字段允许
- **WHEN** YAML 中 `cmd.env` 包含任意环境变量名称,且对应值为字符串
- **THEN** 系统 SHALL 接受这些动态 env 名称
#### Scenario: JSON Schema 不修改输入
- **WHEN** 系统执行 JSON Schema 契约校验
- **THEN** 系统 MUST NOT 通过契约校验器强制转换类型、注入默认值或删除未知字段
#### Scenario: 配置生命周期分离
- **WHEN** 系统加载配置文件
- **THEN** 系统 SHALL 按 `unknown -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig` 的顺序执行契约校验、语义校验和运行期配置解析
#### Scenario: 结构化校验 issue
- **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 一致
系统 SHALL 支持使用单位字符串配置读取上限,单位包括 `B``KB``MB``GB`
#### Scenario: 解析 MB
- **WHEN** YAML 中配置 `maxBodyBytes: "100MB"`
- **THEN** 系统 SHALL 将其解析为 104857600 bytes
#### Scenario: 解析 KB
- **WHEN** YAML 中配置 `maxOutputBytes: "512KB"`
- **THEN** 系统 SHALL 将其解析为 524288 bytes
### Requirement: runtime 并发配置
系统 SHALL 支持 `runtime.maxConcurrentChecks` 配置全局最大并发检查数。
#### Scenario: 使用默认并发限制
- **WHEN** YAML 中未配置 runtime.maxConcurrentChecks
- **THEN** 系统 SHALL 使用默认值 20
#### Scenario: 配置并发限制
- **WHEN** YAML 中配置 `runtime.maxConcurrentChecks: 5`
- **THEN** 系统 SHALL 将全局最大并发检查数设置为 5
### Requirement: YAML 配置使用 Bun 内置解析
系统 SHALL 使用 Bun 内置的 `Bun.YAML.parse()` 解析配置文件,不引入外部 YAML 解析库。
#### Scenario: 解析 YAML 内容
- **WHEN** 系统读取 YAML 文件内容
- **THEN** 系统 SHALL 调用 `Bun.YAML.parse()` 将内容解析为配置对象
### Requirement: expect 配置增强
系统 SHALL 支持 typed target 的领域专用 expect 配置,包括 HTTP 的 `status`(支持精确数字和范围模式)、`headers``body` 和 cmd 的 `exitCode``stdout``stderr`。内容类 expect MUST 使用数组表达配置顺序。
#### Scenario: 解析 HTTP expect 配置
- **WHEN** YAML 配置文件中 HTTP target 的 expect 包含 status、headers、body 规则数组及内部方法
- **THEN** 系统 SHALL 正确解析并存储为 HTTP target 的 expect 字段
#### Scenario: 解析 cmd expect 配置
- **WHEN** YAML 配置文件中 cmd target 的 expect 包含 exitCode、stdout 和 stderr 规则数组
- **THEN** 系统 SHALL 正确解析并存储为 cmd target 的 expect 字段
#### Scenario: 解析 body 有序规则数组
- **WHEN** YAML 中 HTTP target 配置 `expect.body` 为 contains、json、regex 三个数组项
- **THEN** 系统 SHALL 保留数组顺序,供执行阶段按配置顺序快速失败
#### Scenario: 不配置 HTTP status
- **WHEN** HTTP target 未配置 `expect.status`
- **THEN** 系统 SHALL 在执行 expect 时使用默认 `status: [200]` 语义
#### Scenario: 配置 HTTP status 范围模式
- **WHEN** HTTP target 配置 `expect.status: ["2xx"]`
- **THEN** 系统 SHALL 在执行 expect 时匹配所有 200-299 状态码
#### Scenario: 配置 HTTP status 混合模式
- **WHEN** HTTP target 配置 `expect.status: ["2xx", 301]`
- **THEN** 系统 SHALL 在执行 expect 时匹配所有 200-299 状态码或精确匹配 301
#### Scenario: 不配置 cmd exitCode
- **WHEN** cmd target 未配置 `expect.exitCode`
- **THEN** 系统 SHALL 在执行 expect 时使用默认 `exitCode: [0]` 语义
#### Scenario: 不配置 expect
- **WHEN** target 未配置任何 expect 规则
- **THEN** 系统 SHALL 正常处理expect 字段为 undefined
### Requirement: 数据保留配置字段
配置 schema 的 `runtime` 段 SHALL 支持 `retention` 字段,类型为字符串,格式为 `<数字><单位>`(单位:`d` 天、`h` 小时、`m` 分钟),用于指定历史数据保留时长。
#### Scenario: retention 字段校验通过
- **WHEN** 配置文件中 `runtime.retention` 为合法格式(如 `"7d"``"24h"``"30m"`
- **THEN** 配置校验 SHALL 通过
#### Scenario: retention 字段格式非法
- **WHEN** 配置文件中 `runtime.retention` 为非法格式(如 `"abc"``"7x"``""`
- **THEN** 配置校验 SHALL 失败并报告格式错误
#### Scenario: retention 字段缺省
- **WHEN** 配置文件中未指定 `runtime.retention`
- **THEN** 系统 SHALL 使用默认值 `"7d"`
### Requirement: 数据目录路径解析
配置加载流程 SHALL 将 `server.dataDir` 相对路径基于配置文件所在目录configDir解析为绝对路径。绝对路径 SHALL 保持不变。
#### Scenario: dataDir 为相对路径
- **WHEN** 配置文件位于 `/opt/dial/probes.yaml`,且 `server.dataDir` 配置为 `./data`
- **THEN** 系统 SHALL 将 dataDir 解析为 `/opt/dial/data`,而非依赖进程 cwd
#### Scenario: dataDir 为绝对路径
- **WHEN** `server.dataDir` 配置为 `/var/lib/dial/data`
- **THEN** 系统 SHALL 直接使用该绝对路径,不做额外解析
#### Scenario: dataDir 使用默认值
- **WHEN** 未配置 `server.dataDir`(使用默认值 `./data`
- **THEN** 系统 SHALL 将默认值 `./data` 基于 configDir 解析为绝对路径

View File

@@ -1,57 +0,0 @@
## Purpose
定义拨测系统前端 Dashboard 页面总览统计卡片、Dashboard 数据查询、加载和错误状态处理。页面骨架布局见 `dashboard-layout`,分组表格布局见 `target-table`,目标详情 Drawer 见 `target-detail-drawer`,数据轮询和缓存见 `tanstack-query-data-layer`
## Requirements
### Requirement: Dashboard 数据查询
Dashboard SHALL 通过 `GET /api/dashboard` 获取首屏总览统计和目标列表数据。
#### Scenario: 查询 Dashboard 数据
- **WHEN** 页面处于打开状态
- **THEN** 前端 SHALL 使用 TanStack Query 请求 `GET /api/dashboard?window=24h&recentLimit=30`
#### Scenario: 统计数据自动刷新
- **WHEN** 页面处于打开状态
- **THEN** Dashboard 数据 SHALL 通过 TanStack Query 的 refetchInterval=8000 自动刷新
#### Scenario: 元信息独立查询
- **WHEN** 页面需要 checker 类型列表
- **THEN** 前端 SHALL 继续通过 `GET /api/meta` 独立查询 checkerTypes
### Requirement: 总览统计卡片
Dashboard SHALL 在页面顶部使用单个 TDesign Card 组件内嵌一行居中的 Statistic 展示总览统计,包含总目标数、正常数、异常数和窗口异常事件数。
#### Scenario: 展示统计卡片
- **WHEN** 用户打开 Dashboard 页面
- **THEN** 页面顶部 SHALL 使用单个 TDesign Card无 shadow、无 bordered内嵌 TDesign Row/Col 布局展示 4 个居中的 Statistic全部目标数color=blue、正常目标数color=green、异常目标数color=red、24h 异常事件数color=orange
#### Scenario: 指标居中显示
- **WHEN** SummaryCards 渲染
- **THEN** 每个 Statistic 所在的 Col SHALL 使用 `.summary-stat-col` 类实现标题和数字居中对齐
#### Scenario: 异常事件数据来源
- **WHEN** SummaryCards 渲染 24h 异常事件数
- **THEN** 该数值 SHALL 使用 DashboardResponse.summary.incidents 字段,标题 SHALL 基于当前 window 展示为"24h 异常事件数"
### Requirement: 页面加载与错误状态
Dashboard SHALL 使用 TDesign Skeleton 组件处理首次加载状态,使用 Alert 处理错误。
#### Scenario: 首次加载
- **WHEN** 页面首次加载且数据尚未返回
- **THEN** 页面 SHALL 使用 TDesign Skeleton 组件animation="gradient")展示页面骨架,模拟 Summary 区域和 Table 区域的大致结构
#### Scenario: API 请求失败
- **WHEN** 前端 API 请求失败
- **THEN** 页面 SHALL 使用 TDesign Alert 组件theme=error显示错误提示
### Requirement: 前端构建产物拆分
前端生产构建 SHALL 将 vendor 依赖拆分为独立 chunk利用浏览器并行加载和长期缓存。
#### Scenario: vendor chunk 拆分
- **WHEN** 执行前端生产构建
- **THEN** 构建产物 SHALL 包含独立的 vendor chunkreact、tdesign、recharts 各自独立),而非单个 bundle
#### Scenario: 业务代码变更不影响 vendor 缓存
- **WHEN** 仅修改业务代码src/web/ 下非 node_modules 文件)并重新构建
- **THEN** vendor chunk 的文件名(含 hashSHALL 保持不变,浏览器缓存 SHALL 继续有效

View File

@@ -1,171 +0,0 @@
## Purpose
定义基于 SQLite 的拨测数据持久化存储targets 同步含分组信息、check_results 追加写入、Dashboard 和 Metrics 数据查询支持、延迟百分位取数、时间范围和分页查询、索引与聚合查询。
## Requirements
### Requirement: SQLite 数据库初始化
系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。数据库 schema MUST 支持 typed checker target 和结构化检查结果targets 表 MUST 包含 `grp` 列存储分组信息。
#### Scenario: 首次启动创建数据库
- **WHEN** 指定的数据目录下不存在数据库文件
- **THEN** 系统 SHALL 创建数据库文件并初始化 targets 表和 check_results 表check_results 表包含 idINTEGER PRIMARY KEY AUTOINCREMENT、target_idINTEGER NOT NULL、timestampTEXT NOT NULL、matchedINTEGER NOT NULL、duration_msREAL、status_detailTEXT、failureTEXT不包含 success 列
#### Scenario: 数据目录不存在
- **WHEN** 配置的数据目录路径不存在
- **THEN** 系统 SHALL 自动创建该目录
#### Scenario: 数据库已存在时启动
- **WHEN** 数据库文件已存在
- **THEN** 系统 SHALL 直接打开数据库,不重新建表
#### Scenario: 外键约束
- **THEN** 系统 SHALL 启用 `PRAGMA foreign_keys = ON`
#### Scenario: 级联删除
- **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE CASCADE`,确保删除目标时自动清理关联结果记录
### Requirement: targets 表同步
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示摘要、领域配置、调度配置、expect 配置和分组信息。
#### Scenario: 首次同步目标
- **WHEN** 数据库为空且 YAML 中定义了 N 个 typed target
- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、type、target、config、interval_ms、timeout_ms、expect 和 grp
#### Scenario: 配置变更后重新同步
- **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启
- **THEN** 系统 SHALL 根据 name 字段匹配:新增的插入、删除的移除、修改的更新(含 grp 字段)
### Requirement: check_results 表追加写入
系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。
#### Scenario: 写入检查结果
- **WHEN** 一次 checker 执行完成
- **THEN** 系统 SHALL 插入一条包含 target_id、timestamp、matched、duration_ms、status_detail、failure 的记录
#### Scenario: 写入结构化失败信息
- **WHEN** checker 执行失败或 expect 不匹配
- **THEN** 系统 SHALL 将首个失败原因序列化写入 failure 字段
### Requirement: 时间范围查询索引
系统 SHALL 在 check_results 表上创建 (target_id, timestamp) 复合索引,加速按目标和时间范围的查询。
#### Scenario: 查询某目标的历史记录
- **WHEN** 查询指定 target_id 的最近 N 条记录
- **THEN** 系统 SHALL 使用索引快速定位,无需全表扫描
### Requirement: 目标列表按分组排序
系统 SHALL 保证 targets 查询结果按分组排序返回。
#### Scenario: 分组排序查询
- **WHEN** 查询所有 targets
- **THEN** 结果 SHALL 将 "default" 分组目标排在首位,其余分组按 YAML 配置中首次出现的顺序(即 id 自增顺序)排列
### Requirement: 聚合查询支持
数据存储 SHALL 支持按时间段获取指标计算所需数据,用于后端应用层计算可用率、平均耗时、延迟范围、趋势分桶和可靠性指标。
#### Scenario: 轻数据库计算边界
- **WHEN** 实现指标相关数据查询
- **THEN** 数据库 SHALL 主要负责存储、过滤、排序、分页、LIMIT 和标准 SQL 基础聚合,业务指标语义 SHALL 在后端应用层计算
#### Scenario: 可使用的基础 SQL 聚合
- **WHEN** 查询需要减少返回数据量
- **THEN** 系统 MAY 使用标准 SQL 的 COUNT、SUM(CASE)、AVG、MIN、MAX、GROUP BY 等基础能力
#### Scenario: 避免数据库承载业务语义
- **WHEN** 实现状态翻转、故障段、MTTR、最长故障、连续状态、百分位或趋势分桶
- **THEN** 系统 SHALL 在后端应用层实现这些规则,不依赖 SQLite 专有函数或复杂窗口函数承载业务语义
#### Scenario: UP/DOWN 判定
- **WHEN** 系统需要判定目标当前状态
- **THEN** 系统 SHALL 基于 latestCheck.matched 判定目标 UP 或 DOWNmatched=true 为 UPmatched=false 为 DOWN
### Requirement: Dashboard 数据查询支持
ProbeStore SHALL 提供 Dashboard 聚合响应所需的批量取数能力。
#### Scenario: 批量获取最新检查
- **WHEN** Dashboard API 需要计算当前 up/down 和 lastCheckTime
- **THEN** Store SHALL 支持批量获取每个 target 的最新检查记录,避免 N+1 查询
#### Scenario: 批量获取窗口统计基础数据
- **WHEN** Dashboard API 需要计算各 target 在指定 window 内的 totalChecks、upChecks、downChecks 和 availability
- **THEN** Store SHALL 支持按 target_id 批量返回指定时间窗口内的基础计数数据
#### Scenario: 批量获取最近样本
- **WHEN** Dashboard API 需要展示 recentSamples 和计算 capped currentStreak
- **THEN** Store SHALL 支持批量获取每个 target 最近 recentLimit 条检查记录,按 target_id 分组且每组按 timestamp 降序排列
#### Scenario: 获取 Dashboard 异常事件序列
- **WHEN** Dashboard API 需要计算 incidents
- **THEN** Store SHALL 支持获取指定时间窗口内所有 target 的 `{ target_id, timestamp, matched }` 序列,按 target_id 和 timestamp 升序排列,供后端应用层计算状态翻转
### Requirement: 单目标指标取数支持
ProbeStore SHALL 提供单目标 metrics 响应所需的取数能力。
#### Scenario: 获取目标检查点序列
- **WHEN** Metrics API 需要计算趋势分桶、故障段、MTTR、最长故障、故障次数和连续状态
- **THEN** Store SHALL 支持获取指定 target 在 from 到 to 时间范围内的 `{ timestamp, matched, duration_ms }` 数组,按 timestamp 升序排列
#### Scenario: 无检查记录
- **WHEN** 时间窗口内无检查记录
- **THEN** Store SHALL 返回空数组
### Requirement: 目标延迟百分位取数
ProbeStore SHALL 提供 `getTargetDurations(targetId, from, to)` 方法,返回时间窗口内所有成功检查的 duration_ms 数组。
#### Scenario: 获取延迟数据
- **WHEN** 调用 `getTargetDurations(targetId, from, to)`
- **THEN** 系统 SHALL 返回该目标在时间范围内所有 matched=1 且 duration_ms 不为 null 的 duration_ms 值数组
#### Scenario: 延迟数据排序
- **WHEN** 获取延迟数据
- **THEN** 返回数组 SHALL 按 duration_ms 升序排列,供后端应用层计算 P95/P99
#### Scenario: 无成功检查
- **WHEN** 时间窗口内无 matched=1 且 duration_ms 不为 null 的记录
- **THEN** 系统 SHALL 返回空数组
### Requirement: 历史记录时间范围和分页查询
系统 SHALL 继续支持按时间范围筛选并分页查询历史记录。
#### Scenario: 按时间范围筛选历史记录
- **WHEN** 查询指定 target 在 from 到 to 时间范围内的历史记录
- **THEN** 系统 SHALL 返回该时间范围内的记录,按 timestamp 降序排列
#### Scenario: 分页查询历史记录
- **WHEN** 查询指定 page 和 pageSize 的历史记录
- **THEN** 系统 SHALL 返回对应页的数据和总记录数
### Requirement: 目标展示摘要持久化
数据存储 SHALL 为每个 target 持久化一个领域无关的展示摘要字段 `target`
#### Scenario: HTTP target 展示摘要
- **WHEN** 同步 HTTP target
- **THEN** targets.target SHALL 存储该 target 的 URL
#### Scenario: cmd target 展示摘要
- **WHEN** 同步 cmd target
- **THEN** targets.target SHALL 存储由 exec 和 args 组成的命令摘要
#### Scenario: HTTP target config 序列化
- **WHEN** 同步 HTTP target
- **THEN** targets.config SHALL 存储 JSON包含 url、method、headers、body、maxBodyBytes、ignoreSSL、maxRedirects
#### Scenario: cmd target config 序列化
- **WHEN** 同步 cmd target
- **THEN** targets.config SHALL 存储 JSON包含 exec、args、cwd、env、maxOutputBytes
### Requirement: 数据清理方法
ProbeStore SHALL 提供 `prune(retentionMs: number)` 方法,删除超过保留时长的历史检查结果并返回删除行数。
#### Scenario: 清理过期数据
- **WHEN** 调用 `prune(604800000)`7 天毫秒数)
- **THEN** 系统 SHALL 删除 `check_results` 表中 `timestamp` 早于当前时间减去 604800000 毫秒的所有记录,并返回实际删除的行数
#### Scenario: 无过期数据
- **WHEN** 调用 `prune()` 但所有记录都在保留期内
- **THEN** 系统 SHALL 返回 0不删除任何记录
#### Scenario: 清理不影响保留期内数据
- **WHEN** 调用 `prune()` 且存在保留期内和保留期外的记录
- **THEN** 系统 SHALL 仅删除保留期外的记录,保留期内的记录 SHALL 不受影响

View File

@@ -1,255 +0,0 @@
## Purpose
定义拨测调度引擎的行为:按 interval 分组定时、组内并发拨测、expect 结果校验和结果持久化。
## Requirements
### Requirement: 按 interval 分组调度
系统 SHALL 将拨测目标按 interval 值分组,每组使用独立的定时器进行调度。
#### Scenario: 相同 interval 的目标共享定时器
- **WHEN** 多个 target 配置了相同的 interval如 30s
- **THEN** 系统 SHALL 使用同一个 `setInterval` 定时器,每次 tick 并发拨测所有该组目标
#### Scenario: 不同 interval 的目标各自调度
- **WHEN** target A 配置 15s intervaltarget B 配置 30s interval
- **THEN** 系统 SHALL 创建两个独立定时器,分别按各自频率调度
### Requirement: 组内并发拨测
系统 SHALL 在每次调度 tick 时并发执行同组内目标的检查,但实际同时运行的检查数 MUST 受全局 `runtime.maxConcurrentChecks` 限制。当某个目标的 checker 执行 rejected非正常 CheckResult 返回,而是 Promise reject系统 SHALL 将该异常记录为 `matched: false` 的 check_result而非仅 console.warn。
#### Scenario: 同组目标并发执行
- **WHEN** 调度器触发一次 tick该组有 3 个目标,且全局并发余量至少为 3
- **THEN** 系统 SHALL 同时执行 3 个 checker而非顺序执行
#### Scenario: 单个目标失败不影响同组其他目标
- **WHEN** 同组中某个目标的检查请求超时或失败checker 正常返回 CheckResult
- **THEN** 其他目标的检查 SHALL 正常完成并记录结果
#### Scenario: 同组中某个目标的 checker 执行 rejected
- **WHEN** 同组中某个目标的 checker 执行抛出未捕获异常Promise rejected
- **THEN** 系统 SHALL 为该目标写入一条 `matched: false` 的 check_resultfailure 为 `{ kind: "error", phase: "internal", path: "engine", message: <rejected reason> }`,其他目标的检查 SHALL 不受影响
#### Scenario: rejected 结果通过索引关联 targetName
- **WHEN** checker 执行 rejected
- **THEN** 系统 SHALL 通过 Promise.allSettled 的索引关联回 target 数组,获取对应的 targetName 用于写入 check_result
#### Scenario: 全局并发限制生效
- **WHEN** 调度器同时触发 10 个目标且 runtime.maxConcurrentChecks 为 3
- **THEN** 系统 MUST 同时最多运行 3 个检查,其余检查等待并发槽位释放
### Requirement: HTTP 拨测执行
系统 SHALL 对 `type: http` 的目标执行 HTTP 请求,支持 GET、POST、PUT、DELETE、PATCH、HEAD、OPTIONS 方法,并携带 `http.headers``http.body`。系统 SHALL 支持 `http.ignoreSSL` 配置跳过 SSL 证书校验,支持 `http.maxRedirects` 配置控制重定向行为。HTTP response body 读取 SHALL 受 `http.maxBodyBytes` 流式限制,重定向跟随 SHALL 释放被跟随响应的 body。
#### Scenario: 执行 GET 请求
- **WHEN** 目标配置 method 为 GET
- **THEN** 系统 SHALL 发送 GET 请求到目标 URL
#### Scenario: 执行 POST 请求带 body
- **WHEN** 目标配置 method 为 POST 且指定了 body 和 Content-Type header
- **THEN** 系统 SHALL 发送带指定 body 的 POST 请求
#### Scenario: 携带自定义 headers
- **WHEN** 目标配置了 headers如 Authorization
- **THEN** 系统 SHALL 在请求中包含所有配置的 headers
#### Scenario: HTTP body 读取上限
- **WHEN** HTTP response body 超过该 target 的 maxBodyBytes
- **THEN** 系统 MUST 停止继续读取 response body并记录 `matched=false``failure.kind="error"``failure.phase="body"` 的结构化输出超限错误
#### Scenario: HTTP body 大小等于上限
- **WHEN** HTTP response body 的字节数等于该 target 的 maxBodyBytes
- **THEN** 系统 SHALL 允许该 body 进入后续解码和 expect 校验
#### Scenario: HTTP body 上限为 0
- **WHEN** HTTP target 配置 maxBodyBytes 为 0 且响应体非空
- **THEN** 系统 SHALL 停止读取并记录 body 超限错误
#### Scenario: 忽略 SSL 证书校验
- **WHEN** 目标配置 `http.ignoreSSL: true` 且目标 URL 为 HTTPS
- **THEN** 系统 SHALL 跳过 SSL 证书校验,即使证书无效也正常完成请求
#### Scenario: 不忽略 SSL 证书校验
- **WHEN** 目标未配置 `http.ignoreSSL` 或配置为 `false`,且目标 URL 使用自签名证书
- **THEN** 系统 SHALL 因 SSL 证书校验失败而记录请求错误
#### Scenario: 默认不跟随重定向
- **WHEN** 目标未配置 `http.maxRedirects` 或配置为 0且服务端返回 301/302
- **THEN** 系统 SHALL 不跟随重定向,直接返回 301/302 的响应状态码和响应头
#### Scenario: 配置跟随重定向
- **WHEN** 目标配置 `http.maxRedirects: 5` 且服务端返回重定向
- **THEN** 系统 SHALL 跟随重定向,最多跟随 5 次,并在跟随前释放当前重定向响应的 body
#### Scenario: 超过最大重定向次数
- **WHEN** 目标配置 `http.maxRedirects: 1` 且服务端连续返回两次重定向
- **THEN** 系统 SHALL 只跟随第一次重定向,并返回第二次重定向响应的状态码和响应头
#### Scenario: POST 重定向改 GET
- **WHEN** POST 请求遇到 301/302 或任意方法请求遇到 303且系统决定按 GET 跟随重定向
- **THEN** 系统 SHALL 移除请求 body并清理 content-type、content-length 等 body 相关 headers 后发起后续 GET 请求
#### Scenario: 跨 origin 重定向敏感 header
- **WHEN** HTTP 请求跟随重定向到不同 origin
- **THEN** 系统 SHALL NOT 将 authorization、cookie 等敏感 headers 转发到新的 origin
#### Scenario: 响应体编码自动检测
- **WHEN** HTTP 响应的 `Content-Type` header 包含 `charset=gbk``charset="gbk"`
- **THEN** 系统 SHALL 使用 GBK 编码解码响应体,而非硬编码 UTF-8
#### Scenario: 响应体编码回退 UTF-8
- **WHEN** HTTP 响应的 `Content-Type` header 未指定 charset
- **THEN** 系统 SHALL 使用 UTF-8 编码解码响应体
#### Scenario: 响应体编码不支持
- **WHEN** HTTP 响应的 `Content-Type` header 指定了当前运行时不支持的 charset
- **THEN** 系统 SHALL 记录 `matched=false``failure.kind="error"``failure.phase="body"` 的解码失败结果
### Requirement: 请求超时控制
系统 SHALL 对每次 checker 执行实施超时控制,超时时间使用目标配置的 timeout 值。
#### Scenario: HTTP 请求超时
- **WHEN** HTTP 请求在 timeout 时间内未收到响应
- **THEN** 系统 SHALL 中止该请求,记录为失败并标注超时错误
#### Scenario: cmd 执行超时
- **WHEN** cmd 进程在 timeout 时间内未退出
- **THEN** 系统 MUST 终止该子进程,记录为失败并标注超时错误
#### Scenario: 请求在超时前完成
- **WHEN** checker 在超时前完成执行
- **THEN** 系统 SHALL 正常记录执行结果并进入 expect 校验
### Requirement: expect 校验
系统 SHALL 在 checker 执行完成后根据目标类型的 expect 配置校验观测结果,校验结果和首个失败原因记入 check result。HTTP checker 的 `durationMs` SHALL 表示完整 checker 执行耗时,包括重定向、响应体读取、响应体解码和 expect 校验。
#### Scenario: HTTP 默认状态码
- **WHEN** HTTP target 未配置 `expect.status`
- **THEN** 系统 SHALL 按默认 `status: [200]` 校验响应状态码
#### Scenario: 校验 HTTP 状态码精确值
- **WHEN** HTTP target 配置了 `expect.status: [200, 201]`
- **THEN** 系统 SHALL 检查响应状态码是否在列表中,将匹配结果记录到 matched 字段
#### Scenario: 校验 HTTP 状态码范围模式
- **WHEN** HTTP target 配置了 `expect.status: ["2xx"]`
- **THEN** 系统 SHALL 检查响应状态码是否在 200-299 范围内
#### Scenario: 校验 HTTP 状态码混合模式
- **WHEN** HTTP target 配置了 `expect.status: ["2xx", 301]`,且响应状态码为 204
- **THEN** 系统 SHALL 判定状态码匹配204 属于 2xx 范围)
#### Scenario: 校验 HTTP 响应头
- **WHEN** HTTP target 配置了 `expect.headers: {"Content-Type": {contains: "application/json"}}`
- **THEN** 系统 SHALL 检查响应头是否符合指定规则,全部匹配时继续后续阶段
#### Scenario: 校验 HTTP 响应体
- **WHEN** HTTP target 配置了有序 `expect.body` 规则数组
- **THEN** 系统 SHALL 按数组顺序执行 body 规则,任一失败立即记录 failure 并停止后续规则
#### Scenario: 校验 HTTP 完整耗时阈值
- **WHEN** 目标配置了 `expect.maxDurationMs`,且 HTTP checker 完整执行含重定向、body 读取、解码和 expect后的 durationMs 超过阈值
- **THEN** 系统 SHALL 判定 duration 不匹配,记录完整 durationMs 和 duration failure
#### Scenario: HTTP body 前耗时已超阈值
- **WHEN** HTTP target 配置了 body 校验和 `expect.maxDurationMs`,且进入 body 读取前的已耗时已超过阈值
- **THEN** 系统 SHALL 直接返回 duration failure且 MUST NOT 读取 response body
#### Scenario: HTTP body 失败优先于后续 duration 检查
- **WHEN** HTTP target 配置了 body 校验和 `expect.maxDurationMs`body 阶段存在失败,且完整执行后 duration 也超过阈值
- **THEN** 系统 SHALL 返回 body 阶段的失败首个失败为准durationMs SHALL 记录完整耗时
#### Scenario: HTTP 慢响应体计入耗时
- **WHEN** HTTP target 配置了 body 校验和 `expect.maxDurationMs`,且响应头很快返回但响应体读取导致完整执行耗时超过阈值
- **THEN** 系统 SHALL 判定 duration 不匹配并记录完整 durationMs
#### Scenario: 多条 expect 规则
- **WHEN** 目标同时配置状态、duration、元数据和内容规则
- **THEN** 系统 SHALL 所有规则全部通过时 matched 为 true任一不通过则为 false 并记录首个失败原因
#### Scenario: cmd 默认 exitCode
- **WHEN** cmd target 未配置 `expect.exitCode`
- **THEN** 系统 SHALL 按默认 `exitCode: [0]` 校验命令退出码
#### Scenario: 校验 cmd stdout
- **WHEN** cmd target 配置了有序 `expect.stdout` 规则数组
- **THEN** 系统 SHALL 按数组顺序执行 stdout 规则,任一失败立即记录 failure 并停止后续规则
### Requirement: Body 校验按需解析
系统 SHALL 仅在 HTTP target 配置了 body 校验,且 status、headers 阶段均通过,并且进入 body 前未确定 duration 已失败时才读取并解析响应体避免不必要的读取和解析开销。HTTP target 未配置 body 校验时,系统 SHALL NOT 读取 response body。
#### Scenario: status 失败时不读取 body
- **WHEN** HTTP target 的 status 阶段不匹配
- **THEN** 系统 SHALL 立即返回 matched=false且 MUST NOT 读取 response body
#### Scenario: headers 失败时不读取 body
- **WHEN** HTTP target 的 status 阶段匹配但 headers 阶段不匹配
- **THEN** 系统 SHALL 立即返回 matched=false且 MUST NOT 读取 response body
#### Scenario: 进入 body 前 duration 已失败时不读取 body
- **WHEN** HTTP target 已配置 `expect.maxDurationMs`,且进入 body 读取前的已耗时已经超过阈值
- **THEN** 系统 SHALL 返回 duration failure且 MUST NOT 读取 response body
#### Scenario: 仅配置 contains 时不解析 JSON
- **WHEN** HTTP target 仅配置 body contains 规则而未配置 json/css/xpath 规则
- **THEN** 系统 SHALL 不执行 JSON.parse 或 HTML/XML 解析
#### Scenario: 配置 json 时解析 JSON 失败
- **WHEN** HTTP target 配置了 body json 规则但响应体不是合法 JSON
- **THEN** 系统 SHALL 判定 matched 为 false并记录 json 规则对应的 failure.path
### Requirement: HTTP 运行期错误归属
HTTP checker SHALL 将运行期失败归属到实际失败阶段。请求、网络、TLS 和 timeout 错误 SHALL 记录为 request 阶段错误body 超限、响应体解码失败、响应内容解析失败 SHALL 记录为 body 阶段错误expect 不匹配 SHALL 记录为对应 mismatch 阶段。
#### Scenario: 请求错误归属 request
- **WHEN** HTTP 请求因为网络、TLS 或 timeout 失败
- **THEN** 系统 SHALL 记录 `matched=false``failure.kind="error"``failure.phase="request"`
#### Scenario: body 超限归属 body
- **WHEN** HTTP response body 超过 maxBodyBytes
- **THEN** 系统 SHALL 记录 `failure.kind="error"``failure.phase="body"``failure.path="body"`
#### Scenario: body 解析错误归属 body
- **WHEN** HTTP response body 已读取但解码、JSON 解析、CSS 解析或 XPath 解析失败
- **THEN** 系统 SHALL 记录 `failure.phase="body"`,且 SHALL NOT 将该失败记录为 request 错误
### Requirement: 拨测结果记录
系统 SHALL 在每次 checker 完成后,将结果写入 SQLite 数据存储,包含 target_id、timestamp、matched、duration_ms、status_detail、failure 字段。
#### Scenario: 成功检查结果记录
- **WHEN** checker 成功执行且 expect 全部匹配
- **THEN** 系统 SHALL 记录 matched=true、duration_ms、status_detailfailure 为 null
#### Scenario: 执行失败结果记录
- **WHEN** checker 执行失败(网络错误、超时、命令启动失败、输出超限等)
- **THEN** 系统 SHALL 记录 matched=false、failure.kind="error" 和具体错误信息
#### Scenario: expect 不匹配结果记录
- **WHEN** checker 执行成功但 expect 不匹配
- **THEN** 系统 SHALL 记录 matched=false、failure.kind="mismatch" 和具体不匹配信息
### Requirement: runner 选择
系统 SHALL 根据 target.type 选择对应 runner 执行检查。
#### Scenario: 选择 HTTP runner
- **WHEN** target.type 为 `http`
- **THEN** 系统 SHALL 使用 HTTP runner 执行该目标
#### Scenario: 选择 cmd runner
- **WHEN** target.type 为 `cmd`
- **THEN** 系统 SHALL 使用 cmd runner 执行该目标
### Requirement: 定期数据清理
ProbeEngine SHALL 在启动时注册数据清理定时器,定期调用 ProbeStore.prune() 清理过期数据。
#### Scenario: 引擎启动注册清理
- **WHEN** ProbeEngine.start() 被调用且 retentionMs > 0
- **THEN** 系统 SHALL 立即执行一次 prune然后每隔 1 小时再次执行
#### Scenario: 引擎停止清除定时器
- **WHEN** ProbeEngine.stop() 被调用
- **THEN** 系统 SHALL 清除清理定时器,不再执行后续清理
#### Scenario: retentionMs 为 0 不注册清理
- **WHEN** ProbeEngine 构造时 retentionMs 为 0
- **THEN** 系统 SHALL 不注册清理定时器

View File

@@ -1,84 +0,0 @@
## Purpose
定义 Header 刷新频率选择器组件的交互行为:频率切换、倒计时显示、手动刷新按钮、布局稳定性。
## Requirements
### Requirement: 刷新频率选择器
HeadMenu operations 区域 SHALL 提供 RadioGroup 组件供用户选择刷新频率。
#### Scenario: RadioGroup 渲染
- **WHEN** Dashboard 页面渲染
- **THEN** HeadMenu operations 区域 SHALL 显示 RadioGrouptheme="button", variant="default-filled"选项为手动、10秒、30秒、1分钟、5分钟
#### Scenario: 默认选中
- **WHEN** 页面首次加载
- **THEN** RadioGroup SHALL 默认选中"30秒"
#### Scenario: 切换频率立即刷新
- **WHEN** 用户切换刷新频率选项
- **THEN** 系统 SHALL 立即触发一次数据刷新,然后应用新的刷新间隔
### Requirement: 倒计时显示
RadioGroup 右侧 SHALL 显示距下次自动刷新的倒计时文本。倒计时逻辑 SHALL 封装在独立的 `RefreshCountdown` 组件中App 组件 SHALL NOT 持有每秒更新的 `now` state。
#### Scenario: RefreshCountdown 组件封装
- **WHEN** Dashboard 页面渲染
- **THEN** 倒计时显示 SHALL 由独立的 `RefreshCountdown` 组件负责,该组件内部持有 `now` state 和每秒 `setInterval`,渲染边界限制在该组件内部
#### Scenario: RefreshCountdown props
- **WHEN** RefreshCountdown 组件渲染
- **THEN** 组件 SHALL 接收 `dashboardUpdatedAt: number``refreshInterval: number``isFetching: boolean``isManualRefresh: boolean``onRefresh: () => void` 作为 props
#### Scenario: 短时间格式
- **WHEN** 距下次刷新剩余时间小于 60 秒
- **THEN** 倒计时 SHALL 显示为"xx秒"格式(如"26秒"
#### Scenario: 长时间格式
- **WHEN** 距下次刷新剩余时间大于等于 60 秒
- **THEN** 倒计时 SHALL 显示为"x分x秒"格式(如"4分30秒"
#### Scenario: 无前缀
- **WHEN** 倒计时显示
- **THEN** 倒计时文本 SHALL 不包含任何前缀(如"下一次刷新:"),直接显示时间
#### Scenario: 刷新中状态
- **WHEN** 数据正在刷新isFetching=true 且 isLoading=false
- **THEN** 倒计时文本 SHALL 显示为"刷新中..."
### Requirement: App 组件渲染隔离
App 组件 SHALL NOT 持有任何高频更新的 state如每秒更新的时钟确保 App 的重渲染频率与数据刷新频率一致(默认 30 秒一次)。
#### Scenario: App 无 now state
- **WHEN** App 组件渲染
- **THEN** App SHALL NOT 包含 `useState` 管理的时钟 state也 SHALL NOT 包含每秒触发的 `setInterval`
#### Scenario: App 重渲染频率
- **WHEN** Dashboard 处于自动刷新模式
- **THEN** App 组件的重渲染 SHALL 仅由 TanStack Query 的 refetch 触发(频率等于用户选择的刷新间隔),而非每秒触发
### Requirement: 手动刷新按钮
选择"手动"模式时,倒计时区域 SHALL 替换为刷新按钮。
#### Scenario: 手动模式显示按钮
- **WHEN** 用户选择"手动"刷新频率
- **THEN** 倒计时区域 SHALL 替换为刷新图标按钮
#### Scenario: 点击刷新
- **WHEN** 用户点击刷新按钮
- **THEN** 系统 SHALL 触发一次数据刷新
#### Scenario: 刷新中禁用
- **WHEN** 数据正在刷新
- **THEN** 刷新按钮 SHALL 显示 loading 状态且 disabled防止连续点击
### Requirement: 布局稳定性
倒计时/按钮容器 SHALL 保持布局稳定,避免内容变化导致的抖动。
#### Scenario: 数字等宽
- **WHEN** 倒计时数字变化
- **THEN** 容器 SHALL 使用 tabular-nums 字体特性,确保数字等宽不抖动
#### Scenario: 格式切换不抖动
- **WHEN** 倒计时在"秒"和"分秒"格式间切换
- **THEN** 容器 SHALL 使用 min-width 确保最小宽度,避免 RadioGroup 位移

View File

@@ -1,46 +0,0 @@
## Purpose
TBD - 统一服务启动引导函数,封装开发和生产模式的完整启动序列。
## Requirements
### Requirement: 统一启动引导函数
系统 SHALL 提供 `src/server/bootstrap.ts` 导出 `bootstrap(options: BootstrapOptions)` 函数,封装完整的服务启动序列:加载配置、创建 store、同步 targets、创建并启动 engine、启动 HTTP server、注册 shutdown handler。
#### Scenario: 开发模式启动
- **WHEN** `dev.ts` 调用 `bootstrap({ configPath, mode: "development" })`
- **THEN** 系统 SHALL 完成完整启动序列,不传入 staticAssets
#### Scenario: 生产模式启动(带静态资源)
- **WHEN** code-generated entry 调用 `bootstrap({ configPath, mode: "production", staticAssets })`
- **THEN** 系统 SHALL 完成完整启动序列,并将 staticAssets 传递给 startServer
#### Scenario: 启动失败处理
- **WHEN** 启动过程中任何步骤抛出异常
- **THEN** 系统 SHALL 输出错误信息并以非零退出码退出进程
#### Scenario: 优雅关机
- **WHEN** 进程收到 SIGINT 或 SIGTERM 信号
- **THEN** bootstrap 注册的 shutdown handler SHALL 调用 engine.stop() 和 store.close() 后退出
### Requirement: BootstrapOptions 接口
`bootstrap` 函数 SHALL 接受 `BootstrapOptions` 参数,包含 `configPath: string``mode: RuntimeMode` 和可选的 `staticAssets?: StaticAssets`
#### Scenario: 最小配置(开发模式)
- **WHEN** 仅传入 configPath 和 mode
- **THEN** 系统 SHALL 正常启动startServer 不接收 staticAssets 参数
#### Scenario: 生产模式配置
- **WHEN** 传入 configPath、mode 和 staticAssets
- **THEN** 系统 SHALL 将 staticAssets 传递给 startServer
### Requirement: dev.ts 和生产入口使用 bootstrap
`dev.ts``src/server/main.ts` SHALL 调用 `bootstrap()` 而非各自维护启动序列。
#### Scenario: dev.ts 调用 bootstrap
- **WHEN** 开发者运行 `bun run dev`
- **THEN** `dev.ts` SHALL 调用 `bootstrap` 完成启动
#### Scenario: main.ts 调用 bootstrap
- **WHEN** 生产可执行文件启动
- **THEN** `main.ts` SHALL 调用 `bootstrap` 完成启动

View File

@@ -1,68 +0,0 @@
## Purpose
定义将 Vite 构建的前端资源通过 code generation 嵌入 Bun 后端,打包为单个 standalone executable 的生产构建、运行配置和验证要求。
## Requirements
### Requirement: 生产构建顺序
生产构建 MUST 通过三步流水线完成Vite 前端构建 → code generation → Bun compile。
#### Scenario: 运行生产构建
- **WHEN** 开发者运行生产构建命令
- **THEN** 系统 MUST 依次执行 Vite build、资源导入 code generation、Bun.build compile最终输出单可执行文件
#### Scenario: Vite 构建失败
- **WHEN** Vite build 步骤失败
- **THEN** 系统 MUST 停止后续步骤,不生成 code generation 文件或 executable
#### Scenario: Bun compile 失败
- **WHEN** Bun.build compile 步骤失败
- **THEN** 系统 MUST 清理 `.build/` 临时目录,不保留 stale executable
### Requirement: 单 executable 输出
生产构建 SHALL 输出一个 standalone executable其中包含 Bun 后端和通过 `import with { type: "file" }` 嵌入的 Vite 前端产出。
#### Scenario: 在目标机器运行 executable
- **WHEN** 生成的 executable 在兼容目标平台上运行
- **THEN** 它 SHALL 启动全栈应用,且不要求目标机器安装 Node.js、Bun 或 `node_modules`
#### Scenario: 服务嵌入的前端
- **WHEN** executable 收到前端根路径请求
- **THEN** 它 SHALL 通过内嵌的 Vite 构建产出服务前端资源,且不需要外部 `dist/` 目录
#### Scenario: 服务嵌入 API 和页面
- **WHEN** 生成的 executable 启动,且浏览器打开前端根路径
- **THEN** 页面 SHALL 展示同一个 executable 进程中 `/api/summary``/api/targets` 返回的数据
### Requirement: 构建中间产物管理
构建流程 SHALL 使用 `.build/` 临时目录存放 code generation 产物,构建完成后清理。
#### Scenario: 构建成功后清理中间产物
- **WHEN** 生产构建成功完成并输出 executable
- **THEN** 系统 SHALL 删除 `.build/` 临时目录
#### Scenario: 构建失败时清理中间产物
- **WHEN** 生产构建在 Bun compile 步骤失败
- **THEN** 系统 SHALL 删除 `.build/` 临时目录和 stale executable
### Requirement: 外部运行时配置
executable MUST 将环境相关运行时配置保留在嵌入的前端和 server bundle 之外。
#### Scenario: 修改监听端口
- **WHEN** 操作者修改受支持的 port 配置
- **THEN** 同一个 executable SHALL 在不重新构建的情况下监听新端口
#### Scenario: 缺少可选配置
- **WHEN** 可选运行时配置被省略
- **THEN** executable SHALL 使用文档化的默认值
### Requirement: 构建验证
项目 SHALL 提供 `verify` 命令执行质量检查和生产构建;原 smoke test 暂时移除executable 路由验证由后续变更重新设计。
#### Scenario: 完整验证重新构建 executable
- **WHEN** 开发者运行完整验证命令
- **THEN** 系统 MUST 先执行质量检查,再基于当前源码执行生产构建
#### Scenario: 验证失败
- **WHEN** 质量检查或构建阶段失败
- **THEN** 验证 SHALL 使命令失败

View File

@@ -1,58 +0,0 @@
# Static Asset Embedding
定义构建时将 Vite 产出的前端静态资源嵌入 Bun 可执行文件的 code generation 流程和运行时静态资源服务逻辑。
## Purpose
支持将 Vite 构建的前端资源通过 `import with { type: "file" }` 嵌入 Bun 可执行文件,实现单文件交付的同时保持正确的缓存策略和 Content-Type 处理。
## Requirements
### Requirement: 构建时资源扫描与 Code Generation
构建脚本 SHALL 在 Vite build 完成后扫描 `dist/web/` 目录,自动生成 TypeScript 文件,为每个静态资源创建 `import ... with { type: "file" }` 声明。
#### Scenario: 生成资源导入文件
- **WHEN** 构建脚本扫描 `dist/web/` 目录
- **THEN** 系统 SHALL 在 `.build/static-assets.ts` 中为每个文件生成 `import fN from "<path>" with { type: "file" }` 语句,并导出 `StaticAssets` 对象
#### Scenario: StaticAssets 对象结构
- **WHEN** `static-assets.ts` 被生成
- **THEN** 导出的对象 SHALL 包含 `indexHtml: Blob``files: Record<string, Blob>` 两个字段,其中 files 的 key 为 URL 路径(如 `/assets/index-a1b2c3.js`
#### Scenario: 生成 production server entry
- **WHEN** 构建脚本生成资源导入文件后
- **THEN** 系统 SHALL 在 `.build/server-entry.ts` 中生成 production 入口import bootstrap、config 和 staticAssets 并调用 bootstrap
### Requirement: 运行时静态资源服务
系统 SHALL 提供 `serveStaticAsset` 函数,根据请求路径从 StaticAssets 中查找并返回对应资源。
#### Scenario: 请求根路径
- **WHEN** 请求路径为 `/`
- **THEN** 系统 SHALL 返回 `indexHtml`Content-Type 为 `text/html; charset=utf-8`Cache-Control 为 `no-cache`
#### Scenario: 请求已知静态资源
- **WHEN** 请求路径匹配 `files` 中的某个 key
- **THEN** 系统 SHALL 返回对应 BlobContent-Type 根据文件扩展名推断Cache-Control 为 `public, max-age=31536000, immutable`
#### Scenario: 请求未知带扩展名路径
- **WHEN** 请求路径包含文件扩展名但未匹配任何已知资源
- **THEN** 系统 SHALL 返回 404 响应
#### Scenario: SPA Fallback
- **WHEN** 请求路径不包含文件扩展名且不以 `/api/` 开头
- **THEN** 系统 SHALL 返回 `indexHtml`SPA fallback
### Requirement: Content-Type 推断
系统 SHALL 根据文件扩展名推断正确的 Content-Type header。
#### Scenario: JavaScript 文件
- **WHEN** 请求路径以 `.js``.mjs` 结尾
- **THEN** Content-Type SHALL 为 `text/javascript; charset=utf-8`
#### Scenario: CSS 文件
- **WHEN** 请求路径以 `.css` 结尾
- **THEN** Content-Type SHALL 为 `text/css; charset=utf-8`
#### Scenario: SVG 文件
- **WHEN** 请求路径以 `.svg` 结尾
- **THEN** Content-Type SHALL 为 `image/svg+xml`

View File

@@ -1,125 +0,0 @@
## Purpose
定义 TanStack Query 数据层QueryClient 配置、queryKey 工厂、轮询策略、条件查询和开发调试面板。
## Requirements
### Requirement: TanStack Query 数据层
前端 SHALL 使用 TanStack Query@tanstack/react-query管理所有 API 请求,数据层代码 SHALL 按职责拆分为独立 hook 文件。
#### Scenario: QueryClient 配置
- **WHEN** 应用启动
- **THEN** 系统 SHALL 创建 QueryClient默认配置 retry=1、refetchOnWindowFocus=true、staleTime=5000
#### Scenario: QueryClientProvider 挂载
- **WHEN** 应用渲染
- **THEN** 根组件 SHALL 包裹在 QueryClientProvider 中,提供 QueryClient 实例
### Requirement: queryKey 工厂
系统 SHALL 提供统一的 queryKey 工厂函数,确保 queryKey 的唯一性和一致性。
#### Scenario: summary queryKey
- **WHEN** 查询 summary 数据
- **THEN** queryKey SHALL 为 ["summary"]
#### Scenario: targets queryKey
- **WHEN** 查询 targets 数据
- **THEN** queryKey SHALL 为 ["targets"]
#### Scenario: trend queryKey
- **WHEN** 查询某目标的趋势数据
- **THEN** queryKey SHALL 为 ["trend", targetId, from, to]
#### Scenario: history queryKey
- **WHEN** 查询某目标的历史记录
- **THEN** queryKey SHALL 为 ["history", targetId, from, to, page]
#### Scenario: meta queryKey
- **WHEN** 查询 meta 数据
- **THEN** queryKey SHALL 为 ["meta"]
### Requirement: Meta 查询
系统 SHALL 提供 `useMeta` hook 查询系统元数据。
#### Scenario: meta 查询配置
- **WHEN** 应用启动
- **THEN** `useMeta` SHALL 请求 `/api/meta`,配置 `staleTime: Infinity`(应用生命周期内只请求一次)
#### Scenario: meta 数据返回
- **WHEN** meta 查询成功
- **THEN** hook SHALL 返回 `MetaResponse` 类型数据,包含 `checkerTypes` 字段
### Requirement: Hook 文件拆分
数据层 hook SHALL 按职责拆分为独立文件。
#### Scenario: 全局查询 hook 文件
- **WHEN** 开发者需要使用全局面板级查询
- **THEN** `useSummary``useTargets``useMeta` SHALL 从 `hooks/use-queries.ts` 导出
#### Scenario: Drawer 状态 hook 文件
- **WHEN** 开发者需要使用 Drawer 状态管理
- **THEN** `useTargetDetail` SHALL 从 `hooks/use-target-detail.ts` 导出
#### Scenario: fetchJson 不导出
- **WHEN** 数据层内部需要 fetch 封装
- **THEN** `fetchJson` SHALL 定义在 `use-queries.ts` 内部,不作为公共 API 导出
#### Scenario: queryKeys 不导出
- **WHEN** 数据层内部需要 query key
- **THEN** `queryKeys` 对象 SHALL 定义在 `use-queries.ts` 内部,不作为公共 API 导出
### Requirement: Summary 轮询查询
系统 SHALL 使用 useQuery 实现总览统计的自动轮询。
#### Scenario: summary 动态轮询间隔
- **WHEN** Dashboard 页面处于打开状态
- **THEN** 系统 SHALL 按用户选择的刷新间隔自动请求数据,`useDashboard` hook SHALL 接受 `refetchInterval` 参数(`false | number`),由调用方传入
#### Scenario: summary 禁用自动轮询
- **WHEN** 用户选择"手动"刷新模式
- **THEN** `useDashboard` SHALL 接收 `refetchInterval: false`,禁用自动轮询
#### Scenario: summary 后台刷新
- **WHEN** 页面处于后台标签页
- **THEN** 系统 SHALL 暂停轮询refetchIntervalInBackground=false
### Requirement: Targets 轮询查询
系统 SHALL 使用 useQuery 实现目标列表的自动轮询。
#### Scenario: targets 动态轮询间隔
- **WHEN** Dashboard 页面处于打开状态
- **THEN** 系统 SHALL 按用户选择的刷新间隔自动请求数据,轮询间隔与 summary 查询保持一致
### Requirement: 条件查询
详情指标和历史记录查询 SHALL 使用 enabled 条件控制。指标查询 SHALL 在目标和时间范围有效时触发;历史记录查询 SHALL 仅在目标、时间范围有效且"记录"Tab 激活后触发。
#### Scenario: 未选中目标时不请求
- **WHEN** 用户未点击任何目标表格行
- **THEN** metrics 和 history 的 useQuery SHALL enabled=false不发起请求
#### Scenario: 打开 Drawer 默认只请求指标
- **WHEN** 用户点击目标表格行并打开 Drawer
- **THEN** metrics 的 useQuery SHALL enabled=true 并自动发起请求history 的 useQuery SHALL enabled=false 且不发起请求
#### Scenario: 激活记录 Tab 时请求历史记录
- **WHEN** 用户切换到"记录"Tab 且目标与时间范围有效
- **THEN** history 的 useQuery SHALL enabled=true并请求当前页码对应的 `/api/targets/:id/history` 数据
#### Scenario: 概览 Tab 时间范围变化时不请求历史记录
- **WHEN** 用户在"概览"Tab 修改时间范围
- **THEN** metrics 的 useQuery SHALL 因 queryKey 变化自动重新请求history 的 useQuery SHALL 保持 enabled=false 且不发起请求
#### Scenario: 记录 Tab 时间范围变化时重新请求历史记录
- **WHEN** 用户在"记录"Tab 修改时间范围
- **THEN** metrics 和 history 的 useQuery SHALL 因 queryKey 变化自动重新请求,并将 history 页码重置为 1
### Requirement: 开发调试面板
开发环境下 SHALL 挂载 TanStack Query Devtools。
#### Scenario: 开发环境显示 Devtools
- **WHEN** 应用在开发模式下运行
- **THEN** 页面 SHALL 显示 ReactQueryDevtools 浮动面板
#### Scenario: 生产环境排除 Devtools
- **WHEN** 应用在生产模式下构建
- **THEN** ReactQueryDevtools SHALL 不被包含在产物中

View File

@@ -1,294 +0,0 @@
## Purpose
定义目标详情 Drawer时间范围筛选TDesign RadioGroup + DateRangePicker 单行布局含快捷按钮联动概览和记录面板、Tabs 组织概览/记录两个面板、Metrics 数据查询 Hook、多维度统计图表4×2 Card 布局)和分页检查结果列表。
## Requirements
### Requirement: 目标详情 Drawer
Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer展示该目标的详细统计信息和检查记录。Drawer 标题栏和内容不使用内联 style。Drawer 内容 SHALL 拆分为独立的 Tab 组件,并优先通过 TDesign Drawer 原生生命周期能力控制显示、隐藏和滚动穿透。
#### Scenario: 打开 Drawer
- **WHEN** 用户点击某个目标表格行
- **THEN** 系统 SHALL 从右侧滑出 Drawerplacement="right"),宽度为 52%,并将当前 Tab 重置为"概览"
#### Scenario: Drawer 标题栏
- **WHEN** Drawer 渲染
- **THEN** 标题栏 SHALL 使用 TDesign Space 组件align="center")布局,包含 StatusDot、目标名称TDesign Typography.Text strong和类型标签TDesign Tag直接显示 target.type 原始文本),以及内建关闭按钮。不使用内联 style 的 flex 布局
#### Scenario: 关闭 Drawer
- **WHEN** 用户点击关闭按钮、ESC 键或遮罩层
- **THEN** Drawer SHALL 关闭并通过 TDesign Drawer 的 `visible` 状态隐藏
#### Scenario: Drawer 无底部按钮
- **WHEN** Drawer 渲染
- **THEN** Drawer SHALL 不显示底部操作栏footer={false}
#### Scenario: Drawer 数据同步
- **WHEN** Drawer 打开期间后台轮询刷新了 targets 数据
- **THEN** Drawer 中 selectedTarget 的状态 SHALL 随之同步更新
#### Scenario: 切换目标重置 Tab
- **WHEN** 用户从目标 A 切换到目标 B点击不同的表格行
- **THEN** Drawer SHALL 通过受控 Tab 状态重置为概览 Tab且 MUST NOT 使用 `key={target.id}` 强制重建 Drawer 子树
#### Scenario: Drawer 内容区间距
- **WHEN** Drawer 内容渲染
- **THEN** 时间选择器、Tabs 等区块之间的间距 SHALL 通过 TDesign Space 组件direction="vertical", size={16})统一管理,不使用内联 style 的 marginBottom
### Requirement: 概览面板组件化
概览 Tab SHALL 作为独立组件 `OverviewTab` 实现,展示基本信息、多维度统计(左右布局卡片)和趋势图。不再包含状态分布环形图。
#### Scenario: OverviewTab 组件职责
- **WHEN** 概览 Tab 渲染
- **THEN** `OverviewTab` 组件 SHALL 负责基本信息(直接展示 Descriptions、多维度统计卡片4×2 左右布局)和趋势图的渲染
#### Scenario: OverviewTab props
- **WHEN** OverviewTab 渲染
- **THEN** 组件 SHALL 接收 `target: TargetStatus``metricsData: TargetMetricsResponse | null``metricsLoading: boolean` 作为 props
### Requirement: 记录面板组件化
记录 Tab SHALL 作为独立组件 `HistoryTab` 实现。
#### Scenario: HistoryTab 组件职责
- **WHEN** 记录 Tab 渲染
- **THEN** `HistoryTab` 组件 SHALL 负责检查结果表格和分页的渲染
#### Scenario: HistoryTab props
- **WHEN** HistoryTab 渲染
- **THEN** 组件 SHALL 接收 `historyData: HistoryResponse``historyLoading: boolean``onPageChange: (page: number) => void` 作为 props
#### Scenario: 历史记录列定义外置
- **WHEN** HistoryTab 渲染表格
- **THEN** 列定义 SHALL 从 `constants/history-table-columns.tsx` 导入,不在组件内部定义
### Requirement: TrendChart 简化
TrendChart 组件 SHALL 仅接收数据 props不处理 loading 状态。
#### Scenario: TrendChart 无 loading prop
- **WHEN** TrendChart 渲染
- **THEN** 组件 SHALL 仅接收 `data: TrendPoint[]` prop不接收 `loading` prop
#### Scenario: TrendChart 空数据
- **WHEN** TrendChart 接收空数组
- **THEN** 组件 SHALL 显示"暂无趋势数据"占位文本
#### Scenario: TrendChart memo 包裹
- **WHEN** TrendChart 的父组件重渲染但 data prop 引用未变
- **THEN** TrendChart SHALL 跳过重渲染(通过 React.memo shallow compare
#### Scenario: chartData useMemo
- **WHEN** TrendChart 渲染
- **THEN** 内部 `chartData` 转换结果 SHALL 通过 `useMemo` 缓存,依赖为 `[data]`data 引用不变时不重新计算
### Requirement: TargetBoard 分组 memoize
TargetBoard 组件的分组计算 SHALL 使用 useMemo 缓存,避免 targets 引用不变时重复计算分组。
#### Scenario: 分组结果 useMemo
- **WHEN** TargetBoard 渲染
- **THEN** 分组逻辑Map 构建 + sortSHALL 通过 `useMemo` 缓存,依赖为 `[targets]`
#### Scenario: targets 引用不变时跳过分组
- **WHEN** TargetBoard 因父组件重渲染而重渲染,但 targets prop 引用未变
- **THEN** 分组计算 SHALL 返回缓存结果,不重新执行 Map 构建和排序
### Requirement: TargetGroup 渲染优化
TargetGroup 组件 SHALL 使用 React.memo 包裹,在 props 引用不变时跳过重渲染。
#### Scenario: TargetGroup memo 包裹
- **WHEN** TargetBoard 重渲染但某个分组的 targets 数组引用未变
- **THEN** 对应的 TargetGroup SHALL 跳过重渲染(通过 React.memo shallow compare
#### Scenario: TargetGroup props 稳定性
- **WHEN** TargetGroup 渲染
- **THEN** 其 propscolumns、name、targets、onTargetClickSHALL 全部具有引用稳定性columns 通过 useMemo、name 为 string 原始值、targets 通过分组 useMemo、onTargetClick 通过 useCallback
### Requirement: StatusBar 参数化
StatusBar 组件 SHALL 支持可配置的格数。
#### Scenario: maxSlots prop
- **WHEN** StatusBar 渲染
- **THEN** 组件 SHALL 接收可选的 `maxSlots` prop默认 30根据该值渲染对应数量的格子
#### Scenario: 格子渲染逻辑
- **WHEN** StatusBar 渲染且 samples 数量少于 maxSlots
- **THEN** 多余的格子 SHALL 显示为 empty 状态
### Requirement: Metrics 数据查询 Hook
系统 SHALL 提供 `useTargetMetrics` hook 查询单目标指标数据。
#### Scenario: metrics queryKey
- **WHEN** 查询某目标的指标数据
- **THEN** queryKey SHALL 为 ["metrics", targetId, from, to, bucket]
#### Scenario: metrics 条件查询
- **WHEN** 用户未选中任何目标
- **THEN** metrics 的 useQuery SHALL enabled=false不发起请求
#### Scenario: metrics 数据返回
- **WHEN** metrics 查询成功
- **THEN** hook SHALL 返回 `TargetMetricsResponse` 类型数据
#### Scenario: 时间范围变化时重新请求
- **WHEN** 用户更改时间范围
- **THEN** metrics 的 useQuery SHALL 因 queryKey 变化自动重新请求
#### Scenario: Drawer 关闭清理查询缓存
- **WHEN** 用户关闭 Drawer
- **THEN** 系统 MAY 保留 metrics 和 history 查询缓存以降低重复打开成本,依赖 TanStack Query 全局 staleTime 自动管理过期
### Requirement: 时间范围选择器
Drawer SHALL 在 Tabs 外层提供时间范围选择器,影响概览面板的数据;当记录面板处于激活状态或后续首次进入记录面板时,时间范围也 SHALL 影响记录面板的数据。
#### Scenario: 快捷时间按钮
- **WHEN** Drawer 渲染
- **THEN** 时间选择区第一行 SHALL 显示 TDesign RadioGroupvariant=default-filled快捷按钮1小时、6小时、24小时、7天
#### Scenario: 点击快捷按钮
- **WHEN** 用户点击快捷按钮(如 "24小时"
- **THEN** 系统 SHALL 自动设置对应的起止时间DateRangePicker 显示对应的时间范围,该按钮高亮
#### Scenario: 快捷按钮联动统计区
- **WHEN** 用户点击 1小时/6小时/24小时/7天 快捷按钮
- **THEN** 概览面板 SHALL 使用对应的时间窗口重新请求 `/api/targets/:id/metrics` 数据
#### Scenario: 快捷按钮联动激活的历史记录
- **WHEN** 用户在"记录"Tab 激活时点击 1小时/6小时/24小时/7天 快捷按钮
- **THEN** 记录面板 SHALL 使用对应的时间窗口重新请求 `/api/targets/:id/history` 数据,并重置页码为 1
#### Scenario: 快捷按钮不预取未激活历史记录
- **WHEN** 用户在"概览"Tab 激活时点击 1小时/6小时/24小时/7天 快捷按钮
- **THEN** 系统 SHALL NOT 请求 `/api/targets/:id/history`,直到用户切换到"记录"Tab
#### Scenario: 自定义日期时间范围
- **WHEN** 用户通过 TDesign DateRangePickermode=date, enableTimePicker, format="YYYY-MM-DD HH:mm")修改时间范围
- **THEN** 快捷按钮 SHALL 取消高亮,系统 SHALL 按新的时间范围刷新概览数据,并按当前 Tab 状态决定是否刷新历史记录
#### Scenario: 时间精度为分钟级
- **WHEN** 用户通过 DateRangePicker 选择时间
- **THEN** 选择器 SHALL 仅精确到分钟format="YYYY-MM-DD HH:mm"),秒列固定为 00
#### Scenario: DateRangePicker 自适应显示
- **WHEN** Drawer 渲染
- **THEN** DateRangePicker SHALL 通过 CSS 类 `.drawer-date-range`(替代原 `.full-width`)自适应填充时间选择区剩余宽度,不使用内联 style 的 width: 100%
#### Scenario: 默认时间范围
- **WHEN** Drawer 打开
- **THEN** 时间选择器 SHALL 默认选中 "24小时" 快捷按钮
#### Scenario: 筛选触发数据刷新
- **WHEN** 时间范围发生变化
- **THEN** 系统 SHALL 重新请求趋势数据;若"记录"Tab 当前激活,系统 SHALL 同时重新请求历史记录,否则 SHALL 延迟到用户进入"记录"Tab 后请求历史记录
### Requirement: Tabs 内容组织
Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。Tabs SHALL 使用受控 value 管理当前激活 TabTabPanel 内边距通过 className prop 控制。
#### Scenario: Tab 标签
- **WHEN** Drawer 渲染
- **THEN** Tabs SHALL 显示两个标签:概览、记录
#### Scenario: Tabs 受控状态
- **WHEN** 用户切换 Tab
- **THEN** Tabs SHALL 通过 `value``onChange` 更新由 Drawer 状态 hook 管理的当前 Tab 值
#### Scenario: 默认概览 Tab
- **WHEN** Drawer 打开或切换到另一个目标
- **THEN** 当前 Tab SHALL 重置为 `overview`
#### Scenario: Tab 面板内边距
- **WHEN** TabPanel 渲染
- **THEN** TabPanel SHALL 通过 `className` prop 传入自定义类名(`tab-panel-padded`)控制内边距,不通过入侵 TDesign 内部类名(`.t-tab-panel`)覆盖
#### Scenario: TabPanel 懒渲染与缓存
- **WHEN** 用户在概览和记录 Tab 之间切换
- **THEN** 概览和记录 TabPanel 均 SHALL 配置 TDesign TabPanel 的 `destroyOnHide={false}`,隐藏时不销毁组件,保留已挂载的面板状态和已加载的数据;记录 TabPanel SHALL 额外配置懒渲染,首次进入前不渲染 HistoryTab
### Requirement: 概览面板
概览 Tab SHALL 按区域展示基本信息、多维度统计和趋势图。
#### Scenario: 区域排列顺序
- **WHEN** 概览面板渲染
- **THEN** 面板 SHALL 按以下顺序展示区域:基本信息 → 统计 → 趋势,每个区域前 SHALL 显示 TDesign Divideralign="left")作为小标题
#### Scenario: 基本信息直接展示
- **WHEN** 概览面板渲染
- **THEN** 面板 SHALL 在"基本信息"区域直接使用 TDesign Descriptions 组件展示配置信息(不折叠)
#### Scenario: 基本信息内容
- **WHEN** 概览面板渲染
- **THEN** Descriptions SHALL 展示:目标地址、检查间隔、最新检查时间、状态详情
#### Scenario: 统计区左右布局卡片
- **WHEN** 概览面板渲染且有统计数据
- **THEN** 面板 SHALL 在"统计"区域使用 4 列 × 2 行的 Row/Col 布局,每个统计项使用 `<div className="overview-stat-card">` 包裹,通过 CSS 类实现背景色和内边距视觉效果
#### Scenario: 统计区内容
- **WHEN** 概览面板渲染
- **THEN** 统计区 SHALL 展示可用率suffix="%"、平均延迟suffix="ms"、P95 延迟suffix="ms"、检查总数、MTTR动态单位、最长故障动态单位、故障次数suffix="次"、连续正常suffix="次"
#### Scenario: 趋势图
- **WHEN** 概览面板渲染且 metricsData.trend 可用
- **THEN** 面板 SHALL 在"趋势"区域展示 TrendChart 组件
#### Scenario: 统计区加载状态
- **WHEN** metricsData 正在加载
- **THEN** 统计区 SHALL 显示 TDesign Skeleton 加载占位
#### Scenario: 统计区无数据
- **WHEN** metricsData 为 null 且未处于加载状态
- **THEN** 统计区 SHALL 展示占位状态
#### Scenario: 趋势数据加载中
- **WHEN** metricsData 正在加载
- **THEN** "趋势"区域 SHALL 显示 TDesign Skeleton 加载占位
### Requirement: Drawer TDesign 原生生命周期与滚动控制
目标详情 Drawer SHALL 优先使用 TDesign Drawer 的原生 props 控制挂载、可见性和滚动穿透,不通过自定义滚轮事件实现滚动控制。
#### Scenario: Drawer 常驻受控渲染
- **WHEN** 未选中目标时
- **THEN** `TargetDetailDrawer` SHALL 保留 TDesign Drawer 组件并通过 `visible=false` 隐藏,而不是直接返回 `null` 卸载 Drawer 子树
#### Scenario: Drawer 防滚动穿透配置
- **WHEN** Drawer 渲染
- **THEN** Drawer SHALL 显式使用 `attach="body"``preventScrollThrough=true``showInAttachedElement=false``showOverlay=true`
#### Scenario: Drawer 关闭后保留子树
- **WHEN** 用户关闭 Drawer
- **THEN** Drawer SHALL 使用 `destroyOnClose=false` 保留已挂载内容,避免重复打开时重建完整子树
#### Scenario: Drawer 单一纵向滚动容器
- **WHEN** Drawer 内容高度超过可视区域
- **THEN** 系统 SHALL 依赖 Drawer 内容区域作为唯一纵向滚动容器HistoryTab 中的 PrimaryTable SHALL 不配置 `height``maxHeight` 或纵向 `scroll` 来创建第二个纵向滚动区域
### Requirement: Drawer 宽度
Drawer 宽度 SHALL 设置为 52%。
#### Scenario: Drawer 宽度
- **WHEN** Drawer 打开
- **THEN** Drawer size SHALL 为 "52%"
### Requirement: 时间选择器单行布局
Drawer 顶部的时间范围快捷按钮和日期范围选择器 SHALL 在同一行展示。
#### Scenario: 单行布局
- **WHEN** Drawer 渲染时间选择区域
- **THEN** RadioGroup 和 DateRangePicker SHALL 使用 flex 布局在同一行水平排列
### Requirement: 记录面板
记录 Tab SHALL 展示分页检查结果列表,使用 TDesign PrimaryTable。
#### Scenario: 检查结果表格
- **WHEN** 记录面板渲染且数据可用
- **THEN** 面板 SHALL 使用 TDesign PrimaryTable 展示检查结果列包含状态StatusDot 圆点、时间YYYY-MM-DD HH:mm:ss 格式)、耗时(标题含 ms 单位单元格仅显示数值居中对齐、详情statusDetail 和 failure.message 用冒号拼接)
#### Scenario: 服务端分页
- **WHEN** 检查结果总数超过一页
- **THEN** 表格 SHALL 使用内建 paginationdisableDataPage=true分页器显示在表格底部
#### Scenario: 翻页触发请求
- **WHEN** 用户切换分页页码
- **THEN** 系统 SHALL 请求对应页码的服务端数据,表格更新
#### Scenario: 记录数据加载中
- **WHEN** 历史记录正在加载
- **THEN** 表格 SHALL 显示 loading 状态

View File

@@ -1,45 +0,0 @@
## Purpose
定义 target 分组能力YAML 配置中的 group 字段、后端存储、API 传递和前端分组排序。
## Requirements
### Requirement: target 分组配置
系统 SHALL 支持在每个 target 上配置可选的 `group` 字段,用于将目标归类到不同分组。未指定 `group` 的 target SHALL 归入 `"default"` 分组。
#### Scenario: 配置分组名称
- **WHEN** YAML 配置中某个 target 指定 `group: "搜索引擎"`
- **THEN** 系统 SHALL 将该 target 归类到 "搜索引擎" 分组
#### Scenario: 不配置分组
- **WHEN** YAML 配置中某个 target 未指定 `group` 字段
- **THEN** 系统 SHALL 将该 target 归类到 "default" 分组
#### Scenario: group 字段类型校验
- **WHEN** YAML 配置中某个 target 的 `group` 字段不是字符串
- **THEN** 系统 SHALL 以错误退出并提示 group 字段类型错误
### Requirement: 分组排序
系统 SHALL 保证 "default" 分组始终排在最前面,其余分组按配置文件中首次出现的顺序排列。
#### Scenario: default 分组排最前
- **WHEN** 配置中存在多个分组(包括 "default" 和自定义分组)
- **THEN** API 返回的目标列表中 "default" 分组的目标 SHALL 排在其他分组之前
#### Scenario: 自定义分组按出现顺序
- **WHEN** 配置中 "搜索引擎" 分组在 "后端服务" 分组之前首次出现
- **THEN** API 返回中 "搜索引擎" 分组 SHALL 排在 "后端服务" 分组之前
### Requirement: 分组信息 API 传递
系统 SHALL 在 API 响应中返回每个 target 的分组信息。
#### Scenario: targets 列表包含分组
- **WHEN** 客户端请求 `GET /api/targets`
- **THEN** 响应中每个目标 SHALL 包含 `group` 字段,值为该目标所属的分组名称
### Requirement: 分组存储
系统 SHALL 在数据库 targets 表中持久化每个 target 的分组信息。
#### Scenario: 持久化分组信息
- **WHEN** 系统同步 targets 到数据库
- **THEN** 每个 target 的 `grp` 列 SHALL 存储其分组名称,未配置分组的存储 `"default"`

View File

@@ -1,164 +0,0 @@
## Purpose
定义单目标指标 API 的端点、响应类型、延迟百分位计算、MTTR 计算、最长故障时长计算、故障事件计数、当前连续状态、趋势数据应用层分桶和无数据口径。
## Requirements
### Requirement: 单目标指标 API
系统 SHALL 提供 `GET /api/targets/:id/metrics` 端点,返回单个目标在指定时间窗口内的概览统计和趋势数据。
#### Scenario: 获取目标指标
- **WHEN** 客户端请求 `GET /api/targets/1/metrics?from=ISO&to=ISO&bucket=1h`
- **THEN** 系统 SHALL 返回 JSON 对象包含 targetId、window、stats、trend 字段
#### Scenario: from 或 to 参数缺失
- **WHEN** 客户端请求 `GET /api/targets/1/metrics` 未提供 from 或 to 参数
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
#### Scenario: 目标不存在
- **WHEN** 客户端请求 `GET /api/targets/999/metrics?from=ISO&to=ISO`
- **THEN** 系统 SHALL 返回 404 状态码和错误信息
#### Scenario: 无效的目标 ID
- **WHEN** 客户端请求 `GET /api/targets/abc/metrics?from=ISO&to=ISO`
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
#### Scenario: bucket 参数缺失
- **WHEN** 客户端请求 `GET /api/targets/1/metrics?from=ISO&to=ISO` 未提供 bucket 参数
- **THEN** 系统 SHALL 默认使用 bucket=`1h`
#### Scenario: 不支持的 bucket 参数
- **WHEN** 客户端请求 `GET /api/targets/1/metrics?from=ISO&to=ISO&bucket=5m`
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
### Requirement: TargetMetricsResponse 共享类型
系统 SHALL 在 `src/shared/api.ts` 中定义 `TargetMetricsResponse` 类型。
#### Scenario: 类型定义
- **WHEN** 前后端引用 `TargetMetricsResponse` 类型
- **THEN** 该类型 SHALL 包含 targetIdnumber、windowfrom/to/bucket、stats 和 trend 字段
#### Scenario: stats 字段
- **WHEN** metrics 响应包含 stats
- **THEN** stats SHALL 包含 totalChecks、upChecks、downChecks、availability、avgDurationMs、p95DurationMs、p99DurationMs、mttr、longestOutage、incidentCount、currentStreak 字段
#### Scenario: trend 字段
- **WHEN** metrics 响应包含 trend
- **THEN** trend SHALL 为数组,每个元素包含 bucketStart、avgDurationMs、minDurationMs、maxDurationMs、availability、totalChecks、upChecks、downChecks 字段
### Requirement: P95/P99 延迟计算
系统 SHALL 在后端应用层计算 P95 和 P99 延迟百分位数。
#### Scenario: 正常计算 P95
- **WHEN** 时间窗口内存在成功检查记录matched=1 且 duration_ms 不为 null
- **THEN** 系统 SHALL 取出所有成功检查的 duration_ms在后端应用层排序后取第 95 百分位值返回为 p95DurationMs
#### Scenario: 正常计算 P99
- **WHEN** 时间窗口内存在成功检查记录
- **THEN** 系统 SHALL 取第 99 百分位值返回为 p99DurationMs
#### Scenario: 无成功检查记录
- **WHEN** 时间窗口内无 matched=1 且 duration_ms 不为 null 的记录
- **THEN** p95DurationMs 和 p99DurationMs SHALL 返回 null
#### Scenario: 百分位计算方法
- **WHEN** 计算第 N 百分位
- **THEN** 系统 SHALL 将 duration_ms 升序排列,取 index = ceil(count * N / 100) - 1 位置的值
### Requirement: MTTR 计算
系统 SHALL 在后端应用层计算平均恢复时间Mean Time To Recovery
#### Scenario: 存在已恢复的故障段
- **WHEN** 时间窗口内存在至少一个已恢复的故障段(连续 matched=0 后跟 matched=1
- **THEN** 系统 SHALL 计算所有已恢复故障段的平均持续时间(从首个 matched=0 的 timestamp 到恢复后首个 matched=1 的 timestamp 之差),返回为 mttr毫秒
#### Scenario: 无已恢复的故障段
- **WHEN** 时间窗口内无已恢复的故障段(全部正常,或当前仍在故障中且无历史恢复)
- **THEN** mttr SHALL 返回 null
#### Scenario: 当前正在故障中
- **WHEN** 时间窗口内最后一段故障尚未恢复
- **THEN** 该未恢复的故障段 SHALL 不计入 MTTR 平均值
#### Scenario: 窗口起始即为故障且后续恢复
- **WHEN** 时间窗口内第一条记录即为 matched=0故障跨越了 from 边界),且该故障段在窗口内恢复
- **THEN** 该故障段 SHALL 不计入 MTTR 平均值(因无法确定真实故障开始时间),但 SHALL 计入 incidentCount
### Requirement: 最长故障时长
系统 SHALL 在后端应用层计算时间窗口内最长的单次故障持续时间。
#### Scenario: 存在故障段
- **WHEN** 时间窗口内存在故障段
- **THEN** 系统 SHALL 返回最长故障段的持续时间为 longestOutage毫秒
#### Scenario: 无故障
- **WHEN** 时间窗口内无 matched=0 的记录
- **THEN** longestOutage SHALL 返回 null
#### Scenario: 窗口起始即为故障
- **WHEN** 时间窗口内第一条记录即为 matched=0
- **THEN** 该故障段的持续时间 SHALL 从 from 参数开始计算
#### Scenario: 当前正在故障中
- **WHEN** 最后一段故障尚未恢复
- **THEN** 该故障段的持续时间 SHALL 计算为从故障开始到时间窗口 to 参数的时间差
### Requirement: 故障事件计数
系统 SHALL 在后端应用层计算时间窗口内的故障事件次数。
#### Scenario: 计算故障事件数
- **WHEN** 时间窗口内存在状态翻转matched 从 1 变为 0
- **THEN** 系统 SHALL 返回翻转次数为 incidentCount
#### Scenario: 无故障事件
- **WHEN** 时间窗口内所有检查均为 matched=1
- **THEN** incidentCount SHALL 返回 0
#### Scenario: 窗口起始即为故障
- **WHEN** 时间窗口内第一条记录即为 matched=0 且无前序记录可判断翻转
- **THEN** 该故障 SHALL 计为 1 次事件
#### Scenario: 连续异常只计一次
- **WHEN** 某目标连续 10 次 matched=0
- **THEN** 该连续异常段 SHALL 仅计为 1 次事件
### Requirement: 当前连续状态
系统 SHALL 返回目标当前的连续状态信息。
#### Scenario: 当前连续正常
- **WHEN** 目标最近的检查记录连续为 matched=1
- **THEN** currentStreak SHALL 返回 `{ up: true, count: N }`N 为连续正常的检查次数
#### Scenario: 当前连续异常
- **WHEN** 目标最近的检查记录连续为 matched=0
- **THEN** currentStreak SHALL 返回 `{ up: false, count: N }`N 为连续异常的检查次数
#### Scenario: 连续状态达到取数上限
- **WHEN** 连续状态次数达到后端取数或计算上限
- **THEN** currentStreak SHALL 返回 `{ up: boolean, count: N, capped: true }`,前端据此展示上限标记
#### Scenario: 无检查记录
- **WHEN** 目标没有任何检查记录
- **THEN** currentStreak SHALL 返回 null
### Requirement: 趋势数据应用层分桶
系统 SHALL 在后端应用层按 UTC 小时分桶生成趋势数据。
#### Scenario: 按小时生成趋势
- **WHEN** metrics 请求 bucket=`1h`
- **THEN** 系统 SHALL 按 UTC 小时生成 trend 数组,每个点包含该小时内的 totalChecks、upChecks、downChecks、availability、avgDurationMs、minDurationMs、maxDurationMs
#### Scenario: 小时内无成功检查
- **WHEN** 某小时内存在检查记录但无成功检查记录
- **THEN** avgDurationMs、minDurationMs、maxDurationMs SHALL 返回 nullavailability SHALL 基于 upChecks/totalChecks 返回 0
#### Scenario: 小时内无检查记录
- **WHEN** 某小时内没有任何检查记录
- **THEN** 系统 MAY 不返回该小时对应的 trend 点
### Requirement: 无数据口径
系统 SHALL 对无数据窗口返回稳定的空指标口径。
#### Scenario: 窗口内无检查记录
- **WHEN** 指定时间窗口内没有任何检查记录
- **THEN** stats SHALL 返回 totalChecks=0、upChecks=0、downChecks=0、availability=0、avgDurationMs=null、p95DurationMs=null、p99DurationMs=null、mttr=null、longestOutage=null、incidentCount=0、currentStreak=nulltrend SHALL 返回空数组

View File

@@ -1,167 +0,0 @@
## Purpose
定义分组表格的列配置、排序、筛选、行交互和 DOWN 行视觉强化。
## Requirements
### Requirement: 分组表格展示
Dashboard SHALL 按 group 字段将目标分组,每个分组使用 TDesign Card 包裹独立的 PrimaryTable分组间使用 TDesign Space 垂直排列。
#### Scenario: 按分组渲染独立表格
- **WHEN** 用户打开 Dashboard 页面
- **THEN** 页面 SHALL 按 group 字段将目标分组,每个分组使用 TDesign Card 组件包裹Card 内包含一个 PrimaryTable
#### Scenario: 分组 Card 标题
- **WHEN** 页面渲染某个分组
- **THEN** Card 的 `title` prop SHALL 渲染分组名称("default" 显示为 "默认分组"Card 的 `actions` prop SHALL 渲染统计 Tag正常数theme=success, variant=light和异常数theme=danger, variant=light
#### Scenario: 分组 Card 样式
- **WHEN** 页面渲染分组 Card
- **THEN** Card SHALL 设置 `headerBordered` 在标题和表格之间显示分割线
#### Scenario: 分组顺序
- **WHEN** 页面渲染多个分组
- **THEN** "default" 分组 SHALL 排在最上面,其余分组按 YAML 配置中首次出现的顺序排列
#### Scenario: "default" 分组显示名称
- **WHEN** 分组名称为 "default"
- **THEN** Card title SHALL 显示 "默认分组"
#### Scenario: Dashboard 容器最大宽度
- **WHEN** 用户打开 Dashboard 页面
- **THEN** Dashboard 内容区 SHALL 设置 max-width: 1400px 并水平居中
#### Scenario: 分组间统一间距
- **WHEN** 页面渲染多个分组
- **THEN** 分组之间 SHALL 使用 TDesign Space 组件direction=vertical, size=24统一间距
### Requirement: 表格列定义
每个分组的 PrimaryTable SHALL 包含状态、名称、类型、可用率、最近状态条、连续状态、延迟 7 列(不含间隔列)。列渲染不使用内联 style。列定义 SHALL 通过工厂函数动态生成。
#### Scenario: 状态列
- **WHEN** 表格渲染
- **THEN** 状态列 SHALL 使用 StatusDot 组件渲染,标题显示"#",宽度 60pxfixed="left"居中对齐支持筛选UP/DOWN/全部)
#### Scenario: 名称列
- **WHEN** 表格渲染
- **THEN** 名称列 SHALL 显示目标名称支持字母排序zh-CNellipsis 超长名称自动省略并 Tooltip 显示全名
#### Scenario: 类型列
- **WHEN** 表格渲染
- **THEN** 类型列 SHALL 使用 TDesign Tag 组件size=small, theme=primary, variant=light-outline直接显示 target.type 原始文本,支持单选筛选
#### Scenario: 类型筛选器动态生成
- **WHEN** 表格渲染
- **THEN** 类型列的筛选器列表 SHALL 从 meta API 返回的 `checkerTypes` 动态生成,包含"全部"选项和每个 checker 类型选项label 和 value 均为 type 原始文本)
#### Scenario: 可用率列
- **WHEN** 表格渲染
- **THEN** 可用率列标题 SHALL 展示为"可用率(24h)",使用 TDesign Progress 组件theme=line, size=small渲染颜色通过 CSS 自定义属性 `--avail-N` 控制,支持排序
#### Scenario: 最近状态列
- **WHEN** 表格渲染
- **THEN** 最近状态列 SHALL 使用 StatusBar 组件渲染采样色块,宽度 220px
#### Scenario: 连续状态列渲染
- **WHEN** 表格渲染
- **THEN** 表格 SHALL 在「最近状态」列之后、「延迟」列之前展示「连续状态」列,标题为"连续(次)",宽度 88pxTag 内显示方向箭头和数字capped 时追加"+"
#### Scenario: 延迟列
- **WHEN** 表格渲染
- **THEN** 延迟列 SHALL 显示最近一次检查的延迟毫秒数,右对齐,颜色通过 CSS 类控制;超过 9999ms 时 SHALL 显示为"9999+ms"
#### Scenario: 间隔列移除
- **WHEN** 表格渲染
- **THEN** 表格 SHALL 不包含"间隔"列(间隔信息移入 Drawer 基本信息区域)
### Requirement: 列定义工厂函数
列定义 SHALL 通过工厂函数生成,接收动态参数。
#### Scenario: createTargetTableColumns 函数
- **WHEN** 需要生成表格列定义
- **THEN** 系统 SHALL 调用 `createTargetTableColumns(checkerTypes: string[])` 函数,返回 `PrimaryTableCol<TargetStatus>[]`
#### Scenario: checkerTypes 为空数组
- **WHEN** meta API 尚未返回或返回空数组
- **THEN** 类型列的筛选器 SHALL 仅包含"全部"选项
#### Scenario: 列定义缓存
- **WHEN** TargetBoard 组件渲染
- **THEN** 列定义 SHALL 通过 `useMemo` 缓存,仅在 `checkerTypes` 变化时重新生成
### Requirement: TargetGroup 接收 columns prop
TargetGroup 组件 SHALL 通过 prop 接收列定义,不再直接导入静态常量。
#### Scenario: columns prop
- **WHEN** TargetGroup 渲染
- **THEN** 组件 SHALL 接收 `columns: PrimaryTableCol<TargetStatus>[]` prop 并传递给 PrimaryTable
#### Scenario: TargetBoard 传递 columns
- **WHEN** TargetBoard 渲染子组件
- **THEN** TargetBoard SHALL 调用 `createTargetTableColumns` 生成列定义并传递给每个 TargetGroup
### Requirement: 默认排序
表格 SHALL 默认按状态降序排列异常DOWN目标排在最前面。
#### Scenario: 页面初始排序
- **WHEN** 用户打开 Dashboard 页面
- **THEN** 每个分组表格 SHALL 默认按状态降序排列DOWN 目标排在同组最前面
### Requirement: DOWN 行视觉强化
表格中状态为 DOWN 的行 SHALL 具有视觉区分,包含背景色和左侧竖线。
#### Scenario: DOWN 行背景色
- **WHEN** 目标最近一次检查 matched=false
- **THEN** 该行 SHALL 通过 CSS 选择器获得浅红色背景
#### Scenario: DOWN 行左侧竖线
- **WHEN** 目标最近一次检查 matched=false
- **THEN** 该行 SHALL 通过 CSS 选择器获得左侧 3px 红色竖线border-left: 3px solid var(--td-error-color)
#### Scenario: DOWN 行 hover 状态
- **WHEN** 鼠标悬停在 DOWN 行上
- **THEN** 行背景 SHALL 显示 hover 状态色,与正常行 hover 效果协调
### Requirement: 行点击交互
表格行 SHALL 支持点击打开目标详情 Drawer。
#### Scenario: 点击行打开 Drawer
- **WHEN** 用户点击某一行
- **THEN** 系统 SHALL 打开该目标的详情 Drawer
#### Scenario: 行 hover 效果
- **WHEN** 鼠标悬停在表格行上
- **THEN** 行 SHALL 显示 hover 高亮效果TDesign Table hover prop
#### Scenario: 行 cursor 样式
- **WHEN** 鼠标悬停在表格行上
- **THEN** cursor SHALL 显示为 pointer
### Requirement: 表格外观
表格 SHALL 使用 TDesign PrimaryTable 统一外观,不设置 bordered由外层 Card 提供边界)。
#### Scenario: 表格样式
- **WHEN** 表格渲染
- **THEN** 表格 SHALL 设置 size="small"、stripe、hover不设置 bordered
### Requirement: StatusBar Tooltip 交互
StatusBar 色块 SHALL 在 hover 时通过 TDesign Tooltip 展示时间和状态信息。组件 props 类型 SHALL 使用完整的 `RecentSample` 类型(包含 timestamp 字段)而非简化的 `{ up: boolean }`
#### Scenario: StatusBar props 类型变更
- **WHEN** StatusBar 组件接收 samples 数据
- **THEN** 组件 SHALL 接收 `Array<RecentSample>` 类型(包含 timestamp、durationMs、up 字段),而非简化的 `Array<{ up: boolean }>` 类型
#### Scenario: 有数据色块 Tooltip
- **WHEN** 鼠标悬停在有数据的色块上
- **THEN** 色块 SHALL 通过 TDesign Tooltipplacement="top")展示该采样点的时间(使用 formatRelativeTime 格式化)和状态(正常/异常)
#### Scenario: 空色块无 Tooltip
- **WHEN** 鼠标悬停在空色块empty
- **THEN** 色块 SHALL 不显示 Tooltip
### Requirement: 列定义复用
所有分组的表格 SHALL 共享同一套列定义。
#### Scenario: 列定义提取为常量
- **WHEN** 多个分组表格渲染
- **THEN** 列定义 SHALL 从独立的 constants/target-table-columns.tsx 导入,不在组件中重复定义

View File

@@ -1,46 +0,0 @@
# Vite Frontend Bundling
定义 Vite 作为前端构建工具的配置、产出结构和优化策略。
## Purpose
使用 Vite 的 Rolldown 引擎完成前端打包,实现 code splitting、vendor chunk 分离和 CSS 优化,解决 Bun bundler 前端性能问题。
## Requirements
### Requirement: Vite 前端构建配置
系统 SHALL 使用 Vite 作为前端构建工具,配置文件位于项目根目录 `vite.config.ts`,以 `src/web` 为 root产出到 `dist/web/`
#### Scenario: 运行 Vite 生产构建
- **WHEN** 构建脚本执行 `bunx --bun vite build`
- **THEN** Vite SHALL 将 `src/web/index.html` 及其引用的所有模块构建到 `dist/web/` 目录,包含 `index.html``assets/` 子目录
#### Scenario: 产出文件名包含 content hash
- **WHEN** Vite 构建完成
- **THEN** `assets/` 目录下的 JS 和 CSS 文件名 SHALL 包含 content hash`index-a1b2c3.js`
### Requirement: Code Splitting 策略
系统 SHALL 配置 Vite 的 Rolldown code splitting将 vendor 库分离为独立 chunks。
#### Scenario: React 相关库分离
- **WHEN** Vite 构建完成
- **THEN** `react``react-dom``scheduler` SHALL 被打包到名为 `vendor-react` 的独立 chunk
#### Scenario: TDesign 相关库分离
- **WHEN** Vite 构建完成
- **THEN** `tdesign-react``tdesign-icons-react` 相关模块 SHALL 被打包到名为 `vendor-tdesign` 的独立 chunk
#### Scenario: 图表库分离
- **WHEN** Vite 构建完成
- **THEN** `recharts``d3-*` 相关模块 SHALL 被打包到名为 `vendor-chart` 的独立 chunk
### Requirement: CSS 处理
系统 SHALL 通过 Vite 处理 CSS 导入,产出独立的 CSS 文件。
#### Scenario: CSS 文件产出
- **WHEN** Vite 构建完成
- **THEN** 所有 CSS 导入 SHALL 被提取为独立的 `.css` 文件到 `assets/` 目录
#### Scenario: CSS 压缩
- **WHEN** Vite 执行生产构建
- **THEN** 产出的 CSS 文件 SHALL 经过压缩处理

View File

@@ -1,54 +0,0 @@
# Capability: windows-test-compat
## Purpose
确保测试在 Windows、macOS、Linux 平台上的兼容性,包括文件句柄释放后的目录清理重试机制和跨平台命令测试约定。
## Requirements
### Requirement: 测试临时目录清理 SHALL 支持重试
使用 SQLite 数据库的测试 SHALL 在 `afterAll` 中使用带重试的目录删除机制,确保在 Windows 上文件句柄未及时释放时不会导致测试失败。
#### Scenario: Windows 上 SQLite 文件句柄延迟释放
- **WHEN** 测试在 Windows 上运行,`store.close()` 后立即尝试删除临时目录
- **THEN** 删除操作 SHALL 自动重试(最多 3 次,间隔 200ms直到成功或耗尽重试次数
### Requirement: 命令检测器测试 SHALL 使用跨平台命令
命令检测器的测试 SHALL 使用 `bun -e` 脚本替代所有系统命令(包括 `true``false``sleep``bash``echo``yes | head`),确保测试在 Windows、macOS、Linux 三平台上行为一致。
#### Scenario: 进程退出码 0
- **WHEN** 测试需要一个正常退出的命令
- **THEN** 测试 SHALL 使用 `bun -e "process.exit(0)"` 替代 `true`
#### Scenario: 进程退出码非零
- **WHEN** 测试需要一个失败退出的命令
- **THEN** 测试 SHALL 使用 `bun -e "process.exit(1)"` 替代 `false`
#### Scenario: stdout 输出
- **WHEN** 测试需要一个输出文本到 stdout 的命令
- **THEN** 测试 SHALL 使用 `bun -e "console.log('text')"` 替代 `echo text`
#### Scenario: stderr 输出
- **WHEN** 测试需要一个输出文本到 stderr 的命令
- **THEN** 测试 SHALL 使用 `bun -e "process.stderr.write('error\n')"` 替代 `bash -c "echo error >&2"`
#### Scenario: 长时间运行命令
- **WHEN** 测试需要一个超时场景的长时间运行命令
- **THEN** 测试 SHALL 使用 `bun -e "await Bun.sleep(10000)"` 替代 `sleep 10`
#### Scenario: 大量输出
- **WHEN** 测试需要一个产生大量输出的命令
- **THEN** 测试 SHALL 使用 `bun -e "process.stdout.write('y\n'.repeat(N))"` 替代 `bash -c "yes | head -N"`
#### Scenario: 验证非 shell 模式下特殊字符不被展开
- **WHEN** 通过 `Bun.spawn` 执行 `bun -e "console.log('*')"` 并检查 stdout 包含 `*`
- **THEN** 测试 SHALL 在 Windows、macOS 和 Linux 上均返回 `matched: true`
### Requirement: probes.example.yaml 使用跨平台示例
probes.example.yaml 中的 cmd 类型示例 SHALL 使用跨平台命令(如 `bun -e "..."``bun --version`),不使用 Unix 专属命令(如 `uname``ls /tmp``date`)。
#### Scenario: 示例命令跨平台可执行
- **WHEN** 用户在 Windows、macOS 或 Linux 上直接使用 probes.example.yaml 中的 cmd 示例
- **THEN** 所有 cmd 示例 SHALL 能正常执行,不依赖平台特定命令

View File

@@ -1,5 +1,6 @@
{
"name": "dial-server",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
@@ -15,8 +16,13 @@
"verify": "bun run check && bun run build",
"test": "bun test",
"clean": "bun run scripts/clean.ts",
"release": "bun run scripts/release.ts",
"typecheck": "tsc --noEmit",
"prepare": "husky"
"prepare": "husky",
"version:patch": "bun run scripts/bump-version.ts patch",
"version:minor": "bun run scripts/bump-version.ts minor",
"version:major": "bun run scripts/bump-version.ts major",
"version:set": "bun run scripts/bump-version.ts set"
},
"devDependencies": {
"@commitlint/cli": "^21.0.1",
@@ -28,6 +34,7 @@
"@types/jsdom": "^28.0.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/tar-stream": "^3.1.4",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
@@ -41,20 +48,30 @@
"jsdom": "^29.1.1",
"lint-staged": "^17.0.4",
"prettier": "^3.8.3",
"tar-stream": "^3.2.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.3",
"vite": "^8.0.13"
},
"dependencies": {
"@ai-sdk/anthropic": "^3",
"@ai-sdk/openai": "^3",
"@number-flow/react": "^0.6.0",
"@sinclair/typebox": "^0.34.49",
"@tanstack/react-query": "^5.100.10",
"@xmldom/xmldom": "^0.9.10",
"ai": "^6",
"ajv": "^8.20.0",
"cheerio": "^1.2.0",
"croner": "^10.0.1",
"es-toolkit": "^1.46.1",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"pino-roll": "^4.0.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"recharts": "^3.8.1",
"systeminformation": "^5.31.6",
"tdesign-icons-react": "^0.6.4",
"tdesign-react": "^1.16.9",
"xpath": "^0.0.34"

File diff suppressed because it is too large Load Diff

View File

@@ -1,46 +1,67 @@
# yaml-language-server: $schema=./probe-config.schema.json
server:
host: "127.0.0.1"
port: 3000
dataDir: "/tmp/probes_data"
listen:
host: "127.0.0.1"
port: "${server_port|3000}"
storage:
dataDir: "/tmp/probes_data"
# logging:
# level: "info"
# console:
# level: "info"
# file:
# level: "info"
# path: "/var/log/dial/dial.log"
# rotation:
# size: "50MB"
# frequency: "daily"
# maxFiles: 14
runtime:
maxConcurrentChecks: 20
probes:
execution:
maxConcurrentChecks: "${max_checks|20}"
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"
max_checks: 20
server_port: 3000
sqlite_url: "sqlite://:memory:"
targets:
# ========== HTTP targets ==========
- name: "Baidu 首页可用"
- id: "baidu-home"
name: "Baidu 首页可用"
description: "监控百度首页的可用性和响应时间"
type: http
group: "搜索引擎"
http:
url: "https://www.baidu.com"
expect:
status: [200]
maxDurationMs: 5000
durationMs:
lte: 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:
contains: "application/json"
maxDurationMs: 8000
durationMs:
lte: 8000
body:
- json:
path: "$.slideshow.title"
@@ -48,38 +69,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 +90,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:
@@ -160,58 +102,10 @@ targets:
expect:
exitCode: [0]
stdout:
- match: "^\\d+\\.\\d+\\.\\d+"
- regex: "^\\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:
@@ -220,10 +114,11 @@ targets:
expect:
stdout:
- contains: "version:"
- match: "\\d+\\.\\d+\\.\\d+"
- regex: "\\d+\\.\\d+\\.\\d+"
- contains: "healthy"
- name: "stderr 内容检查"
- id: "bun-stderr"
name: "stderr 内容检查"
type: cmd
cmd:
exec: "bun"
@@ -232,3 +127,254 @@ targets:
exitCode: [1]
stderr:
- contains: "simulated error"
# ========== DB targets ==========
- id: "sqlite-connect"
name: "SQLite 内存数据库连接测试"
type: db
group: "数据库"
db:
url: "${sqlite_url}"
expect:
durationMs:
lte: 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: 1
rows:
- id:
gte: 1
name:
exists: true
role:
contains: "engineer"
result:
- json:
path: "$.rows[0].role"
equals: "engineer"
# ========== TCP targets ==========
- id: "redis-port"
name: "Redis 端口可达"
type: tcp
group: "基础设施"
tcp:
host: "127.0.0.1"
port: 6379
expect:
durationMs:
lte: 3000
- id: "smtp-banner"
name: "SMTP Banner 探测"
type: tcp
group: "基础设施"
tcp:
host: "127.0.0.1"
port: 25
readBanner: true
bannerReadTimeout: 3000
expect:
banner:
- contains: "ESMTP"
# ========== ICMP targets ==========
- id: "gateway-icmp"
name: "网关 ICMP 可达"
type: icmp
group: "基础设施"
icmp:
host: "127.0.0.1"
count: 3
packetSize: 56
expect:
alive: true
packetLossPercent:
lte: 10
avgLatencyMs:
lte: 100
maxLatencyMs:
lte: 300
durationMs:
lte: 5000
# ========== DNS targets ==========
# 本机 DNS 解析检查system 模式)
- id: "dns-system-localhost"
name: "本机 DNS 解析"
type: dns
group: "DNS"
dns:
resolver: system
name: "localhost"
family: ipv4
expect:
values:
exact:
- "127.0.0.1"
durationMs:
lte: 200
# DNS server 拨测server 模式A 记录)
- id: "dns-server-cf"
name: "Cloudflare DNS A 记录"
type: dns
group: "DNS"
dns:
resolver: server
server: "1.1.1.1"
name: "example.com"
recordType: A
expect:
rcode: ["NOERROR"]
ttlMin:
gte: 60
durationMs:
lte: 500
# 负向 DNS 检查NXDOMAIN
- id: "dns-nxdomain-check"
name: "负向 DNS 检查"
type: dns
group: "DNS"
dns:
resolver: server
server: "1.1.1.1"
name: "this-domain-should-not-exist.example.com"
recordType: A
expect:
rcode: ["NXDOMAIN"]
# MX 记录检查
- id: "dns-mx-check"
name: "MX 记录检查"
type: dns
group: "DNS"
dns:
resolver: server
server: "1.1.1.1"
name: "gmail.com"
recordType: MX
expect:
rcode: ["NOERROR"]
# ========== UDP targets ==========
- id: "udp-heartbeat"
name: "UDP 心跳检测"
type: udp
group: "基础设施"
udp:
host: "127.0.0.1"
port: 9000
payload: "PING"
expect:
response:
- contains: "PONG"
durationMs:
lte: 100
- id: "udp-binary-probe"
name: "UDP 二进制协议探测"
type: udp
group: "基础设施"
udp:
host: "127.0.0.1"
port: 5683
payload: "400100"
encoding: hex
responseEncoding: hex
expect:
responseSize:
gte: 4
durationMs:
lte: 200
- id: "udp-fire-and-forget"
name: "UDP 发送验证(不等待响应)"
type: udp
group: "基础设施"
udp:
host: "127.0.0.1"
port: 514
payload: "<14>health check"
expect:
responded: false
- id: "llm-openai-probe"
name: "OpenAI Chat Completions 健康检查"
type: llm
group: "AI 服务"
llm:
provider: openai
url: "https://open.bigmodel.cn/api/paas/v4"
model: "glm-4.7-flash"
prompt: "Say OK"
key: "d1e97306540d12bb2f834be961fcacb1.SNBShlCxWYJCx0qZ"
expect:
status:
- 200
finishReason: "stop"
output:
- contains: "OK"
# ========== WS targets ==========
- id: "ws-reachability"
name: "WebSocket 服务可达"
type: ws
group: "基础设施"
ws:
url: "wss://echo.websocket.org"
expect:
durationMs:
lte: 5000
- id: "ws-echo-check"
name: "WebSocket Echo 交互检查"
type: ws
group: "基础设施"
ws:
url: "wss://echo.websocket.org"
send: "hello"
receiveTimeout: 3000
expect:
message:
- contains: "hello"
durationMs:
lte: 5000
- id: "local-cpu"
name: "本机 CPU"
type: cpu
group: "基础设施"
interval: "30s"
timeout: "5s"
cpu:
sampleDuration: "1s"
expect:
usagePercent:
lte: 85
maxCoreUsagePercent:
lte: 95
- id: "local-memory"
name: "本机内存"
type: mem
group: "基础设施"
interval: "30s"
timeout: "5s"
mem: {}
expect:
usagePercent:
lte: 85

127
scripts/build-common.ts Normal file
View File

@@ -0,0 +1,127 @@
import { readdir, rm, writeFile } from "node:fs/promises";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { validateVersion } from "./bump-version-logic";
export const projectRoot = fileURLToPath(new URL("..", import.meta.url));
export const distWebDir = join(projectRoot, "dist/web");
export const buildDir = join(projectRoot, ".build");
export const packageJsonPath = join(projectRoot, "package.json");
export async function cleanup() {
await rm(buildDir, { force: true, recursive: true });
}
export async function codeGeneration() {
console.log("Step 2/3: Code generation...");
await rm(buildDir, { force: true, recursive: true });
await Bun.write(join(buildDir, ".gitkeep"), "");
const packageJson = (await Bun.file(packageJsonPath).json()) as { version: string };
const version = packageJson.version;
if (typeof version !== "string") {
console.error("package.json does not have a valid version field");
process.exit(1);
}
validateVersion(version);
const allFiles = await scanDir(distWebDir, "/");
const importLines: string[] = [];
const fileEntries: string[] = [];
let indexHtmlVar = "";
for (let i = 0; i < allFiles.length; i++) {
const urlPath = allFiles[i]!;
const varName = `f${i}`;
const filePath = toImportSpecifier(buildDir, join(distWebDir, urlPath.slice(1)));
importLines.push(`import ${varName} from "./${filePath}" with { type: "file" };`);
if (urlPath === "/index.html") {
indexHtmlVar = varName;
} else {
fileEntries.push(` "${urlPath}": Bun.file(${varName}),`);
}
}
if (!indexHtmlVar) {
console.error("index.html not found in dist/web/");
process.exit(1);
}
const staticAssetsTs = [
`import type { StaticAssets } from "../src/server/static";`,
"",
...importLines,
"",
`export const staticAssets: StaticAssets = {`,
` files: {`,
...fileEntries,
` },`,
` indexHtml: Bun.file(${indexHtmlVar}),`,
`};`,
"",
].join("\n");
await writeFile(join(buildDir, "static-assets.ts"), staticAssetsTs);
const serverEntryTs = [
`import { bootstrap } from "../src/server/bootstrap";`,
`import { readRuntimeConfig } from "../src/server/config";`,
`import { staticAssets } from "./static-assets";`,
"",
`const APP_VERSION = "${version}" as const;`,
"",
`async function main() {`,
` const { configPath } = readRuntimeConfig();`,
` await bootstrap({ configPath, mode: "production", staticAssets, version: APP_VERSION });`,
`}`,
"",
`void main().catch((error) => {`,
` console.error("启动失败:", error instanceof Error ? error.message : error);`,
` process.exit(1);`,
`});`,
"",
].join("\n");
await writeFile(join(buildDir, "server-entry.ts"), serverEntryTs);
return version;
}
export async function scanDir(dir: string, prefix: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const paths: string[] = [];
for (const entry of entries) {
const fullPath = join(dir, entry.name);
const urlPath = `${prefix}${entry.name}`;
if (entry.isDirectory()) {
paths.push(...(await scanDir(fullPath, `${urlPath}/`)));
} else {
paths.push(urlPath);
}
}
return paths;
}
export function toImportSpecifier(
fromDir: string,
targetPath: string,
relativePath: (from: string, to: string) => string = relative,
) {
return relativePath(fromDir, targetPath).replaceAll("\\", "/");
}
export async function viteBuild() {
console.log("Step 1/3: Vite build...");
const proc = Bun.spawn(["bunx", "--bun", "vite", "build"], {
cwd: projectRoot,
stderr: "inherit",
stdout: "inherit",
});
const exitCode = await proc.exited;
if (exitCode !== 0) {
console.error("Vite build failed");
process.exit(1);
}
}

View File

@@ -1,10 +1,8 @@
import { readdir, rm, writeFile } from "node:fs/promises";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { buildDir, cleanup, codeGeneration, projectRoot, viteBuild } from "./build-common";
const projectRoot = fileURLToPath(new URL("..", import.meta.url));
const distWebDir = join(projectRoot, "dist/web");
const buildDir = join(projectRoot, ".build");
const executablePath = join(projectRoot, "dist/dial-server");
async function build() {
@@ -51,101 +49,4 @@ async function bunCompile() {
}
}
async function cleanup() {
await rm(buildDir, { force: true, recursive: true });
}
async function codeGeneration() {
console.log("Step 2/3: Code generation...");
await rm(buildDir, { force: true, recursive: true });
await Bun.write(join(buildDir, ".gitkeep"), "");
const allFiles = await scanDir(distWebDir, "/");
const importLines: string[] = [];
const fileEntries: string[] = [];
let indexHtmlVar = "";
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)));
importLines.push(`import ${varName} from "./${filePath}" with { type: "file" };`);
if (urlPath === "/index.html") {
indexHtmlVar = varName;
} else {
fileEntries.push(` "${urlPath}": Bun.file(${varName}),`);
}
}
if (!indexHtmlVar) {
console.error("index.html not found in dist/web/");
process.exit(1);
}
const staticAssetsTs = [
`import type { StaticAssets } from "../src/server/static";`,
"",
...importLines,
"",
`export const staticAssets: StaticAssets = {`,
` files: {`,
...fileEntries,
` },`,
` indexHtml: Bun.file(${indexHtmlVar}),`,
`};`,
"",
].join("\n");
await writeFile(join(buildDir, "static-assets.ts"), staticAssetsTs);
const serverEntryTs = [
`import { bootstrap } from "../src/server/bootstrap";`,
`import { readRuntimeConfig } from "../src/server/config";`,
`import { staticAssets } from "./static-assets";`,
"",
`async function main() {`,
` const { configPath } = readRuntimeConfig();`,
` await bootstrap({ configPath, mode: "production", staticAssets });`,
`}`,
"",
`void main().catch((error) => {`,
` console.error("启动失败:", error instanceof Error ? error.message : error);`,
` process.exit(1);`,
`});`,
"",
].join("\n");
await writeFile(join(buildDir, "server-entry.ts"), serverEntryTs);
}
async function scanDir(dir: string, prefix: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const paths: string[] = [];
for (const entry of entries) {
const fullPath = join(dir, entry.name);
const urlPath = `${prefix}${entry.name}`;
if (entry.isDirectory()) {
paths.push(...(await scanDir(fullPath, `${urlPath}/`)));
} else {
paths.push(urlPath);
}
}
return paths;
}
async function viteBuild() {
console.log("Step 1/3: Vite build...");
const proc = Bun.spawn(["bunx", "--bun", "vite", "build"], {
cwd: projectRoot,
stderr: "inherit",
stdout: "inherit",
});
const exitCode = await proc.exited;
if (exitCode !== 0) {
console.error("Vite build failed");
process.exit(1);
}
}
await build();

View File

@@ -0,0 +1,40 @@
const VERSION_REGEX = /^\d+\.\d+\.\d+$/;
export function bumpVersion(current: string, command: "major" | "minor" | "patch" | "set", target?: string): string {
validateVersion(current);
const [major, minor, patch] = parseVersion(current);
switch (command) {
case "major":
return formatVersion(major + 1, 0, 0);
case "minor":
return formatVersion(major, minor + 1, 0);
case "patch":
return formatVersion(major, minor, patch + 1);
case "set": {
if (!target) {
throw new Error("set command requires a target version");
}
validateVersion(target);
return target;
}
}
}
export function formatVersion(major: number, minor: number, patch: number): string {
return `${major}.${minor}.${patch}`;
}
export function parseVersion(version: string): [number, number, number] {
const parts = version.split(".").map((p) => parseInt(p, 10));
if (parts.length !== 3 || parts.some(isNaN)) {
throw new Error(`Invalid version format: ${version}`);
}
return [parts[0]!, parts[1]!, parts[2]!];
}
export function validateVersion(version: string): void {
if (!VERSION_REGEX.test(version)) {
throw new Error(`Invalid version format: ${version}. Expected MAJOR.MINOR.PATCH (e.g., 0.1.0)`);
}
}

45
scripts/bump-version.ts Normal file
View File

@@ -0,0 +1,45 @@
import { writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { bumpVersion, validateVersion } from "./bump-version-logic";
const PACKAGE_JSON_PATH = resolve(import.meta.dir, "..", "package.json");
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: bun run bump-version.ts <patch|minor|major|set> [version]");
process.exit(1);
}
const command = args[0];
if (command !== "patch" && command !== "minor" && command !== "major" && command !== "set") {
console.error(`Unknown command: ${command}. Expected patch, minor, major, or set`);
process.exit(1);
}
if (command === "set" && args.length < 2) {
console.error("Usage: bun run bump-version.ts set <version>");
process.exit(1);
}
const packageJson = (await Bun.file(PACKAGE_JSON_PATH).json()) as { version: string };
const currentVersion = packageJson.version;
if (typeof currentVersion !== "string") {
console.error("package.json does not have a valid version field");
process.exit(1);
}
validateVersion(currentVersion);
const targetVersion = command === "set" ? args[1] : undefined;
const nextVersion = bumpVersion(currentVersion, command, targetVersion);
packageJson.version = nextVersion;
writeFileSync(PACKAGE_JSON_PATH, JSON.stringify(packageJson, null, 2) + "\n");
console.log(`${currentVersion} -> ${nextVersion}`);
}
void main();

View File

@@ -3,13 +3,23 @@ import { resolve } from "node:path";
const root = resolve(import.meta.dir, "..");
const patterns: Array<{ desc: string; glob: string }> = [
{ desc: "构建产物", glob: "dist/**" },
{ desc: "Bun 构建缓存", glob: ".build/**" },
{ desc: "Bun 构建临时文件", glob: ".*.bun-build" },
const dirs: Array<{ desc: string; path: string }> = [
{ desc: "构建产物", path: "dist" },
{ desc: "Bun 构建缓存", path: ".build" },
{ desc: "Playwright 测试报告", path: "playwright-report" },
{ desc: "测试结果", path: "test-results" },
{ desc: "发布产物", path: "dist/release" },
];
for (const { desc, glob } of patterns) {
const filePatterns: Array<{ desc: string; glob: string }> = [{ desc: "Bun 构建临时文件", glob: ".*.bun-build" }];
for (const { desc, path } of dirs) {
const full = resolve(root, path);
await rm(full, { force: true, recursive: true });
console.log(`已清理 ${desc}: ${path}`);
}
for (const { desc, glob } of filePatterns) {
const entries = await Array.fromAsync(new Bun.Glob(glob).scan({ cwd: root, dot: true }));
if (entries.length === 0) continue;
for (const entry of entries) {

196
scripts/release.ts Normal file
View File

@@ -0,0 +1,196 @@
import { createHash } from "node:crypto";
import { mkdir, rm, stat } from "node:fs/promises";
import { join, relative } from "node:path";
import { createGzip } from "node:zlib";
import tar from "tar-stream";
import { buildDir, cleanup, codeGeneration, projectRoot, viteBuild } from "./build-common";
const releaseDir = join(projectRoot, "dist/release");
const binariesDir = join(releaseDir, "binaries");
const packagesDir = join(releaseDir, "packages");
export interface ReleaseTarget {
arch: string;
bunTarget: string;
displayName: string;
os: string;
}
export const ALL_TARGETS: ReleaseTarget[] = [
{ arch: "x64", bunTarget: "bun-linux-x64", displayName: "Linux x64 (glibc)", os: "linux" },
{ arch: "arm64", bunTarget: "bun-linux-arm64", displayName: "Linux ARM64 (glibc)", os: "linux" },
{ arch: "x64-musl", bunTarget: "bun-linux-x64-musl", displayName: "Linux x64 (musl)", os: "linux" },
{ arch: "arm64-musl", bunTarget: "bun-linux-arm64-musl", displayName: "Linux ARM64 (musl)", os: "linux" },
{ arch: "x64", bunTarget: "bun-windows-x64", displayName: "Windows x64", os: "windows" },
{ arch: "x64", bunTarget: "bun-darwin-x64", displayName: "macOS x64 (Intel)", os: "darwin" },
{ arch: "arm64", bunTarget: "bun-darwin-arm64", displayName: "macOS ARM64 (Apple Silicon)", os: "darwin" },
];
export function archiveName(target: ReleaseTarget, version: string): string {
return `dial-server_${version}_${target.os}_${target.arch}.tar.gz`;
}
export function checksumName(target: ReleaseTarget, version: string): string {
return `${archiveName(target, version)}.sha256`;
}
export async function compileTarget(target: ReleaseTarget, version: string): Promise<string> {
const outfile = join(binariesDir, execName(target, version));
console.log(` 编译 ${target.displayName}...`);
const result = await Bun.build({
compile: {
autoloadBunfig: true,
autoloadDotenv: true,
outfile,
target: target.bunTarget as Bun.Build.CompileTarget,
},
entrypoints: [join(buildDir, "server-entry.ts")],
minify: true,
});
if (!result.success) {
console.error(` 编译失败 (${target.displayName}):`, result.logs);
process.exit(1);
}
return outfile;
}
export async function computeChecksum(archivePath: string): Promise<string> {
const content = await Bun.file(archivePath).arrayBuffer();
const hash = createHash("sha256").update(Buffer.from(content)).digest("hex");
const filename = relative(packagesDir, archivePath);
const checksumPath = `${archivePath}.sha256`;
const checksumContent = `${hash} ${filename}\n`;
await Bun.write(checksumPath, checksumContent);
return checksumPath;
}
export function execName(target: ReleaseTarget, version: string): string {
const suffix = target.os === "windows" ? ".exe" : "";
return `dial-server-${version}-${target.os}-${target.arch}${suffix}`;
}
export async function packageTarget(target: ReleaseTarget, version: string, binaryPath: string): Promise<string> {
const archivePath = join(packagesDir, archiveName(target, version));
const prefix = `dial-server_${version}_${target.os}_${target.arch}`;
const binaryName = target.os === "windows" ? "dial-server.exe" : "dial-server";
const binaryContent = await Bun.file(binaryPath).arrayBuffer();
const probesContent = await Bun.file(join(projectRoot, "probes.example.yaml")).arrayBuffer();
const licenseContent = await Bun.file(join(projectRoot, "LICENSE")).arrayBuffer();
const pack = tar.pack();
pack.entry(
{ mode: 0o755, name: `${prefix}/${binaryName}`, size: binaryContent.byteLength },
Buffer.from(binaryContent),
);
pack.entry(
{ mode: 0o644, name: `${prefix}/probes.example.yaml`, size: probesContent.byteLength },
Buffer.from(probesContent),
);
pack.entry({ mode: 0o644, name: `${prefix}/LICENSE`, size: licenseContent.byteLength }, Buffer.from(licenseContent));
pack.finalize();
const gzip = createGzip();
const chunks: Buffer[] = [];
await new Promise<void>((resolve, reject) => {
pack.pipe(gzip);
gzip.on("data", (chunk: Buffer) => chunks.push(chunk));
gzip.on("end", resolve);
gzip.on("error", reject);
});
await Bun.write(archivePath, Buffer.concat(chunks));
return archivePath;
}
export function parseTargets(args: string[]): ReleaseTarget[] {
const targetIndex = args.indexOf("--target");
if (targetIndex === -1 || targetIndex === args.length - 1) {
return ALL_TARGETS;
}
const targetValues = args[targetIndex + 1]!.split(",");
const targets: ReleaseTarget[] = [];
for (const value of targetValues) {
const bunTarget = `bun-${value.trim()}`;
const found = ALL_TARGETS.find((t) => t.bunTarget === bunTarget);
if (!found) {
const available = ALL_TARGETS.map((t) => t.bunTarget.replace(/^bun-/, "")).join(", ");
console.error(`无效的 target: ${value.trim()}`);
console.error(`可用的 target 值: ${available}`);
process.exit(1);
}
targets.push(found);
}
return targets;
}
export async function printReport(binaries: string[], archives: string[]): Promise<void> {
console.log("\n=== Release 报告 ===\n");
console.log("裸二进制:");
for (const binary of binaries) {
const size = (await stat(binary)).size;
const mb = (size / 1024 / 1024).toFixed(1);
console.log(` ${relative(projectRoot, binary)} (${mb} MB)`);
}
console.log("\n压缩包:");
for (const archive of archives) {
const size = (await stat(archive)).size;
const mb = (size / 1024 / 1024).toFixed(1);
console.log(` ${relative(projectRoot, archive)} (${mb} MB)`);
}
}
async function release() {
const targets = parseTargets(process.argv);
console.log(`Release 目标: ${targets.map((t) => t.displayName).join(", ")}\n`);
try {
await viteBuild();
const version = await codeGeneration();
console.log(`\n版本: ${version}`);
console.log(`编译 ${targets.length} 个目标...\n`);
await rm(releaseDir, { force: true, recursive: true });
await mkdir(binariesDir, { recursive: true });
await mkdir(packagesDir, { recursive: true });
const binaries: string[] = [];
for (const target of targets) {
const binaryPath = await compileTarget(target, version);
binaries.push(binaryPath);
}
const archives: string[] = [];
for (let i = 0; i < targets.length; i++) {
const target = targets[i]!;
const binaryPath = binaries[i]!;
console.log(` 打包 ${target.displayName}...`);
const archivePath = await packageTarget(target, version, binaryPath);
await computeChecksum(archivePath);
archives.push(archivePath);
}
await cleanup();
await printReport(binaries, archives);
console.log("\nRelease 完成!");
} catch (error) {
await cleanup();
console.error("Release 失败:", error);
process.exit(1);
}
}
if (import.meta.main) {
await release();
}

11
src/pino-roll.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
declare module "pino-roll" {
interface RollingStreamOptions {
file: string;
frequency?: string;
limit?: { count?: number };
mkdir?: boolean;
size?: string;
}
export default function build(options: RollingStreamOptions): Promise<NodeJS.WritableStream>;
}

View File

@@ -1,12 +1,15 @@
import { join } from "node:path";
import type { RuntimeMode } from "../shared/api";
import type { ResolvedLoggingConfig } from "./checker/types";
import type { Logger } from "./logger";
import type { StartServerOptions } from "./server";
import type { StaticAssets } from "./static";
import { loadConfig, type ResolvedConfig } from "./checker/config-loader";
import { ProbeEngine } from "./checker/engine";
import { ProbeStore } from "./checker/store";
import { createConsoleFallback, createRuntimeLogger } from "./logger";
import { startServer } from "./server";
export interface BootstrapDependencies {
@@ -15,7 +18,9 @@ export interface BootstrapDependencies {
targets: ResolvedConfig["targets"],
maxConcurrentChecks: number,
retentionMs: number,
logger: Logger,
) => BootstrapEngine;
createLogger?: (config: ResolvedLoggingConfig, mode: string, version: string) => Promise<Logger>;
createStore?: (dbPath: string) => ProbeStore;
exit?: (code: number) => never;
loadConfig?: (configPath: string) => Promise<ResolvedConfig>;
@@ -28,6 +33,7 @@ export interface BootstrapOptions {
configPath: string;
mode: RuntimeMode;
staticAssets?: StaticAssets;
version: string;
}
type BootstrapEngine = Pick<ProbeEngine, "start" | "stop">;
@@ -38,8 +44,14 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
const createStore = dependencies.createStore ?? ((dbPath: string) => new ProbeStore(dbPath));
const createEngine =
dependencies.createEngine ??
((store: ProbeStore, targets: ResolvedConfig["targets"], maxConcurrentChecks: number, retentionMs: number) =>
new ProbeEngine(store, targets, maxConcurrentChecks, retentionMs));
((
store: ProbeStore,
targets: ResolvedConfig["targets"],
maxConcurrentChecks: number,
retentionMs: number,
logger: Logger,
) => new ProbeEngine(store, targets, maxConcurrentChecks, retentionMs, logger));
const buildLogger = dependencies.createLogger ?? createRuntimeLogger;
const serve = dependencies.startServer ?? startServer;
const onSignal =
dependencies.onSignal ??
@@ -47,22 +59,52 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
process.on(signal, handler);
});
const exit = dependencies.exit ?? ((code: number) => process.exit(code));
const logError = dependencies.logError ?? console.error;
const logError =
dependencies.logError ??
((...data: unknown[]) => {
createConsoleFallback().fatal(
data.map((item) => (item instanceof Error ? item.message : String(item))).join(" "),
);
});
let store: ProbeStore | undefined;
let engine: BootstrapEngine | undefined;
let logger: Logger | undefined;
try {
const config = await load(options.configPath);
try {
logger = await buildLogger(config.logging, options.mode, options.version);
} catch (logInitError) {
logError("日志初始化失败:", logInitError instanceof Error ? logInitError.message : logInitError);
exit(1);
}
logger!.info({ configPath: options.configPath, mode: options.mode, version: options.version }, "配置加载成功");
store = createStore(join(config.dataDir, "probe.db"));
store.syncTargets(config.targets);
logger!.info({ dataDir: config.dataDir }, "数据库初始化成功");
engine = createEngine(store, config.targets, config.maxConcurrentChecks, config.retentionMs);
engine = createEngine(
store,
config.targets,
config.maxConcurrentChecks,
config.retentionMs,
logger!.child({ component: "engine" }),
);
engine.start();
logger!.info(
{ maxConcurrentChecks: config.maxConcurrentChecks, targetCount: config.targets.length },
"调度引擎启动",
);
const shutdown = () => {
logger?.info("收到退出信号,开始优雅关机");
engine?.stop();
store?.close();
logger?.flush();
exit(0);
};
onSignal("SIGINT", shutdown);
@@ -70,14 +112,21 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
serve({
config: { host: config.host, port: config.port },
logger: logger!.child({ component: "server" }),
mode: options.mode,
staticAssets: options.staticAssets,
store,
version: options.version,
});
} catch (error) {
engine?.stop();
store?.close();
logError("启动失败:", error instanceof Error ? error.message : error);
if (logger) {
logger.fatal({ error: error instanceof Error ? error.message : String(error) }, "启动失败");
logger.flush();
} else {
logError("启动失败:", error instanceof Error ? error.message : error);
}
exit(1);
}
}

View File

@@ -1,13 +1,24 @@
import { isNumber, isPlainObject, isString } from "es-toolkit";
import { dirname, resolve } from "node:path";
import type { ConfigValidationIssue } from "./schema/issues";
import type { DefaultsConfig, EngineRuntimeConfig, RawTargetConfig, ResolvedTargetBase } from "./types";
import type {
ExecutionConfig,
LoggingConfig,
LogLevel,
RawTargetConfig,
ResolvedLoggingConfig,
ResolvedTargetBase,
RotationFrequency,
ServerStorageConfig,
} from "./types";
import { normalizeAuthoringConfig } from "./normalizer";
import { checkerRegistry } from "./runner";
import { issue, throwConfigIssues } from "./schema/issues";
import { asValidatedConfig, type RawProbeConfig } from "./schema/types";
import { asValidatedConfig, type NormalizedProbeConfig } from "./schema/types";
import { validateProbeConfigContract } from "./schema/validate";
import { parseDuration } from "./utils";
import { parseDuration, parseSize } from "./utils";
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 3000;
@@ -16,11 +27,21 @@ const DEFAULT_INTERVAL = "30s";
const DEFAULT_TIMEOUT = "10s";
const DEFAULT_MAX_CONCURRENT_CHECKS = 20;
const DEFAULT_RETENTION = "7d";
const DEFAULT_LOG_LEVEL: LogLevel = "info";
const DEFAULT_ROTATION_SIZE = "50MB";
const DEFAULT_ROTATION_FREQUENCY: RotationFrequency = "daily";
const DEFAULT_ROTATION_MAX_FILES = 14;
const MINIMUM_INTERVAL_MS = parseDuration("10s");
const VALID_LOG_LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
const VALID_ROTATION_FREQUENCIES: RotationFrequency[] = ["hourly", "daily", "weekly"];
export interface ResolvedConfig {
configDir: string;
dataDir: string;
host: string;
logging: ResolvedLoggingConfig;
maxConcurrentChecks: number;
port: number;
retentionMs: number;
@@ -41,11 +62,17 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
throw new Error("配置文件内容为空或格式无效");
}
const contractResult = validateProbeConfigContract(parsed, checkerRegistry);
if (contractResult.config === null && !canRunSemanticValidation(parsed)) {
const normalizeResult = normalizeAuthoringConfig(parsed, checkerRegistry);
if (normalizeResult.issues.length > 0) {
throwConfigIssues(dedupeIssues(normalizeResult.issues));
}
const normalizedConfig = normalizeResult.config;
const contractResult = validateProbeConfigContract(normalizedConfig, checkerRegistry);
if (contractResult.config === null && !canRunSemanticValidation(normalizedConfig)) {
throwConfigIssues(contractResult.issues);
}
const semanticInput = (contractResult.config ?? parsed) as RawProbeConfig;
const semanticInput = (contractResult.config ?? normalizedConfig) as NormalizedProbeConfig;
const validationIssues = validateConfig(semanticInput);
const allIssues = [...contractResult.issues, ...validationIssues];
@@ -62,40 +89,45 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
const configDir = dirname(resolve(configPath));
const server = validated.server ?? {};
const runtime = validated.runtime ?? {};
const defaults = validated.defaults ?? {};
const listen = server.listen ?? {};
const storage = server.storage ?? {};
const host = server.host ?? DEFAULT_HOST;
const port = server.port ?? DEFAULT_PORT;
const dataDir = resolve(configDir, server.dataDir ?? DEFAULT_DATA_DIR);
const host = listen.host ?? DEFAULT_HOST;
const port = listen.port ?? DEFAULT_PORT;
const dataDir = resolve(configDir, storage.dataDir ?? DEFAULT_DATA_DIR);
const maxConcurrentChecks = resolveMaxConcurrentChecks(runtime);
const retentionMs = resolveRetention(runtime);
const probes = validated.probes ?? {};
const execution = probes.execution ?? {};
const maxConcurrentChecks = resolveMaxConcurrentChecks(execution);
const retentionMs = resolveRetention(storage);
const logging = resolveLogging(server.logging ?? {}, dataDir, configDir);
const allRuntimeIssues = [...allIssues];
validateLoggingConfig(server.logging, allRuntimeIssues);
if (allRuntimeIssues.length > 0) {
throwConfigIssues(dedupeIssues(allRuntimeIssues));
}
const defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL);
const defaultTimeoutMs = parseDuration(defaults.timeout ?? DEFAULT_TIMEOUT);
const defaultIntervalMs = parseDuration(DEFAULT_INTERVAL);
const defaultTimeoutMs = parseDuration(DEFAULT_TIMEOUT);
const targets: ResolvedTargetBase[] = validated.targets.map((target) =>
resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir),
resolveTarget(target, defaultIntervalMs, defaultTimeoutMs, configDir),
);
return { configDir, dataDir, host, maxConcurrentChecks, port, retentionMs, targets };
return { configDir, dataDir, host, logging, maxConcurrentChecks, port, retentionMs, targets };
}
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,71 +135,125 @@ 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" ||
!Number.isInteger(runtime.maxConcurrentChecks) ||
runtime.maxConcurrentChecks <= 0
)
return DEFAULT_MAX_CONCURRENT_CHECKS;
return runtime.maxConcurrentChecks;
function isAbsolute(p: string): boolean {
return p.startsWith("/") || /^[A-Za-z]:/.test(p);
}
function resolveRetention(runtime: EngineRuntimeConfig): number {
return parseDuration(runtime.retention ?? DEFAULT_RETENTION);
function resolveLogging(logging: LoggingConfig, dataDir: string, configDir: string): ResolvedLoggingConfig {
const globalLevel = resolveLogLevel(logging.level, DEFAULT_LOG_LEVEL);
const consoleLevel = resolveLogLevel(logging.console?.level, globalLevel);
const fileLevel = resolveLogLevel(logging.file?.level, globalLevel);
const rawPath = logging.file?.path;
const filePath = rawPath
? isAbsolute(rawPath)
? rawPath
: resolve(configDir, rawPath)
: resolve(dataDir, "logs/dial.log");
const rotationRaw = logging.file?.rotation;
const rotationSizeRaw = rotationRaw?.size ?? DEFAULT_ROTATION_SIZE;
const rotationSizeBytes = parseSize(rotationSizeRaw);
const rotationFrequency = rotationRaw?.frequency ?? DEFAULT_ROTATION_FREQUENCY;
const rotationMaxFiles = rotationRaw?.maxFiles ?? DEFAULT_ROTATION_MAX_FILES;
return {
consoleLevel,
fileLevel,
filePath,
rotationFrequency,
rotationMaxFiles,
rotationSizeBytes,
rotationSizeRaw,
};
}
function resolveLogLevel(level: unknown, fallback: LogLevel): LogLevel {
if (!isString(level)) return fallback;
if (VALID_LOG_LEVELS.includes(level as LogLevel)) return level as LogLevel;
return fallback;
}
function resolveMaxConcurrentChecks(execution: ExecutionConfig): number {
if (execution.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
if (
!isNumber(execution.maxConcurrentChecks) ||
!Number.isInteger(execution.maxConcurrentChecks) ||
execution.maxConcurrentChecks <= 0
)
return DEFAULT_MAX_CONCURRENT_CHECKS;
return execution.maxConcurrentChecks;
}
function resolveRetention(storage: ServerStorageConfig): number {
return parseDuration(storage.retention ?? DEFAULT_RETENTION);
}
function resolveTarget(
target: RawTargetConfig,
defaults: DefaultsConfig,
defaultIntervalMs: number,
defaultTimeoutMs: number,
configDir: string,
): ResolvedTargetBase {
const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL);
const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT);
const intervalMs = parseDuration(target.interval ?? DEFAULT_INTERVAL);
const timeoutMs = parseDuration(target.timeout ?? DEFAULT_TIMEOUT);
const checker = checkerRegistry.get(target.type);
const result = checker.resolve(target, { configDir, defaultIntervalMs, defaults, defaultTimeoutMs });
const result = checker.resolve(target, { configDir, defaultIntervalMs, defaultTimeoutMs });
result.intervalMs = intervalMs;
result.timeoutMs = timeoutMs;
result.description = target.description ?? null;
return result;
}
function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
function tryParseDuration(value: string): null | number {
try {
return parseDuration(value);
} catch {
return null;
}
}
function validateConfig(config: NormalizedProbeConfig): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
if (!Array.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;
if (isString(nameValue) && nameValue.trim() === "") {
issues.push(issue("invalid-value", `targets[${i}].name`, "name 不能为空白", name));
}
const type: unknown = raw["type"];
if (!isString(type)) {
issues.push(issue("required", `targets[${i}].type`, "缺少 type 字段", name));
continue;
}
@@ -183,45 +269,53 @@ 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) {
issues.push(...checker.validate({ defaults: config.defaults ?? {}, targets: config.targets }));
issues.push(...checker.validate({ targets: config.targets }));
}
validateDurationValue(config.defaults?.interval, "defaults.interval", issues);
validateDurationValue(config.defaults?.timeout, "defaults.timeout", issues);
validateDurationValue(
typeof config.runtime?.retention === "string" ? config.runtime.retention : undefined,
"runtime.retention",
isString(config.server?.storage?.retention) ? config.server.storage.retention : undefined,
"server.storage.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;
validateDurationValue(
typeof target["interval"] === "string" ? target["interval"] : undefined,
`targets[${i}].interval`,
issues,
targetName,
);
validateDurationValue(
typeof target["timeout"] === "string" ? target["timeout"] : undefined,
`targets[${i}].timeout`,
issues,
targetName,
);
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;
const intervalRaw = isString(targetRecord["interval"]) ? targetRecord["interval"] : undefined;
const timeoutRaw = isString(targetRecord["timeout"]) ? targetRecord["timeout"] : undefined;
validateDurationValue(intervalRaw, `targets[${i}].interval`, issues, targetName);
validateDurationValue(timeoutRaw, `targets[${i}].timeout`, issues, targetName);
const intervalMs = tryParseDuration(intervalRaw ?? DEFAULT_INTERVAL);
const timeoutMs = tryParseDuration(timeoutRaw ?? DEFAULT_TIMEOUT);
if (intervalMs !== null && intervalMs < MINIMUM_INTERVAL_MS) {
issues.push(issue("invalid-value", `targets[${i}].interval`, "interval 不能小于 10s", targetName));
}
if (intervalMs !== null && timeoutMs !== null && timeoutMs > intervalMs) {
issues.push(issue("invalid-value", `targets[${i}].timeout`, "timeout 不能大于 interval", targetName));
}
}
return issues;
@@ -240,3 +334,77 @@ function validateDurationValue(
issues.push(issue("invalid-duration", path, error instanceof Error ? error.message : "时长格式不合法", targetName));
}
}
function validateLoggingConfig(logging: LoggingConfig | undefined, issues: ConfigValidationIssue[]): void {
if (logging === undefined) return;
if (logging.level !== undefined && !VALID_LOG_LEVELS.includes(logging.level)) {
issues.push(
issue(
"invalid-value",
"server.logging.level",
`日志等级非法: "${logging.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
),
);
}
if (logging.console?.level !== undefined && !VALID_LOG_LEVELS.includes(logging.console.level)) {
issues.push(
issue(
"invalid-value",
"server.logging.console.level",
`日志等级非法: "${logging.console.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
),
);
}
if (logging.file?.level !== undefined && !VALID_LOG_LEVELS.includes(logging.file.level)) {
issues.push(
issue(
"invalid-value",
"server.logging.file.level",
`日志等级非法: "${logging.file.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`,
),
);
}
if (logging.file?.path !== undefined) {
if (!isString(logging.file.path) || logging.file.path.trim() === "") {
issues.push(issue("invalid-value", "server.logging.file.path", "日志路径不能为空字符串或空白字符串"));
}
}
const rotation = logging.file?.rotation;
if (rotation?.size !== undefined) {
try {
const bytes = parseSize(rotation.size);
if (bytes <= 0) {
issues.push(issue("invalid-value", "server.logging.file.rotation.size", "滚动大小必须为正整数字节数"));
}
} catch (error) {
issues.push(
issue(
"invalid-value",
"server.logging.file.rotation.size",
error instanceof Error ? error.message : "size 格式非法",
),
);
}
}
if (rotation?.frequency !== undefined && !VALID_ROTATION_FREQUENCIES.includes(rotation.frequency)) {
issues.push(
issue(
"invalid-value",
"server.logging.file.rotation.frequency",
`滚动频率非法: "${rotation.frequency}",支持: ${VALID_ROTATION_FREQUENCIES.join(", ")}`,
),
);
}
if (rotation?.maxFiles !== undefined) {
if (!isNumber(rotation.maxFiles) || !Number.isInteger(rotation.maxFiles) || rotation.maxFiles <= 0) {
issues.push(issue("invalid-value", "server.logging.file.rotation.maxFiles", "maxFiles 必须为正整数"));
}
}
}

View File

@@ -1,93 +1,114 @@
import { groupBy, isError, Semaphore } from "es-toolkit";
import { isError, Semaphore } from "es-toolkit";
import type { Logger } from "../logger";
import type { ProbeStore } from "./store";
import type { CheckResult, ResolvedTargetBase } from "./types";
import { createNoopLogger } from "../logger";
import { errorFailure } from "./expect/failure";
import { checkerRegistry } from "./runner";
const PRUNE_INTERVAL_MS = 3600000;
export class ProbeEngine {
private abort: AbortController | null = null;
private lastMatched = new Map<string, boolean>();
private logger: Logger;
private pruneTimer: null | ReturnType<typeof setInterval> = null;
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>> = [];
constructor(store: ProbeStore, targets: ResolvedTargetBase[], maxConcurrentChecks?: number, retentionMs?: number) {
constructor(
store: ProbeStore,
targets: ResolvedTargetBase[],
maxConcurrentChecks?: number,
retentionMs?: number,
logger?: Logger,
) {
this.store = store;
this.targets = targets;
this.semaphore = new Semaphore(maxConcurrentChecks ?? 20);
this.retentionMs = retentionMs ?? 0;
this.logger = logger ?? createNoopLogger();
this.refreshCache();
this.initStateCache();
}
start(): void {
const groups = groupBy(this.targets, (t) => t.intervalMs);
this.abort = new AbortController();
const signal = this.abort.signal;
for (const [intervalMs, groupTargets] of Object.entries(groups)) {
void this.probeGroup(groupTargets);
const timer = setInterval(() => {
void this.probeGroup(groupTargets);
}, Number(intervalMs));
this.timers.push(timer);
for (const target of this.targets) {
void this.runLoop(target, signal);
}
if (this.retentionMs > 0) {
this.store.prune(this.retentionMs);
const pruneTimer = setInterval(() => {
this.pruneTimer = setInterval(() => {
this.store.prune(this.retentionMs);
}, PRUNE_INTERVAL_MS);
this.timers.push(pruneTimer);
}
}
stop(): void {
for (const timer of this.timers) {
clearInterval(timer);
this.abort?.abort();
this.abort = null;
if (this.pruneTimer) {
clearInterval(this.pruneTimer);
this.pruneTimer = null;
}
this.timers = [];
}
private async probeGroup(targets: ResolvedTargetBase[]): Promise<void> {
const results = await Promise.allSettled(
targets.map(async (target) => {
await this.semaphore.acquire();
try {
return await this.runCheck(target);
} finally {
this.semaphore.release();
}
}),
);
for (const [index, result] of results.entries()) {
if (result.status === "fulfilled") {
this.writeResult(result.value);
} else {
const target = targets[index];
console.warn("探针执行失败:", result.reason);
if (!target) continue;
this.writeResult({
durationMs: null,
failure: errorFailure("internal", "engine", formatReason(result.reason)),
matched: false,
statusDetail: null,
targetName: target.name,
timestamp: new Date().toISOString(),
});
}
private initStateCache(): void {
const latestMap = this.store.getLatestChecksMap();
for (const [id, row] of latestMap) {
this.lastMatched.set(id, row.matched === 1);
}
}
private logCheckDebug(result: CheckResult): void {
this.logger.debug({
durationMs: result.durationMs,
failureMessage: result.failure?.message ?? null,
failurePhase: result.failure?.phase ?? null,
matched: result.matched,
targetId: result.targetId,
});
}
private logStateChange(result: CheckResult): void {
const previous = this.lastMatched.get(result.targetId);
const current = result.matched;
if (previous === undefined) {
if (!current) {
this.logger.warn(
{ durationMs: result.durationMs, failurePhase: result.failure?.phase, targetId: result.targetId },
`目标首次 DOWN: ${result.targetId}`,
);
}
} else if (previous && !current) {
this.logger.warn(
{ durationMs: result.durationMs, failurePhase: result.failure?.phase, targetId: result.targetId },
`目标状态变化 UP → DOWN: ${result.targetId}`,
);
} else if (!previous && current) {
this.logger.info(
{ durationMs: result.durationMs, targetId: result.targetId },
`目标恢复 DOWN → UP: ${result.targetId}`,
);
}
this.lastMatched.set(result.targetId, current);
}
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);
}
}
@@ -103,16 +124,71 @@ export class ProbeEngine {
}
}
private async runLoop(target: ResolvedTargetBase, signal: AbortSignal): Promise<void> {
while (!signal.aborted) {
const start = performance.now();
try {
await this.runOnce(target, signal);
} catch {
break;
}
const elapsed = performance.now() - start;
if (elapsed > target.intervalMs) {
this.logger.warn(
{ elapsed, intervalMs: target.intervalMs, targetId: target.id },
`拨测超时: ${target.id} 耗时 ${Math.round(elapsed)}ms > 间隔 ${target.intervalMs}ms`,
);
}
const delay = Math.max(0, target.intervalMs - elapsed);
try {
await sleep(delay, signal);
} catch {
break;
}
}
}
private async runOnce(target: ResolvedTargetBase, signal?: AbortSignal): Promise<CheckResult> {
await this.semaphore.acquire();
if (signal?.aborted) {
this.semaphore.release();
throw new DOMException("Aborted", "AbortError");
}
try {
const result = await this.runCheck(target);
this.writeResult(result);
this.logStateChange(result);
this.logCheckDebug(result);
return result;
} catch (error) {
const reason = formatReason(error);
this.logger.error({ reason, targetId: target.id, targetType: target.type }, `探针执行失败: ${reason}`);
const errorResult: CheckResult = {
detail: null,
durationMs: null,
failure: errorFailure("internal", "engine", reason),
matched: false,
observation: null,
targetId: target.id,
timestamp: new Date().toISOString(),
};
this.writeResult(errorResult);
return errorResult;
} finally {
this.semaphore.release();
}
}
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,
observation: result.observation ?? null,
targetId: result.targetId,
timestamp: result.timestamp,
});
}
@@ -121,3 +197,24 @@ export class ProbeEngine {
function formatReason(reason: unknown): string {
return isError(reason) ? reason.message : String(reason);
}
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException("Aborted", "AbortError"));
return;
}
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, ms);
function onAbort() {
clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
}
signal.addEventListener("abort", onAbort, { once: true });
});
}

View File

@@ -0,0 +1,281 @@
import { DOMParser } from "@xmldom/xmldom";
import * as cheerio from "cheerio";
import { isPlainObject } from "es-toolkit";
import * as xpath from "xpath";
import type { CheckFailure } from "../types";
import type {
ContentCssExpectation,
ContentExpectation,
ContentExpectations,
ContentJsonExpectation,
ContentValueExpectation,
ContentXpathExpectation,
ExpectationResult,
RawContentCssExpectation,
RawContentExpectation,
RawContentExpectations,
RawContentJsonExpectation,
RawContentXpathExpectation,
ValueExpectation,
ValueMatcher,
} from "./types";
import { errorFailure, mismatchFailure } from "./failure";
import { MATCHER_KEY_SET } from "./keys";
import { applyValueMatcher, displayValueExpectation, evaluateJsonPath } from "./value";
type ParsedJsonResult = { error: string; ok: false } | { ok: true; value: unknown };
export function checkContentExpectations(
source: unknown,
expectations: ContentExpectations | undefined,
options: { path?: string; phase: CheckFailure["phase"] },
): ExpectationResult {
if (!expectations || expectations.length === 0) return { failure: null, matched: true };
const basePath = options.path ?? options.phase;
let parsedJson: ParsedJsonResult | undefined;
for (let i = 0; i < expectations.length; i++) {
const expectation = expectations[i]!;
if (expectation.kind === "json" && parsedJson === undefined) {
parsedJson = parseJsonSource(source);
}
const result = checkSingleContentExpectation(source, expectation, `${basePath}[${i}]`, options.phase, parsedJson);
if (!result.matched) return result;
}
return { failure: null, matched: true };
}
export function resolveContentExpectations(raw: RawContentExpectations | undefined): ContentExpectations | undefined {
if (raw === undefined) return undefined;
return raw.map((entry) => resolveContentExpectation(entry));
}
function checkCssExpectation(
source: unknown,
expectation: ContentCssExpectation,
expectationPath: string,
phase: CheckFailure["phase"],
): ExpectationResult {
const fullPath = `${expectationPath}.css(${expectation.selector}${expectation.attr ? `@${expectation.attr}` : ""})`;
let $: cheerio.CheerioAPI;
try {
$ = cheerio.load(contentText(source));
} catch {
return { failure: errorFailure(phase, fullPath, "failed to parse HTML"), matched: false };
}
const el = $(expectation.selector).first();
const actual = el.length === 0 ? undefined : expectation.attr ? el.attr(expectation.attr) : el.text();
if (!applyValueMatcher(actual, expectation.matcher)) {
return {
failure: mismatchFailure(
phase,
fullPath,
displayValueExpectation(expectation.matcher),
actual,
`css selector ${expectation.selector} mismatch`,
),
matched: false,
};
}
return { failure: null, matched: true };
}
function checkJsonExpectation(
expectation: ContentJsonExpectation,
expectationPath: string,
phase: CheckFailure["phase"],
parsedJson?: ParsedJsonResult,
): ExpectationResult {
const fullPath = `${expectationPath}.json(${expectation.path})`;
if (!parsedJson?.ok) {
return { failure: errorFailure(phase, fullPath, parsedJson?.error ?? "content is not valid JSON"), matched: false };
}
const actual = evaluateJsonPath(parsedJson.value, expectation.path);
if (!applyValueMatcher(actual, expectation.matcher)) {
return {
failure: mismatchFailure(
phase,
fullPath,
displayValueExpectation(expectation.matcher),
actual,
`json path ${expectation.path} mismatch`,
),
matched: false,
};
}
return { failure: null, matched: true };
}
function checkSingleContentExpectation(
source: unknown,
expectation: ContentExpectation,
expectationPath: string,
phase: CheckFailure["phase"],
parsedJson?: ParsedJsonResult,
): ExpectationResult {
switch (expectation.kind) {
case "css":
return checkCssExpectation(source, expectation, expectationPath, phase);
case "json":
return checkJsonExpectation(expectation, expectationPath, phase, parsedJson);
case "value":
return checkValueContentExpectation(source, expectation, expectationPath, phase);
case "xpath":
return checkXpathExpectation(source, expectation, expectationPath, phase);
}
}
function checkValueContentExpectation(
source: unknown,
expectation: ContentValueExpectation,
expectationPath: string,
phase: CheckFailure["phase"],
): ExpectationResult {
if (!applyValueMatcher(source, expectation.matcher, { stringifyNonString: true })) {
return {
failure: mismatchFailure(
phase,
expectationPath,
displayValueExpectation(expectation.matcher),
source,
`${phase} expectation mismatch`,
),
matched: false,
};
}
return { failure: null, matched: true };
}
function checkXpathExpectation(
source: unknown,
expectation: ContentXpathExpectation,
expectationPath: string,
phase: CheckFailure["phase"],
): ExpectationResult {
const fullPath = `${expectationPath}.xpath(${expectation.path})`;
let doc: ReturnType<DOMParser["parseFromString"]>;
try {
doc = new DOMParser().parseFromString(contentText(source), "text/xml");
} catch {
return { failure: errorFailure(phase, fullPath, "failed to parse XML/HTML"), matched: false };
}
const result = xpath.select(expectation.path, doc as unknown as Node);
const actual = xpathValue(result);
if (!applyValueMatcher(actual, expectation.matcher)) {
return {
failure: mismatchFailure(
phase,
fullPath,
displayValueExpectation(expectation.matcher),
actual,
`xpath ${expectation.path} mismatch`,
),
matched: false,
};
}
return { failure: null, matched: true };
}
function contentText(source: unknown): string {
if (source === null || source === undefined) return "";
if (typeof source === "string") return source;
if (typeof source === "number" || typeof source === "boolean" || typeof source === "bigint") return String(source);
if (typeof source === "symbol") return source.description ?? "";
if (typeof source === "function") return source.name;
return JSON.stringify(source) ?? "";
}
function extractDirectMatcher(raw: Record<string, unknown>): ValueMatcher {
const matcher: ValueMatcher = {};
for (const [key, value] of Object.entries(raw)) {
if (MATCHER_KEY_SET.has(key) && value !== undefined) {
(matcher as Record<string, unknown>)[key] = value;
}
}
return matcher;
}
function extractExtractorMatcher(raw: Record<string, unknown>, ownFields: ReadonlySet<string>): ValueExpectation {
const matcher: ValueMatcher = {};
for (const [key, value] of Object.entries(raw)) {
if (ownFields.has(key)) continue;
if (MATCHER_KEY_SET.has(key) && value !== undefined) {
(matcher as Record<string, unknown>)[key] = value;
}
}
if (Object.keys(matcher).length === 0) return { exists: true };
return matcher;
}
function parseJsonSource(source: unknown): ParsedJsonResult {
if (typeof source !== "string") return { ok: true, value: source };
try {
return { ok: true, value: JSON.parse(source) as unknown };
} catch {
return { error: "content is not valid JSON", ok: false };
}
}
function resolveContentExpectation(raw: RawContentExpectation): ContentExpectation {
if (!isPlainObject(raw)) {
return { kind: "value", matcher: { equals: raw } };
}
const record = raw as Record<string, unknown>;
if (isPlainObject(record["json"])) {
const json = record["json"] as RawContentJsonExpectation;
return {
kind: "json",
matcher: extractExtractorMatcher(json as unknown as Record<string, unknown>, new Set(["path"])),
path: json.path,
};
}
if (isPlainObject(record["css"])) {
const css = record["css"] as RawContentCssExpectation;
const expectation: ContentCssExpectation = {
kind: "css",
matcher: extractExtractorMatcher(css as unknown as Record<string, unknown>, new Set(["attr", "selector"])),
selector: css.selector,
};
if (css.attr !== undefined) expectation.attr = css.attr;
return expectation;
}
if (isPlainObject(record["xpath"])) {
const xpathExpectation = record["xpath"] as RawContentXpathExpectation;
return {
kind: "xpath",
matcher: extractExtractorMatcher(xpathExpectation as unknown as Record<string, unknown>, new Set(["path"])),
path: xpathExpectation.path,
};
}
return { kind: "value", matcher: extractDirectMatcher(record) };
}
function xpathValue(result: unknown): unknown {
if (!Array.isArray(result)) return result;
if (result.length === 0) return undefined;
const node = (result as unknown[])[0]!;
if (typeof node !== "object" || node === null) return node;
const asNode = node as Node;
return asNode.nodeValue ?? (asNode as unknown as Element).textContent ?? "";
}

View File

@@ -1,20 +0,0 @@
import type { ExpectResult } from "./types";
import { mismatchFailure } from "./failure";
export function checkDuration(durationMs: number, maxDurationMs?: number): ExpectResult {
if (maxDurationMs === undefined) return { failure: null, matched: true };
if (durationMs > maxDurationMs) {
return {
failure: mismatchFailure(
"duration",
"duration",
`<=${maxDurationMs}ms`,
durationMs,
`duration ${durationMs}ms > ${maxDurationMs}ms`,
),
matched: false,
};
}
return { failure: null, matched: true };
}

View File

@@ -1,3 +1,5 @@
import { isString } from "es-toolkit";
import type { CheckFailure } from "../types";
export function errorFailure(phase: CheckFailure["phase"], path: string, message: string): CheckFailure {
@@ -29,7 +31,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 : typeof value === "object" && value !== null ? JSON.stringify(value) : undefined;
if (str === undefined) return value;
if (str.length <= maxLen) return value;
return `${str.slice(0, maxLen)}…(共 ${str.length} 字符)`;

View File

@@ -0,0 +1,14 @@
import type { ExpectationResult, KeyedExpectations } from "./types";
import { checkKeyedExpectations } from "./keyed";
export function checkHeaderExpectations(
headers: Record<string, unknown>,
expectations?: KeyedExpectations,
): ExpectationResult {
return checkKeyedExpectations(headers, expectations, {
normalizeKey: (key) => key.toLowerCase(),
path: "headers",
phase: "headers",
});
}

View File

@@ -0,0 +1,46 @@
import type { CheckFailure } from "../types";
import type { ExpectationResult, KeyedExpectations, RawKeyedExpectations } from "./types";
import { mismatchFailure } from "./failure";
import { applyValueMatcher, displayValueExpectation, resolveValueExpectation } from "./value";
export function checkKeyedExpectations(
actual: Record<string, unknown>,
expectations: KeyedExpectations | undefined,
options: { normalizeKey?: (key: string) => string; path?: string; phase: CheckFailure["phase"] },
): ExpectationResult {
if (!expectations || expectations.length === 0) return { failure: null, matched: true };
const normalizeKey = options.normalizeKey ?? ((key: string) => key);
const basePath = options.path ?? options.phase;
const actualMap = new Map<string, unknown>();
for (const [key, value] of Object.entries(actual)) {
actualMap.set(normalizeKey(key), value);
}
for (const expectation of expectations) {
const actualValue = actualMap.get(normalizeKey(expectation.key));
if (!applyValueMatcher(actualValue, expectation.matcher)) {
return {
failure: mismatchFailure(
options.phase,
`${basePath}.${expectation.key}`,
displayValueExpectation(expectation.matcher),
actualValue,
`${expectation.key} mismatch`,
),
matched: false,
};
}
}
return { failure: null, matched: true };
}
export function resolveKeyedExpectations(raw: RawKeyedExpectations | undefined): KeyedExpectations | undefined {
if (raw === undefined) return undefined;
return Object.entries(raw).map(([key, value]) => ({
key,
matcher: resolveValueExpectation(value),
}));
}

Some files were not shown because too many files have changed in this diff Show More