From cfca03b4d68886794e3c17b969195a575676b8ac Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Fri, 22 May 2026 18:55:18 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E8=A7=84=E8=8C=83=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E4=B8=8E=E9=87=8D=E7=BB=84=EF=BC=8C=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E7=BB=86=E7=B2=92=E5=BA=A6=E8=A7=84=E8=8C=83=EF=BC=8C=E6=B8=85?= =?UTF-8?q?=E7=90=86=E8=BF=87=E6=97=B6=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 合并 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 审查提示文档 --- docs/prompts/prompt-spec-review.md | 3 +- openspec/specs/api-route-separation/spec.md | 59 --- openspec/specs/backend-code-quality/spec.md | 136 ------- openspec/specs/batch-data-queries/spec.md | 92 ----- openspec/specs/bun-fullstack-routing/spec.md | 49 --- .../specs/checker-cohesion-structure/spec.md | 355 ++++++++++++------ openspec/specs/checker-observation/spec.md | 12 +- .../specs/checker-runner-abstraction/spec.md | 241 ------------ openspec/specs/code-quality-gates/spec.md | 50 ++- openspec/specs/commit-quality-gates/spec.md | 51 --- .../specs/container-image-packaging/spec.md | 2 - openspec/specs/css-utility-classes/spec.md | 140 ------- openspec/specs/dashboard-layout/spec.md | 51 --- openspec/specs/dashboard/spec.md | 327 ++++++++++++++++ openspec/specs/data-retention/spec.md | 47 --- .../{probe-data-store => data-store}/spec.md | 88 ++++- .../specs/eslint-prettier-integration/spec.md | 25 -- openspec/specs/expect-body-checkers/spec.md | 209 ----------- openspec/specs/expect-rule-system/spec.md | 52 ++- .../frontend-development-workflow/spec.md | 62 ++- openspec/specs/fullstack-app-runtime/spec.md | 107 +++++- openspec/specs/http-checker/spec.md | 166 +++++++- openspec/specs/llm-checker/spec.md | 16 +- openspec/specs/meta-api/spec.md | 31 -- openspec/specs/probe-api/spec.md | 148 +++++++- openspec/specs/probe-config/spec.md | 57 ++- openspec/specs/probe-dashboard/spec.md | 57 --- openspec/specs/probe-engine/spec.md | 185 +-------- openspec/specs/refresh-control/spec.md | 104 ----- openspec/specs/server-bootstrap/spec.md | 50 --- .../specs/single-executable-packaging/spec.md | 62 ++- openspec/specs/static-asset-embedding/spec.md | 69 ---- openspec/specs/target-grouping/spec.md | 45 --- openspec/specs/target-identity/spec.md | 110 ------ openspec/specs/target-metrics-api/spec.md | 164 -------- openspec/specs/tcp-checker/spec.md | 8 - .../specs/test-output-cleanliness/spec.md | 2 - openspec/specs/udp-checker/spec.md | 10 +- openspec/specs/vite-frontend-bundling/spec.md | 58 --- openspec/specs/windows-test-compat/spec.md | 2 - 40 files changed, 1368 insertions(+), 2134 deletions(-) delete mode 100644 openspec/specs/api-route-separation/spec.md delete mode 100644 openspec/specs/backend-code-quality/spec.md delete mode 100644 openspec/specs/batch-data-queries/spec.md delete mode 100644 openspec/specs/bun-fullstack-routing/spec.md delete mode 100644 openspec/specs/checker-runner-abstraction/spec.md delete mode 100644 openspec/specs/commit-quality-gates/spec.md delete mode 100644 openspec/specs/css-utility-classes/spec.md delete mode 100644 openspec/specs/dashboard-layout/spec.md create mode 100644 openspec/specs/dashboard/spec.md delete mode 100644 openspec/specs/data-retention/spec.md rename openspec/specs/{probe-data-store => data-store}/spec.md (70%) delete mode 100644 openspec/specs/eslint-prettier-integration/spec.md delete mode 100644 openspec/specs/expect-body-checkers/spec.md delete mode 100644 openspec/specs/meta-api/spec.md delete mode 100644 openspec/specs/probe-dashboard/spec.md delete mode 100644 openspec/specs/refresh-control/spec.md delete mode 100644 openspec/specs/server-bootstrap/spec.md delete mode 100644 openspec/specs/static-asset-embedding/spec.md delete mode 100644 openspec/specs/target-grouping/spec.md delete mode 100644 openspec/specs/target-identity/spec.md delete mode 100644 openspec/specs/target-metrics-api/spec.md delete mode 100644 openspec/specs/vite-frontend-bundling/spec.md diff --git a/docs/prompts/prompt-spec-review.md b/docs/prompts/prompt-spec-review.md index 1237151..af1bced 100644 --- a/docs/prompts/prompt-spec-review.md +++ b/docs/prompts/prompt-spec-review.md @@ -9,10 +9,11 @@ - 仅删除内容已被其他规范完整覆盖且无独立检索价值的规范;非冗余内容仅迁移、合并、拆分或重命名 - 每批重构执行前用提问工具获得用户确认;删除或重写前先备份原文件为 `{file}.bak.{timestamp}` - 命名、Purpose、Requirement 标题都必须保留用户下一次最可能搜索的业务关键词 +- 使用subagents处理计算密集或多步骤的并行任务(如代码实现、测试执行);文件读取直接使用Read工具并行调用,禁止用subagent转发文件内容 ## 1. 收集 -并行读取: +读取: - `openspec/config.yaml` - `README.md`,以及与模块结构、API、架构相关的 README 或文档 diff --git a/openspec/specs/api-route-separation/spec.md b/openspec/specs/api-route-separation/spec.md deleted file mode 100644 index 09712e1..0000000 --- a/openspec/specs/api-route-separation/spec.md +++ /dev/null @@ -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 的客户端路由 diff --git a/openspec/specs/backend-code-quality/spec.md b/openspec/specs/backend-code-quality/spec.md deleted file mode 100644 index a1d73ef..0000000 --- a/openspec/specs/backend-code-quality/spec.md +++ /dev/null @@ -1,136 +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: 使用原生 API 进行数组类型判断 -系统 SHALL 使用原生 `Array.isArray()` 替代 `es-toolkit/compat` 的 `isArray`,用于 checker 模块中所有数组类型判断场景。 - -#### Scenario: 数组值判定为数组 -- **WHEN** 校验值为数组(如 `[1, 2, 3]`) -- **THEN** `Array.isArray(value)` SHALL 返回 true - -#### Scenario: 非数组值判定为非数组 -- **WHEN** 校验值为对象 `{}`、字符串 `"abc"`、数字 `123`、null 等 -- **THEN** `Array.isArray(value)` SHALL 返回 false - -### Requirement: 使用原生 API 进行对象类型判断 -系统 SHALL 使用原生 `typeof x === 'object' && x !== null` 替代 `es-toolkit/compat` 的 `isObject`,用于 checker 模块中需要判断值为对象类型(排除 null)的场景。 - -#### Scenario: 普通对象判定为对象 -- **WHEN** 值为普通对象 `{ key: "val" }` -- **THEN** `typeof value === 'object' && value !== null` SHALL 返回 true - -#### Scenario: 数组判定为对象 -- **WHEN** 值为数组 `[1, 2, 3]` -- **THEN** `typeof value === 'object' && value !== null` SHALL 返回 true - -#### Scenario: Headers 实例判定为对象 -- **WHEN** 值为 `Headers` 实例 -- **THEN** `typeof value === 'object' && value !== null` SHALL 返回 true - -#### Scenario: null 判定为非对象 -- **WHEN** 值为 null -- **THEN** `typeof value === 'object' && value !== null` SHALL 返回 false - -#### Scenario: 原始值判定为非对象 -- **WHEN** 值为字符串、数字、布尔值、undefined -- **THEN** `typeof value === 'object' && value !== null` SHALL 返回 false - -### Requirement: 使用 Bun 内置 API 进行 Headers 转换 -系统 SHALL 使用 `Object.fromEntries(headers)` 标准 Web API 替代手写的 `headersToRecord` 函数,用于将 Fetch API 的 Headers 对象转换为键值对。 - -#### Scenario: 转换响应头 -- **WHEN** HTTP runner 获取到 response headers -- **THEN** `Object.fromEntries(response.headers)` SHALL 返回以 header 名称为 key、header 值为 value 的对象 diff --git a/openspec/specs/batch-data-queries/spec.md b/openspec/specs/batch-data-queries/spec.md deleted file mode 100644 index f4e56b9..0000000 --- a/openspec/specs/batch-data-queries/spec.md +++ /dev/null @@ -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` - -#### 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` - -#### Scenario: 目标无历史记录 -- **WHEN** 某 target 在 check_results 表中无任何记录 -- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key - -#### Scenario: availability 精度 -- **WHEN** 计算 availability(upCount / 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>` 结构。 - -#### 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()`,因需要在事务闭包内持有引用 diff --git a/openspec/specs/bun-fullstack-routing/spec.md b/openspec/specs/bun-fullstack-routing/spec.md deleted file mode 100644 index 393ea81..0000000 --- a/openspec/specs/bun-fullstack-routing/spec.md +++ /dev/null @@ -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 访问前端 diff --git a/openspec/specs/checker-cohesion-structure/spec.md b/openspec/specs/checker-cohesion-structure/spec.md index 38966d2..07655e4 100644 --- a/openspec/specs/checker-cohesion-structure/spec.md +++ b/openspec/specs/checker-cohesion-structure/spec.md @@ -1,107 +1,25 @@ ## Purpose -定义 checker 模块的内聚化组织结构,确保每个 checker 以独立目录形式存在,包含其全部类型定义、schema 声明、语义校验、执行逻辑和断言逻辑。同时定义共享的 expect/ 和 schema/ 基础设施,以及严格的依赖方向约束。 +定义 checker 模块的内聚化组织结构,确保每个 checker 以独立目录形式存在,包含其全部类型定义、schema 声明、语义校验、执行逻辑和断言逻辑。同时定义严格的依赖方向约束、Checker 接口定义、CheckerRegistry 注册中心、配置契约片段、配置校验 issue、引擎调度和服务注册委托。 ## Requirements ### Requirement: Checker 目录内聚结构 -每个 checker SHALL 以独立目录形式存在于 `src/server/checker/runner//`,目录内 SHALL 包含该 checker 的全部类型定义、schema 声明、语义校验、执行逻辑和断言逻辑。 +每个 checker SHALL 以独立目录形式存在于 checker runner 目录下,目录内 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 目录 +- **THEN** 该目录 SHALL 包含该 checker 的全部类型定义、schema 声明、语义校验、执行逻辑和断言逻辑 #### Scenario: 新增 checker 最小改动 - **WHEN** 开发者新增一个 checker 类型(如 dns) -- **THEN** 开发者 SHALL 只需创建 `src/server/checker/runner/dns/` 目录及其内部文件,并在 `runner/index.ts` 注册列表中添加一行 import 和一行数组项 +- **THEN** 开发者 SHALL 只需创建 checker 目录及其内部文件,并在注册列表中添加一行 import 和一行数组项 -### Requirement: Checker 目录文件职责 -每个 checker 目录内的文件 SHALL 遵循统一的职责划分。 +### Requirement: 断言基础设施 +系统 SHALL 提供所有 checker 共享的断言基础设施,使用 Raw/Resolved expectation 术语和 value/content/keyed/status/headers 模块边界。 -#### 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 或 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 共享的断言基础设施。共享 expect 目录 SHALL 使用 Raw/Resolved expectation 术语和 value/content/keyed/status/headers 模块边界。 - -#### Scenario: expect 共享类型位置 -- **WHEN** 任何 checker 需要使用断言相关的共享类型(如 `ExpectationResult`、`ValueExpectation`、`ContentExpectations` 或 `KeyedExpectations`) -- **THEN** 这些类型 SHALL 从 `src/server/checker/expect/types.ts` 导入 - -#### Scenario: value 断言引擎位置 -- **WHEN** 任何 checker 需要使用 `applyValueMatcher`、`evaluateJsonPath`、`resolveValueExpectation` 或 `checkValueExpectation` -- **THEN** 这些函数 SHALL 从 `src/server/checker/expect/value.ts` 导入 - -#### Scenario: content 和 keyed 断言位置 -- **WHEN** 任何 checker 需要执行内容数组或键值表 expectation -- **THEN** SHALL 分别从 `src/server/checker/expect/content.ts` 和 `src/server/checker/expect/keyed.ts` 导入共享函数 - -#### Scenario: failure 构造器位置 -- **WHEN** 任何 checker 需要使用 `errorFailure` 或 `mismatchFailure` -- **THEN** 这些函数 SHALL 从 `src/server/checker/expect/failure.ts` 导入 - -#### Scenario: expectation 校验位置 -- **WHEN** 任何 checker 的 validate 需要校验 Raw value、Raw content 或 Raw keyed expectation -- **THEN** 对应函数 SHALL 从 `src/server/checker/expect/validate.ts` 导入 - -#### Scenario: ExpectationResult 类型位置 -- **WHEN** 任何 checker 需要使用 `ExpectationResult` 类型 -- **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: Schema 体系 +系统 SHALL 通过 schema 体系组织配置校验、契约片段和 issue 报告,支持从 registry 动态构建整体配置 schema、共享 schema 片段引用、Ajv 校验入口和 ConfigValidationIssue 构造。 ### Requirement: 依赖方向约束 checker 系统内的模块依赖 SHALL 遵循严格的分层方向。 @@ -119,35 +37,248 @@ checker 系统内的模块依赖 SHALL 遵循严格的分层方向。 - **THEN** `schema/` 中的文件 SHALL 仅通过 `CheckerDefinition` 接口与 checker 交互,SHALL NOT 直接导入具体 checker 目录 ### Requirement: 显式注册列表 -系统 SHALL 在 `src/server/checker/runner/index.ts` 中使用显式 import 列表注册所有 checker。 +系统 SHALL 在 checker 注册入口文件中使用显式 import 列表注册所有 checker。 #### Scenario: 注册入口结构 -- **WHEN** 开发者查看 `runner/index.ts` +- **WHEN** 开发者查看 checker 注册入口文件 - **THEN** 该文件 SHALL 包含所有 checker 的静态 import 和一个 checker 实例数组,通过循环调用 `registry.register()` 完成注册 #### Scenario: 新增 checker 注册 - **WHEN** 开发者新增一个 checker -- **THEN** 开发者 SHALL 在 `runner/index.ts` 中添加一行 import 和一行数组项,无需修改其他文件 +- **THEN** 开发者 SHALL 在注册入口文件中添加一行 import 和一行数组项,无需修改其他文件 -### Requirement: 公共类型文件瘦身 -顶层 `src/server/checker/types.ts` SHALL 仅保留跨 checker 共享的 base 类型和存储相关类型;expect 专属类型 SHALL 放在 `src/server/checker/expect/types.ts`。 +### Requirement: Checker 配置契约片段 +系统 SHALL 支持 checker 提供自身 TypeBox 配置契约片段,用于描述该 checker 的 target 领域分组和 expect 分组。checker 契约 SHALL 区分 Authoring schema 与 Normalized schema。Authoring schema SHALL 描述用户 YAML 可书写形式,包括变量引用和 expect 简写;Normalized schema SHALL 描述 `normalizeAuthoringConfig()` 输出形式,不接受变量引用、不接受 expect primitive 简写。公共配置加载模块 SHALL 通过 registry 获取已注册 checker 的契约片段,并按用途组合为运行时 Ajv 契约校验流程和外部 `probe-config.schema.json` 导出流程。 -#### Scenario: types.ts 不包含 checker 或 expect 专属类型 -- **WHEN** 开发者查看顶层 `types.ts` -- **THEN** 该文件 SHALL NOT 包含 `HttpTargetConfig`、`ResolvedHttpTarget`、`RawCommandExpectConfig`、`ContentExpectation` 等 checker 专属或 expect 专属类型 +#### Scenario: HTTP checker 提供契约片段 +- **WHEN** HTTP checker 被注册 +- **THEN** registry SHALL 能提供 HTTP target 和 HTTP expect 的 TypeBox 契约片段 -#### Scenario: types.ts 保留 base 类型 -- **WHEN** 开发者查看顶层 `types.ts` -- **THEN** 该文件 SHALL 包含 `ResolvedTargetBase`、`RawTargetConfig`、`DefaultsConfig`、`CheckResult`、`CheckFailure`、`StoredTarget`、`StoredCheckResult`、`JsonValue` 等公共类型 +#### Scenario: Cmd checker 提供契约片段 +- **WHEN** Cmd checker 被注册 +- **THEN** registry SHALL 能提供 Cmd target 和 Cmd expect 的 TypeBox 契约片段 -#### Scenario: ResolvedTargetBase 替代联合类型 -- **WHEN** engine、store、config-loader 需要引用 resolved target 类型 -- **THEN** 这些模块 SHALL 使用 `ResolvedTargetBase` interface,不再使用硬编码联合类型 +#### Scenario: 新 checker 只维护自身契约 +- **WHEN** 开发者新增一个 checker 类型 +- **THEN** 该 checker SHALL 提供自身 TypeBox 配置契约和语义 validator,而不需要把 checker 专属字段写入中央手工校验逻辑 -#### Scenario: DefaultsConfig 为宽松 base 形式 -- **WHEN** 开发者查看顶层 `types.ts` 中的 `DefaultsConfig` -- **THEN** 该 interface SHALL 仅包含公共字段(`interval?`、`timeout?`)和 index signature(`[checkerKey: string]: unknown`),SHALL NOT 包含 `cmd?`、`http?` 等 checker 专属字段 +#### Scenario: 外部 schema 通过 registry 生成 +- **WHEN** 系统生成 `probe-config.schema.json` +- **THEN** 生成流程 SHALL 从 registry 获取已注册 checker 的 Authoring 契约片段,并将其组合进完整配置 schema -#### Scenario: 各 checker validate 自行 narrow defaults -- **WHEN** checker 的 `validate()` 方法需要访问自身的 defaults 配置 -- **THEN** checker SHALL 从 `DefaultsConfig` 中通过 `defaults[configKey]` 获取并自行 narrow 为具体类型 +#### Scenario: 运行时 schema 通过 registry 生成 +- **WHEN** config-loader 执行运行时 AJV 契约校验 +- **THEN** 校验流程 SHALL 从 registry 获取已注册 checker 的 Normalized 契约片段,并将其组合进完整配置 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 定义面向扩展的泛型 `CheckerDefinition`,包含 `type`、`configKey`、Authoring/Normalized TypeBox 配置契约、启动期语义校验、`resolve`、`execute`、`serialize`、`buildDetail` 成员。泛型参数 SHALL 约束 `execute` 和 `serialize` 方法的 target 参数类型,使 checker 实现内部获得编译期类型安全。默认泛型参数 `= ResolvedTargetBase` 保证中间层(registry、engine、config-loader)无需指定泛型。 + +#### Scenario: Checker 接口包含必要方法 +- **WHEN** 开发者实现一个新的 Checker +- **THEN** 该实现 MUST 提供 `type`(字符串标识)、`configKey`(配置分组名)、Authoring/Normalized TypeBox 配置契约、启动期语义校验、`resolve(target, context)`(解析配置并填充默认值)、`execute(target, ctx)`(执行探测返回 CheckResult)、`serialize(target)`(返回 target 展示文本和 config JSON)和 `buildDetail(observation)`(从 observation 构造人可读摘要) + +#### 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 假定配置已经通过 Normalized TypeBox/Ajv 契约校验和启动期语义校验,只负责默认值填充、路径解析和领域配置转换 + +#### Scenario: resolve 接收 Normalized target +- **WHEN** config-loader 调用 checker.resolve() +- **THEN** 传入的 target SHALL 已通过 Normalized schema 和语义校验,且不包含变量引用、Authoring expect primitive 简写或 Raw content/keyed DSL + +#### Scenario: type 与 configKey 默认一致 +- **WHEN** checker 定义 `type: "tcp"` +- **THEN** checker 的 `configKey` SHALL 默认使用 `"tcp"`,对应 target 的 `tcp` 分组 + +#### Scenario: 接口方法使用泛型约束 +- **WHEN** 开发者查看 `CheckerDefinition` 接口签名 +- **THEN** `resolve` 的返回值 SHALL 为 `TResolved`;`execute` 的参数 SHALL 为 `TResolved`;`serialize` 的参数 SHALL 为 `TResolved` + +#### Scenario: checker 实现无需手动断言 +- **WHEN** HttpChecker 实现 `CheckerDefinition` +- **THEN** `execute` 方法的 target 参数类型 SHALL 直接为 `ResolvedHttpTarget`,无需在方法内部使用 `as` 类型断言 + +#### Scenario: registry 使用默认泛型参数 +- **WHEN** CheckerRegistry 存储和返回 checker 实例 +- **THEN** registry 内部 SHALL 使用 `CheckerDefinition`(等价于 `CheckerDefinition`),实现类型擦除 + +#### Scenario: buildDetail 方法签名 +- **WHEN** 开发者实现 buildDetail 方法 +- **THEN** 方法签名 SHALL 为 `buildDetail(observation: Record): string | null`,接收 observation 对象并返回人可读摘要字符串或 null + +#### Scenario: buildDetail 由 API 层调用 +- **WHEN** API 序列化 CheckResult +- **THEN** API 层 SHALL 通过 registry 获取对应 checker 并调用 buildDetail,而非由 execute 方法直接生成 detail + +### Requirement: CheckerRegistry 注册中心 +系统 SHALL 提供 `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 在引擎执行检查时通过 `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 在配置加载流程中通过 `checkerRegistry` 发现已注册 checker,组合公共 TypeBox 契约与 checker 契约,并将 checker 专属语义校验和解析委托给对应 checker。公共配置校验 SHALL 仅保留公共语义校验(name 非空、name 不重复、group 类型、type 已注册等)和契约调度职责,不包含 checker 专属字段校验。 + +#### Scenario: 配置契约通过 registry 组合 +- **WHEN** config-loader 校验配置文件 +- **THEN** config-loader SHALL 从 `checkerRegistry` 获取已注册 checker 的契约片段,并用于校验 defaults 与 targets 中对应 checker 的配置形状 + +#### Scenario: Authoring 契约通过 registry 组合 +- **WHEN** 系统导出用户配置 JSON Schema +- **THEN** 配置 builder SHALL 从 `checkerRegistry` 获取已注册 checker 的 Authoring 契约片段 + +#### Scenario: Normalized 契约通过 registry 组合 +- **WHEN** config-loader 校验 normalized 配置对象 +- **THEN** config-loader SHALL 从 `checkerRegistry` 获取已注册 checker 的 Normalized 契约片段 + +#### 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 在存储同步 targets 时通过 `checkerRegistry.get(t.type).serialize(t)` 获取每个 target 的展示摘要和配置 JSON,替代函数中的类型分支。系统 SHALL 将 `targets.expect` 持久化为 null,不依赖 Raw expect 或 Resolved expect。 + +#### Scenario: 序列化委托 checker +- **WHEN** store 同步 targets 表 +- **THEN** store SHALL 对每个 target 调用对应 checker 的 `serialize()` 方法获取 `{ target, config }` + +#### Scenario: expect 持久化不依赖 rawExpect +- **WHEN** store 同步带 expect 的 target 到 targets 表 +- **THEN** store SHALL 将 `targets.expect` 写入 NULL,MUST NOT 依赖 `rawExpect` 或 Raw expect 快照 + +### Requirement: Checker resolve 只接收已去糖配置 +每个 checker 的 `resolve()` SHALL 接收已通过 Normalized schema 和语义校验的配置,不再包含变量引用、Authoring expect primitive 简写或 Raw content/keyed DSL。`config-loader` SHALL 继续通过 registry 委托 checker resolve,MUST NOT 在中间层理解 checker 专属 expect 字段。 + +#### Scenario: resolve 不再展开 Raw expect +- **WHEN** config-loader 解析一个带 `expect.durationMs: {equals: 1000}` 的 target +- **THEN** 对应 checker 的 resolved target SHALL 直接使用 Normalized expect 中的 `{equals: 1000}`,resolve 只负责默认值和运行期配置转换 + +#### Scenario: 中间层不感知 checker expect 字段 +- **WHEN** 新增 checker 定义自己的 Raw/Resolved expect 字段 +- **THEN** config-loader SHALL 只调用该 checker 的 `validate()` 和 `resolve()`,不新增 checker 类型分支 + +### Requirement: 共享 expect 断言函数 +系统 SHALL 提供可被多个 checker 复用的 expect 基础设施。共享基础设施 SHALL 包含 value expectation、content expectations、keyed expectations、status code 断言、headers keyed 断言、failure 构造和 ReDoS 校验。checker 专用的状态类断言 SHALL 保留在对应 checker 目录,或在多个 checker 复用时移动到共享模块。仅被单个 checker 使用且不属于通用 value/content/keyed/status/header 模型的断言模块 SHALL 位于该 checker 目录内。 + +#### Scenario: 共享 ValueExpectation 断言 +- **WHEN** 任何 checker 需要对数字、字符串、布尔或 JSON value 执行 matcher 匹配 +- **THEN** SHALL 调用共享 value expectation 工具执行 `equals`、`contains`、`regex`、`exists`、`empty`、`gt`、`gte`、`lt` 和 `lte` 语义 + +#### Scenario: 共享 ContentExpectations 断言 +- **WHEN** HTTP body、LLM output、Cmd stdout/stderr、UDP response 或 TCP banner 需要执行返回内容校验 +- **THEN** SHALL 调用共享 content expectations 工具,而不是在 checker 目录内复制 contains/regex/json/css/xpath 逻辑 + +#### Scenario: 共享 KeyedExpectations 断言 +- **WHEN** HTTP 或 LLM checker 需要校验响应 headers,或 DB checker 需要校验 rows 中的列值 +- **THEN** SHALL 调用共享 keyed expectations 工具,并按调用方规则决定 key 是否大小写敏感 + +#### Scenario: 共享 headers 断言 +- **WHEN** HTTP 或 LLM checker 需要校验响应 headers +- **THEN** SHALL 调用共享 header expectation 包装函数,确保 header key 大小写不敏感 + +#### Scenario: 共享 regex ReDoS 校验 +- **WHEN** 任一 matcher 或 content expectation 配置 `regex` +- **THEN** SHALL 调用共享 ReDoS 校验工具在启动期拒绝危险正则 + +#### Scenario: 共享 failure 构造 +- **WHEN** 任何 checker 需要构造 CheckFailure 对象 +- **THEN** SHALL 调用共享的 `errorFailure()` 或 `mismatchFailure()` 构造 CheckFailure,并保留 actual 截断策略 + +#### Scenario: 共享 status 断言 +- **WHEN** HTTP 或 LLM checker 需要校验响应状态码 +- **THEN** SHALL 复用共享 status code 断言函数,支持精确状态码和 `1xx` 到 `5xx` 范围模式 + +### Requirement: 超时控制由引擎注入 signal +Checker 实现的 `execute()` MUST 使用 `ctx.signal` 感知超时,不得自行创建 `AbortController` 或 `setTimeout` 用于超时控制。Cmd checker 和 ping 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 中记录超时错误 + +#### Scenario: Ping checker 响应 signal +- **WHEN** IcmpChecker 执行 ping 命令且 signal 被 abort +- **THEN** SHALL 调用 `proc.kill()` 终止 ping 子进程,并在 CheckResult 中记录超时错误 + +### Requirement: CheckFailure.phase 使用 string 类型 +`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 直接展示而不做类型判断 diff --git a/openspec/specs/checker-observation/spec.md b/openspec/specs/checker-observation/spec.md index 99b756a..a2788ec 100644 --- a/openspec/specs/checker-observation/spec.md +++ b/openspec/specs/checker-observation/spec.md @@ -48,14 +48,14 @@ UDP checker 的 observation SHALL 包含 responded(boolean)、durationMs(n - **WHEN** UDP 发送数据后超时未收到响应 - **THEN** observation SHALL 包含 responded=false -### Requirement: Ping Checker Observation -Ping/ICMP checker 的 observation SHALL 包含 alive(boolean)、transmitted(number)、received(number)、packetLoss(number)、avgLatencyMs(number | null)、maxLatencyMs(number | null)、minLatencyMs(number | null)、error(string | null)。API registry type SHALL 仍为 `ping`。 +### Requirement: ICMP Checker Observation +ICMP checker 的 observation SHALL 包含 alive(boolean)、transmitted(number)、received(number)、packetLoss(number)、avgLatencyMs(number | null)、maxLatencyMs(number | null)、minLatencyMs(number | null)、error(string | null)。 -#### Scenario: Ping 正常返回统计 +#### Scenario: ICMP 正常返回统计 - **WHEN** ping 命令成功执行并解析出统计数据 -- **THEN** observation SHALL 包含完整的 PingStats 字段 +- **THEN** observation SHALL 包含完整的 ICMPStats 字段 -#### Scenario: Ping 命令失败 +#### Scenario: ICMP 命令失败 - **WHEN** ping 命令未找到或超时 - **THEN** observation SHALL 为 null 或包含 error 字段 @@ -155,4 +155,4 @@ CheckResult SHALL 包含 `detail: string | null` 字段(替代原 statusDetail #### Scenario: 各 checker 的 detail 格式 - **WHEN** 各 checker 的 buildDetail 被调用 -- **THEN** HTTP SHALL 返回 `"HTTP {statusCode}"` 格式;TCP SHALL 返回连接状态和 banner 摘要;UDP SHALL 返回响应状态和大小摘要;Ping SHALL 返回存活状态、平均延迟和丢包率摘要;DB SHALL 返回连接状态或行数摘要;CMD SHALL 返回 `"exitCode={N}"` 格式;LLM SHALL 返回 provider、mode、状态码、finish 原因、输出长度和 token 用量摘要 +- **THEN** HTTP SHALL 返回 `"HTTP {statusCode}"` 格式;TCP SHALL 返回连接状态和 banner 摘要;UDP SHALL 返回响应状态和大小摘要;Ping SHALL 返回存活状态、平均延迟和丢包率摘要;ICMP SHALL 返回存活状态、平均延迟和丢包率摘要;DB SHALL 返回连接状态或行数摘要;CMD SHALL 返回 `"exitCode={N}"` 格式;LLM SHALL 返回 provider、mode、状态码、finish 原因、输出长度和 token 用量摘要 diff --git a/openspec/specs/checker-runner-abstraction/spec.md b/openspec/specs/checker-runner-abstraction/spec.md deleted file mode 100644 index 399bc72..0000000 --- a/openspec/specs/checker-runner-abstraction/spec.md +++ /dev/null @@ -1,241 +0,0 @@ -## Purpose - -定义 Checker 接口规范、注册机制、CheckerContext 上下文注入,以及共享 expect 断言函数的职责边界。此 capability 是 checker 系统的架构基础,不定义任何具体 checker 类型的业务行为。 - -## Requirements - -### Requirement: Checker 配置契约片段 -系统 SHALL 支持 checker 提供自身 TypeBox 配置契约片段,用于描述该 checker 的 target 领域分组和 expect 分组。checker 契约 SHALL 区分 Authoring schema 与 Normalized schema。Authoring schema SHALL 描述用户 YAML 可书写形式,包括变量引用和 expect 简写;Normalized schema SHALL 描述 `normalizeAuthoringConfig()` 输出形式,不接受变量引用、不接受 expect primitive 简写。公共配置加载模块 SHALL 通过 registry 获取已注册 checker 的契约片段,并按用途组合为运行时 Ajv 契约校验流程和外部 `probe-config.schema.json` 导出流程。 - -#### Scenario: HTTP checker 提供契约片段 -- **WHEN** HTTP checker 被注册 -- **THEN** registry SHALL 能提供 HTTP target 和 HTTP expect 的 TypeBox 契约片段 - -#### Scenario: Cmd checker 提供契约片段 -- **WHEN** Cmd checker 被注册 -- **THEN** registry SHALL 能提供 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 的 Authoring 契约片段,并将其组合进完整配置 schema - -#### Scenario: 运行时 schema 通过 registry 生成 -- **WHEN** config-loader 执行运行时 AJV 契约校验 -- **THEN** 校验流程 SHALL 从 registry 获取已注册 checker 的 Normalized 契约片段,并将其组合进完整配置 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`,包含 `type`、`configKey`、Authoring/Normalized TypeBox 配置契约、启动期语义校验、`resolve`、`execute`、`serialize`、`buildDetail` 成员。泛型参数 SHALL 约束 `execute` 和 `serialize` 方法的 target 参数类型,使 checker 实现内部获得编译期类型安全。默认泛型参数 `= ResolvedTargetBase` 保证中间层(registry、engine、config-loader)无需指定泛型。 - -#### Scenario: Checker 接口包含必要方法 -- **WHEN** 开发者实现一个新的 Checker -- **THEN** 该实现 MUST 提供 `type`(字符串标识)、`configKey`(配置分组名)、Authoring/Normalized TypeBox 配置契约、启动期语义校验、`resolve(target, context)`(解析配置并填充默认值)、`execute(target, ctx)`(执行探测返回 CheckResult)、`serialize(target)`(返回 target 展示文本和 config JSON)和 `buildDetail(observation)`(从 observation 构造人可读摘要) - -#### 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 假定配置已经通过 Normalized TypeBox/Ajv 契约校验和启动期语义校验,只负责默认值填充、路径解析和领域配置转换 - -#### Scenario: resolve 接收 Normalized target -- **WHEN** config-loader 调用 checker.resolve() -- **THEN** 传入的 target SHALL 已通过 Normalized schema 和语义校验,且不包含变量引用、Authoring expect primitive 简写或 Raw content/keyed DSL - -#### Scenario: type 与 configKey 默认一致 -- **WHEN** checker 定义 `type: "tcp"` -- **THEN** checker 的 `configKey` SHALL 默认使用 `"tcp"`,对应 target 的 `tcp` 分组和 defaults.tcp 分组 - -#### Scenario: 接口方法使用泛型约束 -- **WHEN** 开发者查看 `CheckerDefinition` 接口签名 -- **THEN** `resolve` 的返回值 SHALL 为 `TResolved`;`execute` 的参数 SHALL 为 `TResolved`;`serialize` 的参数 SHALL 为 `TResolved` - -#### Scenario: checker 实现无需手动断言 -- **WHEN** HttpChecker 实现 `CheckerDefinition` -- **THEN** `execute` 方法的 target 参数类型 SHALL 直接为 `ResolvedHttpTarget`,无需在方法内部使用 `as` 类型断言 - -#### Scenario: registry 使用默认泛型参数 -- **WHEN** CheckerRegistry 存储和返回 checker 实例 -- **THEN** registry 内部 SHALL 使用 `CheckerDefinition`(等价于 `CheckerDefinition`),实现类型擦除 - -#### Scenario: buildDetail 方法签名 -- **WHEN** 开发者实现 buildDetail 方法 -- **THEN** 方法签名 SHALL 为 `buildDetail(observation: Record): string | null`,接收 observation 对象并返回人可读摘要字符串或 null - -#### Scenario: buildDetail 由 API 层调用 -- **WHEN** API 序列化 CheckResult -- **THEN** API 层 SHALL 通过 registry 获取对应 checker 并调用 buildDetail,而非由 execute 方法直接生成 detail - -### 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: Authoring 契约通过 registry 组合 -- **WHEN** 系统导出用户配置 JSON Schema -- **THEN** 配置 builder SHALL 从 `checkerRegistry` 获取已注册 checker 的 Authoring 契约片段 - -#### Scenario: Normalized 契约通过 registry 组合 -- **WHEN** config-loader 校验 normalized 配置对象 -- **THEN** config-loader SHALL 从 `checkerRegistry` 获取已注册 checker 的 Normalized 契约片段 - -#### 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()` 中的类型分支。系统 SHALL 将 `targets.expect` 持久化为 null,不依赖 Raw expect 或 Resolved expect。 - -#### Scenario: 序列化委托 checker -- **WHEN** store 同步 targets 表 -- **THEN** store SHALL 对每个 target 调用对应 checker 的 `serialize()` 方法获取 `{ target, config }` - -#### Scenario: expect 持久化不依赖 rawExpect -- **WHEN** store 同步带 expect 的 target 到 targets 表 -- **THEN** store SHALL 将 `targets.expect` 写入 NULL,MUST NOT 依赖 `rawExpect` 或 Raw expect 快照 - -### Requirement: Checker resolve 只接收已去糖配置 -每个 checker 的 `resolve()` SHALL 接收已通过 Normalized schema 和语义校验的配置,不再包含变量引用、Authoring expect primitive 简写或 Raw content/keyed DSL。`config-loader` SHALL 继续通过 registry 委托 checker resolve,MUST NOT 在中间层理解 checker 专属 expect 字段。 - -#### Scenario: resolve 不再展开 Raw expect -- **WHEN** config-loader 解析一个带 `expect.durationMs: {equals: 1000}` 的 target -- **THEN** 对应 checker 的 resolved target SHALL 直接使用 Normalized expect 中的 `{equals: 1000}`,resolve 只负责默认值和运行期配置转换 - -#### Scenario: 中间层不感知 checker expect 字段 -- **WHEN** 新增 checker 定义自己的 Raw/Resolved expect 字段 -- **THEN** config-loader SHALL 只调用该 checker 的 `validate()` 和 `resolve()`,不新增 checker 类型分支 - -### Requirement: 共享 expect 断言函数 -系统 SHALL 在 `src/server/checker/expect/` 中提供可被多个 checker 复用的 expect 基础设施。共享基础设施 SHALL 包含 value expectation、content expectations、keyed expectations、status code 断言、headers keyed 断言、failure 构造和 ReDoS 校验。checker 专用的状态类断言 SHALL 保留在对应 checker 目录,或在多个 checker 复用时移动到共享模块。仅被单个 checker 使用且不属于通用 value/content/keyed/status/header 模型的断言模块 SHALL 位于该 checker 目录内。 - -#### Scenario: 共享 ValueExpectation 断言 -- **WHEN** 任何 checker 需要对数字、字符串、布尔或 JSON value 执行 matcher 匹配 -- **THEN** SHALL 调用共享 value expectation 工具执行 `equals`、`contains`、`regex`、`exists`、`empty`、`gt`、`gte`、`lt` 和 `lte` 语义 - -#### Scenario: 共享 ContentExpectations 断言 -- **WHEN** HTTP body、LLM output、Cmd stdout/stderr、UDP response 或 TCP banner 需要执行返回内容校验 -- **THEN** SHALL 调用共享 content expectations 工具,而不是在 checker 目录内复制 contains/regex/json/css/xpath 逻辑 - -#### Scenario: 共享 KeyedExpectations 断言 -- **WHEN** HTTP 或 LLM checker 需要校验响应 headers,或 DB checker 需要校验 rows 中的列值 -- **THEN** SHALL 调用共享 keyed expectations 工具,并按调用方规则决定 key 是否大小写敏感 - -#### Scenario: 共享 headers 断言 -- **WHEN** HTTP 或 LLM checker 需要校验响应 headers -- **THEN** SHALL 调用共享 header expectation 包装函数,确保 header key 大小写不敏感 - -#### Scenario: 共享 regex ReDoS 校验 -- **WHEN** 任一 matcher 或 content expectation 配置 `regex` -- **THEN** SHALL 调用共享 ReDoS 校验工具在启动期拒绝危险正则 - -#### Scenario: 共享 failure 构造 -- **WHEN** 任何 checker 需要构造 CheckFailure 对象 -- **THEN** SHALL 调用 `expect/failure.ts` 中的 `errorFailure()` 或 `mismatchFailure()`,并保留 actual 截断策略 - -#### Scenario: 共享 status 断言 -- **WHEN** HTTP 或 LLM checker 需要校验响应状态码 -- **THEN** SHALL 复用共享 status code 断言函数,支持精确状态码和 `1xx` 到 `5xx` 范围模式 - -### Requirement: 超时控制由引擎注入 signal -Checker 实现的 `execute()` MUST 使用 `ctx.signal` 感知超时,不得自行创建 `AbortController` 或 `setTimeout` 用于超时控制。Cmd checker 和 ping 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 中记录超时错误 - -#### Scenario: Ping checker 响应 signal -- **WHEN** IcmpChecker 执行 ping 命令且 signal 被 abort -- **THEN** SHALL 调用 `proc.kill()` 终止 ping 子进程,并在 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 直接展示而不做类型判断 diff --git a/openspec/specs/code-quality-gates/spec.md b/openspec/specs/code-quality-gates/spec.md index a7c90f2..ec6c657 100644 --- a/openspec/specs/code-quality-gates/spec.md +++ b/openspec/specs/code-quality-gates/spec.md @@ -1,6 +1,6 @@ ## Purpose -定义项目代码质量门禁、格式化检查、快速检查和完整验证命令的行为要求,确保开发者可以通过文档化命令稳定验证源码质量、基础测试和生产构建。 +定义项目代码质量门禁、格式化检查、Git hooks 自动化质量门禁、提交信息格式校验、快速检查和完整验证命令的行为要求,确保开发者可以通过文档化命令稳定验证源码质量、基础测试和生产构建。 ## Requirements @@ -55,6 +55,52 @@ - **WHEN** 不同开发者在不同操作系统上运行格式化(通过 ESLint 或独立 Prettier) - **THEN** 由于所有格式化参数均显式定义,产物 SHALL 完全一致 +### 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 跳过,不覆盖已有配置 + ### Requirement: TypeScript 未使用变量检测 项目 SHALL 启用 TypeScript `noUnusedLocals` 编译选项,将未使用的局部变量检测为编译错误。 @@ -115,7 +161,7 @@ - **THEN** 系统 SHALL 执行 `tests/` 目录下所有 `*.test.ts` 和 `*.test.tsx` 文件 ### Requirement: 完整验证命令 -项目 SHALL 提供完整 `verify` 命令,用于提交前或发布前验证当前源码、测试和生产构建。原 executable smoke test 暂时移除,后续通过独立变更重新设计。 +项目 SHALL 提供完整 `verify` 命令,用于提交前或发布前验证当前源码、测试和生产构建。 #### Scenario: 运行完整验证 - **WHEN** 开发者运行 `bun run verify` diff --git a/openspec/specs/commit-quality-gates/spec.md b/openspec/specs/commit-quality-gates/spec.md deleted file mode 100644 index 0fb58b9..0000000 --- a/openspec/specs/commit-quality-gates/spec.md +++ /dev/null @@ -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 跳过,不覆盖已有配置 diff --git a/openspec/specs/container-image-packaging/spec.md b/openspec/specs/container-image-packaging/spec.md index d4e37f0..8624e19 100644 --- a/openspec/specs/container-image-packaging/spec.md +++ b/openspec/specs/container-image-packaging/spec.md @@ -1,5 +1,3 @@ -# Container Image Packaging - ## Purpose Provide Alpine-based multi-stage Docker container image packaging for DiAL, enabling containerized deployment with musl executables, minimal runtime dependencies, and documented build/run workflows. diff --git a/openspec/specs/css-utility-classes/spec.md b/openspec/specs/css-utility-classes/spec.md deleted file mode 100644 index 06b9500..0000000 --- a/openspec/specs/css-utility-classes/spec.md +++ /dev/null @@ -1,140 +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: 100vh,width: 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: Header 右侧操作区类 -- **WHEN** HeadMenu operations 区域渲染主题模式选择器、刷新频率选择器和倒计时/按钮 -- **THEN** 容器 SHALL 使用 `.dashboard-header-controls` 类(display: inline-flex; align-items: center; gap: var(--td-comp-margin-s); margin-right: var(--td-comp-margin-xxl)) - -#### Scenario: Header 右侧操作区单行布局 -- **WHEN** Header 右侧操作区渲染 -- **THEN** `.dashboard-header-controls` SHALL 保持桌面单行水平布局,不为该区域新增窄屏换行或收纳规则 - -#### Scenario: 倒计时文本类 -- **WHEN** 倒计时文本或刷新按钮渲染 -- **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)),并使用 TDesign Statistic 组件自带的上下布局(title 在上、value 在下),通过 `.summary-stat-col` 类(text-align: center)实现内容居中。系统 SHALL NOT 使用已移除的 `.overview-stat-item` 和 `.overview-stat-value` 类。 - -### Requirement: NumberFlow 倒计时样式类 -styles.css SHALL 定义 NumberFlow 倒计时相关样式类,供 Header 倒计时组件使用。样式 SHALL 继承 TDesign 文本颜色或使用 TDesign CSS tokens,不得使用组件内联 `style`、硬编码色值、`!important` 或覆盖 TDesign 内部类名。 - -#### Scenario: 倒计时滚动容器类 -- **WHEN** Header 自动刷新倒计时以 NumberFlow 形式渲染 -- **THEN** 倒计时 SHALL 使用集中定义的滚动容器类,保持 inline-flex、baseline 对齐、nowrap 和 tabular-nums - -#### Scenario: 倒计时数字类 -- **WHEN** NumberFlow 数字渲染 -- **THEN** 数字 SHALL 使用集中定义的数字类配置 line-height 和 NumberFlow mask CSS 变量,减少滚动边缘突兀感 - -#### Scenario: 倒计时单位类 -- **WHEN** 分钟或秒单位文本渲染 -- **THEN** 单位 SHALL 使用集中定义的单位类与数字保持基线对齐,并继承当前 TDesign 文本色 - -#### Scenario: 不使用内联样式 -- **WHEN** RefreshCountdown 组件渲染 NumberFlow 倒计时 -- **THEN** 组件 SHALL 通过 `className` 引用 styles.css 中的样式类,不得通过 React `style` prop 设置 NumberFlow 展示样式 - -### Requirement: 异常行背景类 -styles.css SHALL 定义 DOWN 行的背景色和左侧竖线,使用安全选择器且不使用 `!important`。 - -#### 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 状态色 diff --git a/openspec/specs/dashboard-layout/spec.md b/openspec/specs/dashboard-layout/spec.md deleted file mode 100644 index 1dc2b34..0000000 --- a/openspec/specs/dashboard-layout/spec.md +++ /dev/null @@ -1,51 +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: Header 右侧操作区 -- **WHEN** Dashboard 页面渲染 -- **THEN** HeadMenu operations 区域 SHALL 包含主题模式 RadioGroup、刷新频率 RadioGroup 和倒计时文本(或手动刷新按钮),三者水平排列并垂直居中 - -#### Scenario: 主题选择器位置 -- **WHEN** HeadMenu operations 区域渲染 -- **THEN** 主题模式 RadioGroup SHALL 位于刷新频率 RadioGroup 前面 - -#### Scenario: Header 右侧操作区位置 -- **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)`,内容卡片浮于当前 TDesign 主题背景之上 - -### Requirement: Header 版本号展示 -Dashboard SHALL 在顶部导航栏品牌区域展示当前运行实例的应用版本号,版本号 SHALL 使用 `/api/meta` 返回的 `version` 字段,并以 `v` 前缀显示。 - -#### Scenario: Meta 数据已加载 -- **WHEN** Dashboard 成功获取 `/api/meta` 且返回 `version: "0.1.0"` -- **THEN** Header 品牌区域 SHALL 展示 `v0.1.0` - -#### Scenario: Meta 数据尚未加载或请求失败 -- **WHEN** Dashboard 尚未获取到有效 `version` -- **THEN** Header SHALL 保持可用并省略版本号占位,不影响品牌名、主题模式选择器、刷新频率选择器和倒计时/刷新按钮渲染 - -#### Scenario: 版本号视觉层级 -- **WHEN** Header 展示版本号 -- **THEN** 版本号 SHALL 使用次级文本样式弱展示,不得使用内联 style、硬编码色值、`!important` 或覆盖 TDesign 内部类名 diff --git a/openspec/specs/dashboard/spec.md b/openspec/specs/dashboard/spec.md new file mode 100644 index 0000000..d830aa3 --- /dev/null +++ b/openspec/specs/dashboard/spec.md @@ -0,0 +1,327 @@ +## Purpose + +定义拨测系统前端 Dashboard 页面:页面骨架布局、刷新频率控制与倒计时、CSS 工具类基础设施、总览统计卡片、Dashboard 数据查询、加载和错误状态处理。分组表格布局见 `target-table`,目标详情 Drawer 见 `target-detail-drawer`,数据轮询和缓存见 `tanstack-query-data-layer`。 + +## 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: Header 右侧操作区 +- **WHEN** Dashboard 页面渲染 +- **THEN** HeadMenu operations 区域 SHALL 包含主题模式 RadioGroup、刷新频率 RadioGroup 和倒计时文本(或手动刷新按钮),三者水平排列并垂直居中 + +#### Scenario: 主题选择器位置 +- **WHEN** HeadMenu operations 区域渲染 +- **THEN** 主题模式 RadioGroup SHALL 位于刷新频率 RadioGroup 前面 + +#### Scenario: Header 右侧操作区位置 +- **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)`,内容卡片浮于当前 TDesign 主题背景之上 + +### Requirement: Header 版本号展示 +Dashboard SHALL 在顶部导航栏品牌区域展示当前运行实例的应用版本号,版本号 SHALL 使用 `/api/meta` 返回的 `version` 字段,并以 `v` 前缀显示。 + +#### Scenario: Meta 数据已加载 +- **WHEN** Dashboard 成功获取 `/api/meta` 且返回 `version: "0.1.0"` +- **THEN** Header 品牌区域 SHALL 展示 `v0.1.0` + +#### Scenario: Meta 数据尚未加载或请求失败 +- **WHEN** Dashboard 尚未获取到有效 `version` +- **THEN** Header SHALL 保持可用并省略版本号占位,不影响品牌名、主题模式选择器、刷新频率选择器和倒计时/刷新按钮渲染 + +#### Scenario: 版本号视觉层级 +- **WHEN** Header 展示版本号 +- **THEN** 版本号 SHALL 使用次级文本样式弱展示,不得使用内联 style、硬编码色值、`!important` 或覆盖 TDesign 内部类名 + +### Requirement: 刷新频率选择器 +HeadMenu operations 区域 SHALL 提供 RadioGroup 组件供用户选择刷新频率。 + +#### Scenario: RadioGroup 渲染 +- **WHEN** Dashboard 页面渲染 +- **THEN** HeadMenu operations 区域 SHALL 显示 RadioGroup(theme="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。自动倒计时数字 SHALL 使用 `@number-flow/react` 提供滚动过渡,非倒计时状态 SHALL 保持普通文本或按钮语义。 + +#### 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: NumberFlow 数字滚动 +- **WHEN** 自动刷新模式下已完成首次刷新且当前未处于刷新中状态 +- **THEN** 倒计时数字 SHALL 使用 `@number-flow/react` 的 `NumberFlow` 渲染,并使用向下滚动趋势表达倒计时递减 + +#### Scenario: 秒级间隔格式 +- **WHEN** 自动刷新间隔小于 60 秒 +- **THEN** 倒计时 SHALL 显示为"xx秒"格式(如"26秒") + +#### Scenario: 分钟级稳定格式 +- **WHEN** 自动刷新间隔大于等于 60 秒 +- **THEN** 倒计时 SHALL 显示为"x分xx秒"格式,秒数 SHALL 固定为两位(如"4分30秒"、"0分09秒") + +#### Scenario: 时间数字边界 +- **WHEN** 分钟级倒计时中的秒数在 59 到 00 边界变化 +- **THEN** 秒数十位 SHALL 按时间显示规则限制在 0 到 5 之间滚动 + +#### Scenario: 无前缀 +- **WHEN** 倒计时显示 +- **THEN** 可见倒计时文本 SHALL 不包含任何前缀(如"下一次刷新:"),直接显示时间 + +#### Scenario: 可访问文本 +- **WHEN** NumberFlow 倒计时渲染 +- **THEN** 倒计时容器 SHALL 暴露与当前倒计时等价的可访问文本,供测试和辅助技术读取 + +#### Scenario: 刷新中状态 +- **WHEN** 数据正在刷新(isFetching=true 且 isLoading=false) +- **THEN** 倒计时文本 SHALL 显示为"刷新中..." + +#### Scenario: 等待首次刷新状态 +- **WHEN** 自动刷新模式下尚未完成首次刷新 +- **THEN** 倒计时文本 SHALL 显示为"等待首次刷新" + +### Requirement: App 组件渲染隔离 +App 组件 SHALL NOT 持有任何高频更新的 state(如每秒更新的时钟),确保 App 的重渲染频率与数据刷新频率一致(默认 30 秒一次)。 + +#### 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 保持布局稳定,避免内容变化导致的抖动。NumberFlow 倒计时 SHALL 通过分组同步和等宽数字样式降低位数、单位和动画变化带来的布局偏移。 + +#### Scenario: 数字等宽 +- **WHEN** 倒计时数字变化 +- **THEN** 容器和 NumberFlow 倒计时 SHALL 使用 tabular-nums 字体特性,确保数字等宽不抖动 + +#### Scenario: NumberFlow 分组同步 +- **WHEN** 分钟级倒计时同时渲染分钟和秒数 +- **THEN** 分钟和秒数 SHALL 使用 `NumberFlowGroup` 同步布局变化 + +#### Scenario: 格式切换不抖动 +- **WHEN** 倒计时在按钮、秒级文本和分钟级文本之间切换 +- **THEN** 容器 SHALL 使用 min-width 确保最小宽度,避免 RadioGroup 位移 + +### 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: 100vh,width: 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: Header 右侧操作区类 +- **WHEN** HeadMenu operations 区域渲染主题模式选择器、刷新频率选择器和倒计时/按钮 +- **THEN** 容器 SHALL 使用 `.dashboard-header-controls` 类(display: inline-flex; align-items: center; gap: var(--td-comp-margin-s); margin-right: var(--td-comp-margin-xxl)) + +#### Scenario: Header 右侧操作区单行布局 +- **WHEN** Header 右侧操作区渲染 +- **THEN** `.dashboard-header-controls` SHALL 保持桌面单行水平布局,不为该区域新增窄屏换行或收纳规则 + +#### Scenario: 倒计时文本类 +- **WHEN** 倒计时文本或刷新按钮渲染 +- **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)),并使用 TDesign Statistic 组件自带的上下布局(title 在上、value 在下),通过 `.summary-stat-col` 类(text-align: center)实现内容居中。系统 SHALL NOT 使用已移除的 `.overview-stat-item` 和 `.overview-stat-value` 类。 + +### Requirement: NumberFlow 倒计时样式类 +styles.css SHALL 定义 NumberFlow 倒计时相关样式类,供 Header 倒计时组件使用。样式 SHALL 继承 TDesign 文本颜色或使用 TDesign CSS tokens,不得使用组件内联 `style`、硬编码色值、`!important` 或覆盖 TDesign 内部类名。 + +#### Scenario: 倒计时滚动容器类 +- **WHEN** Header 自动刷新倒计时以 NumberFlow 形式渲染 +- **THEN** 倒计时 SHALL 使用集中定义的滚动容器类,保持 inline-flex、baseline 对齐、nowrap 和 tabular-nums + +#### Scenario: 倒计时数字类 +- **WHEN** NumberFlow 数字渲染 +- **THEN** 数字 SHALL 使用集中定义的数字类配置 line-height 和 NumberFlow mask CSS 变量,减少滚动边缘突兀感 + +#### Scenario: 倒计时单位类 +- **WHEN** 分钟或秒单位文本渲染 +- **THEN** 单位 SHALL 使用集中定义的单位类与数字保持基线对齐,并继承当前 TDesign 文本色 + +#### Scenario: 不使用内联样式 +- **WHEN** RefreshCountdown 组件渲染 NumberFlow 倒计时 +- **THEN** 组件 SHALL 通过 `className` 引用 styles.css 中的样式类,不得通过 React `style` prop 设置 NumberFlow 展示样式 + +### Requirement: 异常行背景类 +styles.css SHALL 定义 DOWN 行的背景色和左侧竖线,使用安全选择器且不使用 `!important`。 + +#### 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 状态色 + +### 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)显示错误提示 + diff --git a/openspec/specs/data-retention/spec.md b/openspec/specs/data-retention/spec.md deleted file mode 100644 index 885bf8a..0000000 --- a/openspec/specs/data-retention/spec.md +++ /dev/null @@ -1,47 +0,0 @@ -## Purpose - -定义历史拨测数据的自动清理机制:可配置的保留时长和定时清理调度。 - -## Requirements - -### Requirement: 数据保留配置 -系统 SHALL 支持通过 `server.storage.retention` 配置项指定历史数据保留时长,格式为持续时间字符串(`<数字><单位>`,单位支持 `d`/`h`/`m`)。 - -#### Scenario: 配置 7 天保留 -- **WHEN** 配置文件中 `server.storage.retention` 设置为 `"7d"` -- **THEN** 系统 SHALL 保留最近 7 天的检查结果,清理更早的数据 - -#### Scenario: 配置小时级保留 -- **WHEN** 配置文件中 `server.storage.retention` 设置为 `"24h"` -- **THEN** 系统 SHALL 保留最近 24 小时的检查结果 - -#### Scenario: 未配置 retention -- **WHEN** 配置文件中未指定 `server.storage.retention` -- **THEN** 系统 SHALL 使用默认值 `"7d"` - -#### Scenario: 无效 retention 格式 -- **WHEN** 配置文件中 `server.storage.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** 清理定时器触发且 check_results 过期清理执行完毕 -- **THEN** 系统 SHALL 删除 `targets` 表中 `active = 0` 且在 `check_results` 表中不存在任何关联记录的目标行 - -#### Scenario: 引擎停止时清除定时器 -- **WHEN** ProbeEngine.stop() 被调用 -- **THEN** 系统 SHALL 清除清理定时器,不再执行后续清理 - -#### Scenario: retention 为 0 时不清理 -- **WHEN** 配置的 retention 解析为 0 毫秒 -- **THEN** 系统 SHALL 不注册清理定时器,数据永久保留 diff --git a/openspec/specs/probe-data-store/spec.md b/openspec/specs/data-store/spec.md similarity index 70% rename from openspec/specs/probe-data-store/spec.md rename to openspec/specs/data-store/spec.md index 01cb4ee..970ab5e 100644 --- a/openspec/specs/probe-data-store/spec.md +++ b/openspec/specs/data-store/spec.md @@ -1,6 +1,6 @@ ## Purpose -定义基于 SQLite 的拨测数据持久化存储:targets 同步(含分组信息)、check_results 追加写入、Dashboard 和 Metrics 数据查询支持、延迟百分位取数、时间范围和分页查询、索引与聚合查询。 +定义基于 SQLite 的拨测数据持久化存储:targets 同步(含分组信息)、check_results 追加写入、Dashboard 和 Metrics 数据查询支持、延迟百分位取数、时间范围和分页查询、索引与聚合查询、批量查询方法(getLatestChecksMap、getAllTargetStats、getAllRecentSamples)、N+1 查询优化,以及 prepared statement 使用 query() 缓存。 ## Requirements @@ -225,3 +225,89 @@ ProbeStore SHALL 提供 `prune(retentionMs: number)` 方法,删除超过保留 #### Scenario: 活跃目标永不清理 - **WHEN** 存在 active=1 的 target 且该 target 在 check_results 表中无关联记录 - **THEN** 系统 SHALL NOT 删除该 target 行 + +### Requirement: 批量查询最新检查结果 +系统 SHALL 提供 `getLatestChecksMap` 方法,通过单次 SQL 查询获取所有 target 的最新一次 check 结果,返回 Map 结构供调用方按 target_id 索引。 + +#### Scenario: 获取所有目标的最新检查 +- **WHEN** 调用 `getLatestChecksMap()` +- **THEN** 系统 SHALL 执行子查询找到每个 target_id 的 MAX(timestamp),再 JOIN 回 check_results 获取完整行,返回 `Map` + +#### Scenario: 批量查询目标无历史记录 +- **WHEN** 某 target 在 check_results 表中无任何记录 +- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key + +### Requirement: 批量查询目标统计 +系统 SHALL 提供 `getAllTargetStats` 方法,通过单次 SQL GROUP BY 聚合查询获取所有 target 的拨测统计(totalChecks 和 availability)。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` + +#### Scenario: 统计查询目标无历史记录 +- **WHEN** 某 target 在 check_results 表中无任何记录 +- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key + +#### Scenario: availability 精度 +- **WHEN** 计算 availability(upCount / totalChecks * 100) +- **THEN** 结果 SHALL 使用 `Math.round(value * 100) / 100` 四舍五入保留两位小数,与 `getTargetStats` 方法一致 + +### Requirement: 批量查询所有目标的最近采样数据 +系统 SHALL 提供 `getAllRecentSamples(limit: number)` 方法,通过单次 SQL 查询获取所有 target 的最近 N 条采样数据,返回 `Map>` 结构。 + +#### 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 降序排列(最新在前) + +#### Scenario: 采样目标无数据返回空数组 +- **WHEN** 某 target 在 getAllRecentSamples 返回的 Map 中不存在 +- **THEN** 该 target 的 recentSamples SHALL 为空数组 + +### 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 逐条查询数据库 + +### Requirement: prepared statement 使用 query() 缓存 +ProbeStore 中不涉及事务内复用的单次读/写操作 SHALL 使用 `this.db.query()` 而非 `this.db.prepare()`,利用 bun:sqlite 内置的 statement 缓存机制。 + +#### Scenario: insertCheckResult 使用 query +- **WHEN** 写入一条检查结果 +- **THEN** `insertCheckResult` SHALL 使用 `this.db.query("INSERT INTO ...").run(...)` 而非 `this.db.prepare("INSERT INTO ...").run(...)` + +#### Scenario: getHistory 查询使用 query +- **WHEN** 查询历史记录(包括 COUNT 和分页查询) +- **THEN** `getHistory` SHALL 使用 `this.db.query(...)` 而非 `this.db.prepare(...)` + +#### Scenario: getTargetStats 查询使用 query +- **WHEN** 查询单目标统计 +- **THEN** `getTargetStats` SHALL 使用 `this.db.query(...)` 而非 `this.db.prepare(...)` + +#### Scenario: getTrend 查询使用 query +- **WHEN** 查询趋势数据 +- **THEN** `getTrend` SHALL 使用 `this.db.query(...)` 而非 `this.db.prepare(...)` + +#### Scenario: getRecentSamples 查询使用 query +- **WHEN** 查询采样数据 +- **THEN** `getRecentSamples` SHALL 使用 `this.db.query(...)` 而非 `this.db.prepare(...)` + +#### Scenario: syncTargets 事务保持 prepare(例外) +- **WHEN** 同步 targets 配置(事务内多次复用 insertStmt/updateStmt/deleteStmt) +- **THEN** `syncTargets` 方法 SHALL 保持使用 `this.db.prepare()`,因需要在事务闭包内持有引用 diff --git a/openspec/specs/eslint-prettier-integration/spec.md b/openspec/specs/eslint-prettier-integration/spec.md deleted file mode 100644 index f27a952..0000000 --- a/openspec/specs/eslint-prettier-integration/spec.md +++ /dev/null @@ -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% diff --git a/openspec/specs/expect-body-checkers/spec.md b/openspec/specs/expect-body-checkers/spec.md deleted file mode 100644 index 38b26c1..0000000 --- a/openspec/specs/expect-body-checkers/spec.md +++ /dev/null @@ -1,209 +0,0 @@ -## Purpose - -定义 HTTP 拨测中响应体校验方法集(contains/regex/json/css/xpath)、操作符系统和响应头校验的行为规范。 - -## Requirements - -### Requirement: 响应体多种校验方法 -系统 SHALL 支持通过共享 `ContentExpectations` 对 HTTP 响应体进行有序内容校验。`expect.body` MUST 为 expectation 数组。每个 expectation SHALL 为直接 `ValueMatcher`,或 `json`、`css`、`xpath` extractor expectation 之一。直接 matcher SHALL 作用于完整响应体文本。`json` SHALL 解析响应体为 JSON 并用 JSONPath 子集提取值。`css` SHALL 使用 CSS selector 从 HTML 中提取元素文本或属性。`xpath` SHALL 使用 XPath 从 XML/HTML 中提取节点值。Extractor 未配置 matcher 时,resolve 阶段 SHALL 将其 matcher 物化为 `{exists: true}`。 - -#### Scenario: contains 子串匹配 -- **WHEN** HTTP target 配置 `expect.body: [{contains: "healthy"}]`,且响应体包含 `"healthy"` -- **THEN** 系统 SHALL 判定该 body expectation 通过 - -#### Scenario: regex 正则匹配 -- **WHEN** HTTP target 配置 `expect.body: [{regex: '"status"\\s*:\\s*"ok"'}]`,且响应体匹配该正则 -- **THEN** 系统 SHALL 判定该 body expectation 通过 - -#### Scenario: json JSONPath 等值匹配 -- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.status", equals: "ok"}}]`,且响应 JSON 中 `$.status` 值为 `"ok"` -- **THEN** 系统 SHALL 判定该 body expectation 通过 - -#### Scenario: json JSONPath 存在性匹配 -- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.status"}}]`,且响应 JSON 中存在 `$.status` -- **THEN** resolve 阶段 SHALL 将该 expectation 按 `exists: true` 语义物化并在运行期判定通过 - -#### Scenario: css 选择器匹配 -- **WHEN** HTTP target 配置 `expect.body: [{css: {selector: "div#health", equals: "OK"}}]`,且 HTML 中存在 `div#health` 元素文本为 `"OK"` -- **THEN** 系统 SHALL 判定该 body expectation 通过 - -#### Scenario: css 选择器匹配属性值 -- **WHEN** HTTP target 配置 css expectation 带 `attr: "content"` 用于提取属性,且属性值匹配期望 -- **THEN** 系统 SHALL 判定该 body expectation 通过 - -#### Scenario: xpath 表达式匹配 -- **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/root/status/text()", equals: "ok"}}]`,且 XML 中 `/root/status` 节点文本为 `"ok"` -- **THEN** 系统 SHALL 判定该 body expectation 通过 - -#### Scenario: 提取器无匹配目标失败 -- **WHEN** HTTP target 配置了 json、css 或 xpath expectation 且对应路径、元素或节点不存在,并且 expectation 未配置 `exists: false` -- **THEN** 系统 SHALL 判定 matched 为 false - -### Requirement: 多种 body 校验方法 AND 组合 -系统 SHALL 支持在 `expect.body` 数组中同时配置多条内容 expectation,所有 expectation 均通过时 matched 方为 true。系统 SHALL 按数组顺序执行 expectation,任一 expectation 失败后 MUST NOT 继续执行后续 expectation。 - -#### Scenario: 多种方法全部通过 -- **WHEN** HTTP target 的 `expect.body` 数组依次配置 contains、json、regex,且全部通过 -- **THEN** 系统 SHALL 判定 matched 为 true - -#### Scenario: 多种方法任一失败 -- **WHEN** HTTP target 的 `expect.body` 数组第一条 contains 不通过,后续还有 json expectation -- **THEN** 系统 SHALL 判定 matched 为 false,且不再检查后续 json expectation - -#### Scenario: 直接 matcher 多字段组合 -- **WHEN** HTTP target 配置 `expect.body: [{contains: "ok", regex: "status"}]`,且响应体同时满足 contains 和 regex -- **THEN** 系统 SHALL 判定该 expectation 通过 - -### Requirement: 操作符系统 -系统 SHALL 支持通过共享 `ValueMatcher` 对提取值和文本值进行比较:`equals`(深度等值)、`contains`(子串包含)、`regex`(正则匹配)、`empty`(空值判断)、`exists`(存在性判断)、`gte`/`lte`/`gt`/`lt`(数值比较)。系统 MUST NOT 支持旧 `match` 字段。 - -#### Scenario: equals 匹配 JSON value -- **WHEN** 配置 `{equals: {status: "ok"}}`,且实际值为相同 JSON object -- **THEN** 系统 SHALL 使用深度相等判定通过 - -#### Scenario: 显式 contains matcher -- **WHEN** 配置 `{contains: "success"}`,且实际值字符串化后包含 `"success"` -- **THEN** 系统 SHALL 判定该规则通过 - -#### Scenario: 显式 regex matcher -- **WHEN** 配置 `{regex: '\\d+\\.\\d+\\.\\d+'}`,且实际值字符串化后匹配该正则 -- **THEN** 系统 SHALL 判定该规则通过 - -#### Scenario: empty matcher 判断为空 -- **WHEN** 配置 `{empty: true}`,且实际值为空数组 `[]` -- **THEN** 系统 SHALL 判定该规则通过 - -#### Scenario: exists matcher 判断不存在 -- **WHEN** 配置 `{exists: false}`,且实际值为 `undefined` -- **THEN** 系统 SHALL 判定该规则通过 - -#### Scenario: 数值比较 matcher -- **WHEN** 配置 `{gt: 0, lt: 1000}`,且实际值为 `500` -- **THEN** 系统 SHALL 对同一字段进行多 matcher 复合比较,全部通过则该规则通过 - -### Requirement: 响应头校验 -系统 SHALL 支持通过共享 `KeyedExpectations` 配置 `expect.headers` 对 HTTP 响应头进行键值断言,header 名称匹配 MUST 不区分大小写。header 期望值 MUST 为 `RawValueExpectation`,primitive 字面量 SHALL 在 resolve 阶段等价为 `{equals: }`。 - -#### Scenario: 响应头字面量匹配 -- **WHEN** HTTP target 配置 `expect.headers: {"Content-Type": "application/json"}`,且响应包含该 header 且值精确匹配 -- **THEN** 系统 SHALL 判定 headers 阶段通过 - -#### Scenario: 响应头 matcher 匹配 -- **WHEN** HTTP target 配置 `expect.headers: {"Content-Type": {contains: "application/json"}}`,且响应 header 值包含该文本 -- **THEN** 系统 SHALL 判定 headers 阶段通过 - -#### Scenario: 响应头缺失 -- **WHEN** HTTP target 配置了某个 header 但响应中不存在该 header,且未配置 `exists: false` -- **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 SHALL 只允许 `status`、`headers`、`body` 和 `durationMs` 字段。`expect.body` MUST 为 `RawContentExpectations` 数组。直接 `ValueMatcher` 对象 MUST 至少包含一个合法 matcher。Extractor expectation MUST 只包含 `json`、`css`、`xpath` 中的一种 extractor。Extractor 内部可以不配置 matcher,并 SHALL 在 resolve 阶段以存在性 matcher 作为通过语义。`equals` matcher SHALL 支持任意 JSON value,包括数组和对象。系统 SHALL 在启动期对所有 `regex` 执行静态 ReDoS 检测。语义校验 MUST NOT 修改 Raw HTTP expect 输入。 - -#### Scenario: body rule 使用 regex 字段 -- **WHEN** HTTP target 配置 `expect.body: [{regex: "ok|healthy"}]` 且 regex 可编译且无 ReDoS 风险 -- **THEN** 系统 SHALL 接受该配置,并在运行期按 regex expectation 匹配响应体 - -#### Scenario: body rule 不支持 match 字段 -- **WHEN** HTTP target 配置 `expect.body: [{match: "ok"}]` -- **THEN** 系统 SHALL 在启动期配置校验失败,提示 `match` 是未知字段或不支持字段 - -#### Scenario: body rule 多 extractor 非法 -- **WHEN** HTTP target 的同一条 body expectation 同时配置 `json` 和 `css` -- **THEN** 系统 SHALL 在启动期配置校验失败 - -#### Scenario: matcher regex 正则非法 -- **WHEN** HTTP target 的 expect.headers、body 直接 matcher 或 extractor 内部 matcher 配置了不可编译的 regex -- **THEN** 系统 SHALL 在启动期配置校验失败 - -#### Scenario: matcher 数值比较类型非法 -- **WHEN** HTTP target 的 matcher 配置 gt、gte、lt 或 lte,且对应值不是有限数字 -- **THEN** 系统 SHALL 在启动期配置校验失败 - -#### Scenario: matcher 布尔类型非法 -- **WHEN** HTTP target 的 matcher 配置 empty 或 exists,且对应值不是布尔值 -- **THEN** 系统 SHALL 在启动期配置校验失败 - -#### Scenario: JSONPath 子集非法 -- **WHEN** HTTP target 的 json body expectation path 不符合系统支持的 JSONPath 子集 -- **THEN** 系统 SHALL 在启动期配置校验失败 - -#### Scenario: matcher 未知字段非法 -- **WHEN** HTTP target 的 matcher 配置了 `foo: "bar"` 等未知字段 -- **THEN** 系统 SHALL 在启动期配置校验失败 - -#### Scenario: durationMs matcher 非法 -- **WHEN** HTTP target 配置 `expect.durationMs` 不是合法 `RawValueExpectation` 或其中数值 matcher 不是有限数字 -- **THEN** 系统 SHALL 在启动期配置校验失败 - -### Requirement: HTTP body 运行期失败结构化 -系统 SHALL 将 HTTP body 运行期失败记录为结构化 CheckFailure,并保留与具体 expectation 相关的 phase 和 path。响应内容不符合配置 SHALL 记录为 mismatch;响应内容无法按配置解析或解码 SHALL 记录为 error。 - -#### Scenario: JSON 响应不是合法 JSON -- **WHEN** HTTP target 配置 json body expectation,但响应体不是合法 JSON -- **THEN** 系统 SHALL 记录 `failure.kind="error"`、`failure.phase="body"`,且 failure.path SHALL 指向对应 json expectation - -#### Scenario: CSS selector 无匹配元素 -- **WHEN** HTTP target 配置 css body expectation,但响应 HTML 中无匹配元素 -- **THEN** 系统 SHALL 记录 `failure.kind="mismatch"`、`failure.phase="body"`,且 failure.path SHALL 指向对应 css expectation - -#### Scenario: XPath 无匹配节点 -- **WHEN** HTTP target 配置 xpath body expectation,但响应 XML/HTML 中无匹配节点 -- **THEN** 系统 SHALL 记录 `failure.kind="mismatch"`、`failure.phase="body"`,且 failure.path SHALL 指向对应 xpath expectation diff --git a/openspec/specs/expect-rule-system/spec.md b/openspec/specs/expect-rule-system/spec.md index 23cc64e..23e4d7f 100644 --- a/openspec/specs/expect-rule-system/spec.md +++ b/openspec/specs/expect-rule-system/spec.md @@ -1,6 +1,6 @@ ## Purpose -定义共享 expect 断言规则系统的核心概念和基础设施:ValueMatcher 统一匹配器、ContentExpectations 内容断言数组、KeyedExpectations 键控断言数组、以及相关的启动期校验和失败路径规范。 +定义共享 expect 断言规则系统的核心概念和基础设施:ValueMatcher 统一匹配器、ContentExpectations 内容断言数组、KeyedExpectations 键控断言数组、HTTP 场景特有规则(状态码范围匹配、body 运行期失败结构化)、以及相关的启动期校验和失败路径规范。 ## Requirements @@ -249,3 +249,53 @@ #### Scenario: actual 截断 - **WHEN** matcher 失败时 actual 字符串长度超过 200 字符 - **THEN** 系统 SHALL 使用现有截断策略保存 failure.actual,避免历史记录写入过长内容 + +### 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 body 运行期失败结构化 +系统 SHALL 将 HTTP body 运行期失败记录为结构化 CheckFailure,并保留与具体 expectation 相关的 phase 和 path。响应内容不符合配置 SHALL 记录为 mismatch;响应内容无法按配置解析或解码 SHALL 记录为 error。 + +#### Scenario: JSON 响应不是合法 JSON +- **WHEN** HTTP target 配置 json body expectation,但响应体不是合法 JSON +- **THEN** 系统 SHALL 记录 `failure.kind="error"`、`failure.phase="body"`,且 failure.path SHALL 指向对应 json expectation + +#### Scenario: CSS selector 无匹配元素 +- **WHEN** HTTP target 配置 css body expectation,但响应 HTML 中无匹配元素 +- **THEN** 系统 SHALL 记录 `failure.kind="mismatch"`、`failure.phase="body"`,且 failure.path SHALL 指向对应 css expectation + +#### Scenario: XPath 无匹配节点 +- **WHEN** HTTP target 配置 xpath body expectation,但响应 XML/HTML 中无匹配节点 +- **THEN** 系统 SHALL 记录 `failure.kind="mismatch"`、`failure.phase="body"`,且 failure.path SHALL 指向对应 xpath expectation diff --git a/openspec/specs/frontend-development-workflow/spec.md b/openspec/specs/frontend-development-workflow/spec.md index 177bd91..a5f821b 100644 --- a/openspec/specs/frontend-development-workflow/spec.md +++ b/openspec/specs/frontend-development-workflow/spec.md @@ -1,6 +1,6 @@ ## Purpose -定义基于 Vite dev server + Bun API server 的前端开发工作流、开发期 API 访问和共享契约的行为要求。 +定义基于 Vite dev server + Bun API server 的前端开发工作流、生产前端构建与 code splitting 策略、开发期 API 访问和共享契约的行为要求。 ## Requirements @@ -15,6 +15,66 @@ - **WHEN** 开发者运行前端生产构建命令 - **THEN** 系统 SHALL 通过 Vite build(Rolldown)产出优化的前端静态资源到 `dist/web/` +### 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,并通过 `React.lazy()` 动态导入实现按需加载。 + +#### 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 + +#### Scenario: TargetDetailDrawer 延迟加载 +- **WHEN** Vite 构建完成 +- **THEN** `TargetDetailDrawer` 及其依赖(recharts、D3、DateRangePicker 等)SHALL 通过 `React.lazy()` 动态导入,被 Rolldown 自动拆分为异步 chunk,不包含在初始加载的 JS 中 + +#### Scenario: Drawer 首次渲染无闪烁 +- **WHEN** 用户首次点击目标触发 Drawer 渲染 +- **THEN** Drawer SHALL 通过 `` 包裹,利用其默认 visible=false 状态避免加载期间的视觉闪烁 + +### Requirement: CSS 处理 +系统 SHALL 通过 Vite 处理 CSS 导入,产出独立的 CSS 文件。TDesign 组件样式 SHALL 保持全量导入方式。 + +#### Scenario: CSS 文件产出 +- **WHEN** Vite 构建完成 +- **THEN** 所有 CSS 导入 SHALL 被提取为独立的 `.css` 文件到 `assets/` 目录 + +#### Scenario: CSS 压缩 +- **WHEN** Vite 执行生产构建 +- **THEN** 产出的 CSS 文件 SHALL 经过压缩处理 + +#### Scenario: TDesign CSS 全量导入 +- **WHEN** 前端入口文件初始化样式 +- **THEN** 系统 SHALL 通过 `tdesign-react/dist/reset.css` 和 `tdesign-react/dist/tdesign.min.css` 全量导入 TDesign 组件样式 + +### Requirement: 前端构建产物拆分 +前端生产构建 SHALL 将 vendor 依赖拆分为独立 chunk,利用浏览器并行加载和长期缓存。 + +#### Scenario: vendor chunk 拆分 +- **WHEN** 执行前端生产构建 +- **THEN** 构建产物 SHALL 包含独立的 vendor chunk(react、tdesign、recharts 各自独立),而非单个 bundle + +#### Scenario: 业务代码变更不影响 vendor 缓存 +- **WHEN** 仅修改业务代码(src/web/ 下非 node_modules 文件)并重新构建 +- **THEN** vendor chunk 的文件名(含 hash)SHALL 保持不变,浏览器缓存 SHALL 继续有效 + ### Requirement: 前端开发期 API 代理 前端开发服务器 SHALL 通过 Vite proxy 配置将 API 请求转发到后端 server。 diff --git a/openspec/specs/fullstack-app-runtime/spec.md b/openspec/specs/fullstack-app-runtime/spec.md index a049d05..f799a32 100644 --- a/openspec/specs/fullstack-app-runtime/spec.md +++ b/openspec/specs/fullstack-app-runtime/spec.md @@ -1,6 +1,6 @@ ## Purpose -定义基于 Vite + Bun 的全栈应用运行时,包括 HTTP server、API 命名空间、健康检查、生产静态资源服务和 SPA fallback 行为。 +定义基于 Vite + Bun 的全栈应用运行时,包括统一服务 bootstrap、声明式 Bun routes、API 路由组织、HTTP server、API 命名空间、健康检查、生产静态资源服务和 SPA fallback 行为。 ## Requirements @@ -113,3 +113,108 @@ #### Scenario: SIGINT/SIGTERM 处理 - **WHEN** 开发模式进程收到 SIGINT 或 SIGTERM 信号 - **THEN** 系统 SHALL 调用 engine.stop() 停止调度、调用 store.close() 关闭数据库连接后退出进程 + +### 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: 路由按职责拆分 +系统 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 提供 `src/server/bootstrap.ts` 导出 `bootstrap(options: BootstrapOptions)` 函数,封装完整的服务启动序列:加载配置、初始化正式 logger、创建 store、同步 targets、创建并启动 engine、启动 HTTP server、注册 shutdown handler。配置加载失败发生在正式 logger 初始化之前,系统 SHALL 使用 console fallback 输出启动失败信息。配置加载成功后的启动失败 SHALL 使用正式 logger 输出 `fatal` 后退出。 + +#### Scenario: 开发模式启动 +- **WHEN** `dev.ts` 调用 `bootstrap({ configPath, mode: "development" })` +- **THEN** 系统 SHALL 完成完整启动序列,不传入 staticAssets,并初始化运行时 logger + +#### Scenario: 生产模式启动(带静态资源) +- **WHEN** code-generated entry 调用 `bootstrap({ configPath, mode: "production", staticAssets })` +- **THEN** 系统 SHALL 完成完整启动序列,将 staticAssets 传递给 startServer,并初始化运行时 logger + +#### Scenario: 配置加载失败处理 +- **WHEN** 配置文件读取、YAML 解析或配置校验失败 +- **THEN** 系统 SHALL 通过 console fallback 输出错误信息并以非零退出码退出进程 + +#### Scenario: 配置加载后的启动失败处理 +- **WHEN** logger、store、engine 或 HTTP server 初始化失败 +- **THEN** 系统 SHALL 通过正式 logger 输出 `fatal` 日志并以非零退出码退出进程 + +#### Scenario: 优雅关机 +- **WHEN** 进程收到 SIGINT 或 SIGTERM 信号 +- **THEN** bootstrap 注册的 shutdown handler SHALL 调用 engine.stop()、store.close() 和 logger.flush() 后退出 + +### 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` 完成启动 diff --git a/openspec/specs/http-checker/spec.md b/openspec/specs/http-checker/spec.md index 65d6f7c..97b8b11 100644 --- a/openspec/specs/http-checker/spec.md +++ b/openspec/specs/http-checker/spec.md @@ -25,8 +25,172 @@ #### Scenario: HTTP target 覆盖 headers - **WHEN** YAML 中某个 HTTP target 显式配置 `http.headers` -- **THEN** 该 target SHALL 使用自身 headers 字段的值,不再与规则文件级 defaults 合并 +- **THEN** 该 target SHALL 使用自身 headers 字段的值 + +#### 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 次重定向 #### Scenario: HTTP 序列化展示摘要 - **WHEN** 系统同步 HTTP target 到 targets 表 - **THEN** `target` 展示摘要 SHALL 为 HTTP URL,`config` JSON SHALL 包含 resolved 后的 url、method、headers、body、ignoreSSL、maxBodyBytes 和 maxRedirects + +### Requirement: HTTP checker 执行 +系统 SHALL 按 HTTP target 配置发起 HTTP 请求,使用引擎注入的 `ctx.signal` 响应超时。系统 SHALL 支持手动重定向跟随(`redirect: "manual"`),在 `maxRedirects` 限制内跟随 301/302/303/307/308 重定向。系统 SHALL 记录完整执行耗时和 HTTP observation,并在网络错误、超时、响应体超限或字符编码不支持时产生结构化失败信息。 + +#### Scenario: HTTP 请求成功 +- **WHEN** HTTP target 指向可正常响应的 HTTP 服务,且未配置 expect 或 `expect.status` 为 `[200]` +- **THEN** 系统 SHALL 记录 `matched=true`、`durationMs` 和包含 statusCode、headers、bodyPreview、contentType、contentLength 的 observation + +#### Scenario: 使用 ctx.signal 响应超时 +- **WHEN** 引擎注入的 `ctx.signal` 在 HTTP 请求过程中 abort +- **THEN** 系统 SHALL 记录 `matched=false`,observation SHALL 为 null,failure 的 kind 为 `error`,phase 为 `request`,message 包含超时信息 + +#### Scenario: 网络错误 +- **WHEN** HTTP 请求因网络错误(DNS 解析失败、连接被拒绝等)失败 +- **THEN** 系统 SHALL 记录 `matched=false`,observation SHALL 为 null,failure 的 kind 为 `error`,phase 为 `request` + +#### Scenario: 重定向跟随 +- **WHEN** HTTP target 配置 `http.maxRedirects: 3` 且服务端返回 301/302/303/307/308 重定向 +- **THEN** 系统 SHALL 在 maxRedirects 限制内自动跟随重定向,并返回最终响应供 expect 校验 + +#### Scenario: 重定向次数耗尽 +- **WHEN** HTTP target 配置 `http.maxRedirects: 1` 且服务端返回超过 1 次重定向 +- **THEN** 系统 SHALL 将最后一次重定向响应作为最终结果返回,不继续跟随 + +#### Scenario: POST 重定向为 GET +- **WHEN** HTTP target 使用 POST 方法且收到 301 或 302 重定向,或收到 303 重定向 +- **THEN** 系统 SHALL 将后续请求方法改为 GET,移除 Content-Type 和 Content-Length 请求头,并移除 body + +#### Scenario: 跨域重定向移除敏感头 +- **WHEN** 重定向到不同 origin 的目标 +- **THEN** 系统 SHALL 移除 Authorization 和 Cookie 请求头 + +#### Scenario: 响应体读取与大小限制 +- **WHEN** HTTP target 配置了 body expect 且响应体未超过 `maxBodyBytes` +- **THEN** 系统 SHALL 读取完整响应体,按 Content-Type 字符编码解码,并用于 body expect 校验 + +#### Scenario: 响应体超过大小限制 +- **WHEN** HTTP target 配置了 body expect 且响应体超过 `maxBodyBytes` +- **THEN** 系统 SHALL 停止读取,记录 `matched=false`,failure 的 kind 为 `error`,phase 为 `body`,message 包含响应体超过大小限制的信息 + +#### Scenario: 不支持的字符编码 +- **WHEN** HTTP 响应的 Content-Type 指定了不支持的 charset +- **THEN** 系统 SHALL 记录 `matched=false`,failure 的 kind 为 `error`,phase 为 `body`,message 包含不支持的字符编码信息 + +#### Scenario: 无 body expect 时不读取响应体 +- **WHEN** HTTP target 未配置 `expect.body` 或 body expect 为空数组 +- **THEN** 系统 SHALL NOT 读取响应体内容,bodyPreview SHALL 为 null + +#### Scenario: GET/HEAD 请求不发送 body +- **WHEN** HTTP target 配置 `http.method: GET` 或 `http.method: HEAD` +- **THEN** 系统 SHALL NOT 发送请求体,即使配置了 `http.body` + +### Requirement: HTTP expect 校验 +系统 SHALL 支持 HTTP 专属 expect,包括 `status`、`headers`、`body` 和 `durationMs`,并按 status、headers、early-timeout(仅当配置了 body expect 时)、body、durationMs 的阶段顺序快速失败。`status` SHALL 保持状态码数组语义,支持精确数字(100-599)和范围模式(`1xx` 到 `5xx`),未配置时在 Resolved expect 中默认 `[200]`。`headers` SHALL 使用共享 `RawKeyedExpectations` 输入并在运行期使用 `KeyedExpectations`,header key 大小写不敏感。`body` MUST 使用共享 `RawContentExpectations` 数组输入并在运行期使用 `ContentExpectations`,支持直接 ValueMatcher 以及 json/css/xpath 提取器。`durationMs` SHALL 使用共享 `RawValueExpectation` 输入并在运行期使用 `ValueExpectation` 校验完整执行耗时。 + +#### Scenario: 默认 status 成功语义 +- **WHEN** HTTP target 未显式配置 `expect.status` +- **THEN** 系统 SHALL 在 Resolved HTTP expect 中使用默认 `status: [200]` 进行校验 + +#### Scenario: status 精确值匹配 +- **WHEN** HTTP target 配置 `expect.status: [200, 201]` 且响应状态码为 201 +- **THEN** 系统 SHALL 判定 status 阶段通过,继续后续 expect 阶段 + +#### Scenario: status 范围模式匹配 +- **WHEN** HTTP target 配置 `expect.status: ["2xx"]` 且响应状态码为 204 +- **THEN** 系统 SHALL 判定 status 阶段通过 + +#### Scenario: status 不匹配快速失败 +- **WHEN** HTTP target 配置 `expect.status: [200]` 且响应状态码为 500 +- **THEN** 系统 SHALL 立即返回 `matched=false`,failure 的 phase 为 `status` + +#### Scenario: headers 校验通过 +- **WHEN** HTTP target 配置 `expect.headers: {Content-Type: {contains: "application/json"}}` 且响应包含对应 header +- **THEN** 系统 SHALL 判定 headers 阶段通过,header key 匹配大小写不敏感 + +#### Scenario: headers 校验失败 +- **WHEN** HTTP target 配置 `expect.headers: {X-Custom: {exists: true}}` 且响应不包含该 header +- **THEN** 系统 SHALL 返回 `matched=false`,failure 的 phase 为 `headers` + +#### Scenario: body ContentExpectations 校验通过 +- **WHEN** HTTP target 配置 `expect.body: [{contains: "ok"}, {json: {path: "$.status", equals: "success"}}]` 且响应体满足全部条件 +- **THEN** 系统 SHALL 判定 body 阶段通过 + +#### Scenario: body ContentExpectations 快速失败 +- **WHEN** HTTP target 配置两条 body expectation 且第一条失败 +- **THEN** 系统 SHALL 返回第一条失败 expectation 的 failure,phase 为 `body`,并 MUST NOT 执行第二条 expectation + +#### Scenario: body 非 JSON 响应触发 JSONPath 失败 +- **WHEN** HTTP target 配置 json body expectation 但响应体不是合法 JSON +- **THEN** 系统 SHALL 记录 `matched=false`,failure 的 kind 为 `error`,phase 为 `body` + +#### Scenario: early-timeout 快速失败 +- **WHEN** HTTP target 配置了 body expect 和 `expect.durationMs: {lte: 500}`,且请求状态码和 headers 通过后已耗时超过 500ms +- **THEN** 系统 SHALL 在读取响应体之前判定 durationMs 超限,不读取响应体,直接返回 `matched=false`,failure 的 phase 为 `duration` + +#### Scenario: durationMs 校验 +- **WHEN** HTTP target 配置 `expect.durationMs: {lte: 1000}` 且完整执行耗时超过 1000ms +- **THEN** 系统 SHALL 返回 `matched=false`,failure 的 phase 为 `duration` + +### Requirement: HTTP checker 启动期配置校验 +系统 SHALL 在启动期对 HTTP checker 的配置契约和语义执行严格校验。HTTP target 的 `http` 分组 SHALL 只允许 `url`、`method`、`headers`、`body`、`ignoreSSL`、`maxBodyBytes`、`maxRedirects` 字段。HTTP expect SHALL 只允许 `status`、`headers`、`body`、`durationMs` 字段。未知字段、非法类型、非法 URL 协议、非法 status 值、非法 maxBodyBytes、非法 ContentExpectations 和不可编译正则 MUST 导致启动期配置错误。语义校验 MUST NOT 修改 Raw HTTP expect 输入。 + +#### Scenario: http.url 为空字符串 +- **WHEN** YAML 中 HTTP target 配置 `http.url: ""` +- **THEN** 系统 SHALL 以配置错误退出,提示缺少 http.url 字段 + +#### Scenario: http.url 协议非法 +- **WHEN** YAML 中 HTTP target 的 `http.url` 不以 `http://` 或 `https://` 开头 +- **THEN** 系统 SHALL 以配置错误退出,提示 URL 格式不合法 + +#### Scenario: http.url 格式非法 +- **WHEN** YAML 中 HTTP target 的 `http.url` 不是合法 URL +- **THEN** 系统 SHALL 以配置错误退出,提示 URL 格式不合法 + +#### Scenario: http.maxBodyBytes 格式非法 +- **WHEN** YAML 中 HTTP target 的 `http.maxBodyBytes` 不是合法 size 值 +- **THEN** 系统 SHALL 以配置错误退出,提示 maxBodyBytes 格式错误 + +#### Scenario: http 分组未知字段失败 +- **WHEN** YAML 中 HTTP target 的 `http` 分组包含未知字段 +- **THEN** 系统 SHALL 以配置错误退出,提示 http 分组包含未知字段 + +#### Scenario: expect.status 数字非法 +- **WHEN** YAML 中 HTTP target 的 `expect.status` 包含非整数或不在 100-599 范围内的数字 +- **THEN** 系统 SHALL 以配置错误退出,提示 status 数字不合法 + +#### Scenario: expect.status 模式非法 +- **WHEN** YAML 中 HTTP target 的 `expect.status` 包含不符合 `1xx` 到 `5xx` 格式的字符串 +- **THEN** 系统 SHALL 以配置错误退出,提示 status 模式不合法 + +#### Scenario: expect.body 必须为数组 +- **WHEN** YAML 中 HTTP target 的 `expect.body` 已配置但不是数组 +- **THEN** 系统 SHALL 以配置错误退出,提示 expect.body 必须为数组 + +#### Scenario: HTTP expect 未知字段失败 +- **WHEN** YAML 中 HTTP target 的 expect 包含 `connected`、`exitCode`、`maxDurationMs` 或其他非 HTTP expect 字段 +- **THEN** 系统 SHALL 以配置错误退出,提示 expect 包含未知字段 + +### Requirement: HTTP observation 与 detail +HTTP checker SHALL 在 observation 中记录 statusCode(number)、headers(Record,截断至前 20 个)、bodyPreview(string | null,截断至前 1024 字符)、contentType(string | null)、contentLength(number | null)。API detail SHALL 由 `buildDetail` 从 observation 动态构造,格式为 `HTTP {statusCode}`。网络错误或超时导致无法收集 observation 时,observation SHALL 为 null,detail SHALL 为 null。 + +#### Scenario: 正常响应 observation +- **WHEN** HTTP 请求成功返回 +- **THEN** observation SHALL 包含 statusCode、截断后的 headers、截断后的 bodyPreview、contentType 和 contentLength + +#### Scenario: 未读取响应体时 bodyPreview 为 null +- **WHEN** HTTP target 未配置 body expect +- **THEN** observation.bodyPreview SHALL 为 null + +#### Scenario: 请求失败 observation 为 null +- **WHEN** HTTP 请求因网络错误或超时失败 +- **THEN** observation SHALL 为 null,detail SHALL 为 null + +#### Scenario: detail 格式 +- **WHEN** API 序列化 HTTP CheckResult +- **THEN** detail SHALL 为 `HTTP {statusCode}` 格式(如 `HTTP 200`) diff --git a/openspec/specs/llm-checker/spec.md b/openspec/specs/llm-checker/spec.md index 5757cec..08bfa06 100644 --- a/openspec/specs/llm-checker/spec.md +++ b/openspec/specs/llm-checker/spec.md @@ -5,14 +5,14 @@ ## Requirements ### Requirement: LLM Checker 注册与模块结构 -系统 SHALL 提供 `type: llm` checker,用于大模型服务的应用层拨测。LLM checker MUST 位于 `src/server/checker/runner/llm/` 自包含目录,并通过 `src/server/checker/runner/index.ts` 注册到 `CheckerRegistry`。LLM checker SHALL 复用现有 checker 抽象、配置 schema 组装、启动期语义校验、引擎调度、存储序列化和共享 expect 基础设施。 +系统 SHALL 提供 `type: llm` checker,用于大模型服务的应用层拨测。LLM checker MUST 位于自包含目录,并通过 checker 注册入口注册到 `CheckerRegistry`。LLM checker SHALL 复用现有 checker 抽象、配置 schema 组装、启动期语义校验、引擎调度、存储序列化和共享 expect 基础设施。 #### Scenario: 注册 LLM checker - **WHEN** 系统初始化默认 checker registry - **THEN** registry SHALL 包含 `llm` 类型,且 `/api/meta` 返回的 `checkerTypes` SHALL 包含 `llm` #### Scenario: LLM checker 目录自包含 -- **WHEN** 开发者查看 `src/server/checker/runner/llm/` 目录 +- **WHEN** 开发者查看 LLM checker 目录 - **THEN** 该目录 SHALL 包含 LLM checker 的类型、schema、语义校验、provider 创建、observation 构建、expect 断言、执行逻辑和模块入口 #### Scenario: 不扩展存储和 API 结构 @@ -53,18 +53,6 @@ LLM checker SHALL 解析 `llm.provider`、`llm.url`、`llm.model`、`llm.prompt` - **WHEN** 系统读取只包含 `type: llm` 以及 `llm.provider`、`llm.url`、`llm.model`、`llm.prompt` 的 target - **THEN** 系统 SHALL 解析为 LLM target,并填充 `mode=http`、`key=""`、`ignoreSSL=false`、`options.maxOutputTokens=16`、`options.temperature=0` -#### Scenario: headers 默认值合并 -- **WHEN** `defaults.llm.headers` 和 target `llm.headers` 同时配置同名 header -- **THEN** LLM checker SHALL 按原始 header key 浅合并 headers,并由 target `llm.headers` 覆盖 defaults 中同名 key - -#### Scenario: options 默认值合并 -- **WHEN** `defaults.llm.options` 和 target `llm.options` 同时配置同名 option -- **THEN** LLM checker SHALL 浅合并 options,并由 target `llm.options` 覆盖 defaults 中同名字段 - -#### Scenario: providerOptions 默认值合并 -- **WHEN** `defaults.llm.providerOptions` 和 target `llm.providerOptions` 同时配置同名 provider namespace -- **THEN** LLM checker SHALL 按 provider namespace 浅合并 providerOptions,并由 target namespace 覆盖 defaults 中同名 namespace - #### Scenario: Anthropic Bearer token - **WHEN** target 配置 `llm.provider: anthropic` 和非空 `llm.authToken` - **THEN** LLM checker SHALL 将 `authToken` 映射到 Anthropic SDK 的 Bearer token 认证字段 diff --git a/openspec/specs/meta-api/spec.md b/openspec/specs/meta-api/spec.md deleted file mode 100644 index 0ffefc7..0000000 --- a/openspec/specs/meta-api/spec.md +++ /dev/null @@ -1,31 +0,0 @@ -## Purpose - -定义系统运行时元数据 API:checker 类型列表、应用版本号等元信息的对外暴露方式。 - -## Requirements - -### Requirement: Meta 信息 API -系统 SHALL 提供 `GET /api/meta` 端点,返回系统运行时元数据,包括应用版本号和 checker 类型列表。未匹配 method SHALL 按 API 通配符处理为 JSON 404,不再保留自定义 HEAD/405 语义。 - -#### Scenario: 获取 checker 类型列表和版本号 -- **WHEN** 客户端请求 `GET /api/meta` -- **THEN** 系统 SHALL 返回 JSON `{ checkerTypes: string[], version: string }`,其中 `checkerTypes` 包含所有已注册的 checker 类型标识符(如 `["http", "cmd"]`),`version` 为当前运行实例的 `MAJOR.MINOR.PATCH` 应用版本 - -#### Scenario: 类型列表来源 -- **WHEN** 系统启动并注册了 checker -- **THEN** `/api/meta` 返回的 `checkerTypes` SHALL 与 `CheckerRegistry.supportedTypes` 完全一致 - -#### Scenario: 版本号来源 -- **WHEN** 系统启动并确定应用版本 -- **THEN** `/api/meta` 返回的 `version` SHALL 与启动时注入的应用版本完全一致 - -#### 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[]` 和 `version: string` 字段 diff --git a/openspec/specs/probe-api/spec.md b/openspec/specs/probe-api/spec.md index c2ef28f..0addca1 100644 --- a/openspec/specs/probe-api/spec.md +++ b/openspec/specs/probe-api/spec.md @@ -1,6 +1,6 @@ ## Purpose -定义拨测系统的 REST API 端点:Dashboard 聚合 API、单目标指标 API、带时间范围和分页的历史记录、共享类型定义和 API 错误处理。 +定义拨测系统的 REST API 端点:Dashboard 聚合 API、单目标指标 API、历史记录 API、Meta 信息 API、共享类型定义和 API 错误处理。 ## Requirements @@ -75,11 +75,11 @@ Dashboard API SHALL 返回基于时间窗口计算的目标统计和连续状态 - **THEN** 每个 recentSamples 元素 SHALL 包含 timestamp、durationMs、up 字段,其中 up 为 boolean 且等于 matched ### Requirement: 单目标指标 API -系统 SHALL 提供 `GET /api/targets/:id/metrics` 端点,返回 Drawer 概览所需的单目标统计和趋势数据。仅活跃目标的指标 SHALL 可查询。端点的详细计算规则(P95/P99、MTTR、故障分析、趋势分桶等)定义在 `target-metrics-api` 能力中。 +系统 SHALL 提供 `GET /api/targets/:id/metrics` 端点,返回单个目标在指定时间窗口内的概览统计和趋势数据。仅活跃目标的指标 SHALL 可查询。 -#### Scenario: 指定时间范围查询指标 +#### Scenario: 获取目标指标 - **WHEN** 客户端请求 `GET /api/targets/1/metrics?from=ISO&to=ISO&bucket=1h` 且该目标为活跃目标 -- **THEN** 系统 SHALL 返回 targetId、window、stats、trend 字段 +- **THEN** 系统 SHALL 返回 JSON 对象包含 targetId、window、stats、trend 字段 #### Scenario: from 或 to 参数缺失 - **WHEN** 客户端请求 `GET /api/targets/1/metrics` 未提供 from 或 to 参数 @@ -101,6 +101,134 @@ Dashboard API SHALL 返回基于时间窗口计算的目标统计和连续状态 - **WHEN** 客户端请求 `GET /api/targets/1/metrics?from=ISO&to=ISO&bucket=5m` - **THEN** 系统 SHALL 返回 400 状态码和错误信息 +### Requirement: 指标统计字段 +单目标指标 API SHALL 返回基于时间窗口计算的完整统计字段。 + +#### 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 返回 null,availability 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=null,trend SHALL 返回空数组 + ### Requirement: 历史记录 API 系统 SHALL 保留 `GET /api/targets/:id/history` 端点,支持时间范围筛选和分页返回指定目标的拨测记录。仅活跃目标的历史记录 SHALL 可查询。 @@ -221,16 +349,20 @@ Dashboard API SHALL 返回基于时间窗口计算的目标统计和连续状态 - **THEN** API SHALL 返回 failure 为 null ### Requirement: Meta 信息 API -系统 SHALL 提供 `GET /api/meta` 端点,返回系统运行时元数据。未匹配 method SHALL 按 API 通配符处理为 JSON 404,不再保留自定义 HEAD/405 语义。 +系统 SHALL 提供 `GET /api/meta` 端点,返回系统运行时元数据,包括应用版本号和 checker 类型列表。未匹配 method SHALL 按 API 通配符处理为 JSON 404。 -#### Scenario: 获取 checker 类型列表 +#### Scenario: 获取 checker 类型列表和版本号 - **WHEN** 客户端请求 `GET /api/meta` -- **THEN** 系统 SHALL 返回 JSON `{ checkerTypes: string[] }`,包含所有已注册的 checker 类型标识符 +- **THEN** 系统 SHALL 返回 JSON `{ checkerTypes: string[], version: string }`,其中 `checkerTypes` 包含所有已注册的 checker 类型标识符,`version` 为当前运行实例的 `MAJOR.MINOR.PATCH` 应用版本 #### Scenario: 类型列表来源 - **WHEN** 系统启动并注册了 checker - **THEN** `/api/meta` 返回的 `checkerTypes` SHALL 与 `CheckerRegistry.supportedTypes` 完全一致 +#### Scenario: 版本号来源 +- **WHEN** 系统启动并确定应用版本 +- **THEN** `/api/meta` 返回的 `version` SHALL 与启动时注入的应用版本完全一致 + #### Scenario: 不支持的 method 请求 - **WHEN** 客户端使用 POST/PUT/DELETE/HEAD 等未声明 method 请求 `/api/meta` - **THEN** `/api/*` 通配符 SHALL 返回 JSON 404 响应 @@ -240,4 +372,4 @@ Dashboard API SHALL 返回基于时间窗口计算的目标统计和连续状态 #### Scenario: MetaResponse 类型定义 - **WHEN** 前后端引用 `MetaResponse` 类型 -- **THEN** 该类型 SHALL 包含 `checkerTypes: string[]` 字段 +- **THEN** 该类型 SHALL 包含 `checkerTypes: string[]` 和 `version: string` 字段 diff --git a/openspec/specs/probe-config/spec.md b/openspec/specs/probe-config/spec.md index 473a371..4d8e77d 100644 --- a/openspec/specs/probe-config/spec.md +++ b/openspec/specs/probe-config/spec.md @@ -447,12 +447,40 @@ JSON Schema 契约 SHALL 覆盖业务无关的结构规则,包括字段类型 - **THEN** 系统 SHALL 将默认值 `./data` 基于 configDir 解析为绝对路径 ### Requirement: target 通用元信息字段约束 -系统 SHALL 在 YAML target 通用字段中支持 `description` 字段,并对 `id`、`name` 和 `description` 执行契约校验。`id` MUST 为 1 到 30 个字符。`name` MUST 为 null 或 1 到 30 个字符的字符串,且语义校验 SHALL 拒绝仅包含空白字符的 name。`description` MUST 为 null 或不超过 500 个字符的字符串,且 MAY 为空字符串。 +系统 SHALL 在 YAML target 通用字段中对 `id`、`name` 和 `description` 执行契约校验。`id` MUST 为 1 到 30 个字符,符合 `[a-zA-Z0-9][a-zA-Z0-9_-]*` 命名规则,MUST 在所有 targets 中全局唯一,MUST NOT 参与变量替换。`name` MUST 为 null 或 1 到 30 个字符的字符串,支持变量替换,MUST NOT 要求全局唯一,MUST NOT 参与 target 唯一性判定,语义校验 SHALL 拒绝仅包含空白字符的 name。`description` MUST 为 null 或不超过 500 个字符的字符串,支持变量替换,且 MAY 为空字符串,MUST NOT 参与 target 唯一性判定。`name` 为 null 时前端展示 SHALL 使用 `name ?? id` 作为目标名称文案,但该 fallback MUST NOT 改变 target 本身的 name 值。 #### Scenario: description 字段解析 - **WHEN** 系统读取包含 `description: "检查生产 API 健康状态"` 的 target - **THEN** 系统 SHALL 将该字段解析为 target 的目标说明 +#### Scenario: id 包含下划线和连字符 +- **WHEN** target 配置 `id: "db_check-01"` +- **THEN** 系统 SHALL 使用该 id 作为 target 的唯一标识符 + +#### Scenario: id 不合法报错 +- **WHEN** target 配置 `id: "_invalid"` 或 `id: "-start"` 或 `id: "has space"` +- **THEN** 系统 SHALL 以配置错误退出,提示 id 不符合命名规则 + +#### Scenario: id 重复报错 +- **WHEN** 两个 target 配置相同的 `id: "api-health"` +- **THEN** 系统 SHALL 以配置错误退出,提示 id 重复 + +#### Scenario: id 不参与变量替换 +- **WHEN** target 配置 `id: "${VAR}"` 形式的变量引用 +- **THEN** 系统 SHALL NOT 对 id 执行变量替换 + +#### Scenario: name 使用变量 +- **WHEN** target 配置 `name: "${env} API 健康检查"` 且 variables 中 `env: "生产"` +- **THEN** 系统 SHALL 将 name 解析为 "生产 API 健康检查" + +#### Scenario: name 显式 null +- **WHEN** target 配置 `id: "api-health"` 和 `name: null` +- **THEN** 系统 SHALL 接受该配置,并在解析、存储和 API 响应中保留 name 为 null + +#### Scenario: name 为 null 时前端展示 fallback +- **WHEN** 前端展示 name 为 null 的 target +- **THEN** 前端 SHALL 显示该 target 的 id 作为目标名称文案 + #### Scenario: name 为 null 通过校验 - **WHEN** 系统读取包含 `name: null` 或省略 `name` 的 target - **THEN** 系统 SHALL 接受该配置 @@ -489,6 +517,33 @@ JSON Schema 契约 SHALL 覆盖业务无关的结构规则,包括字段类型 - **WHEN** target 的 `description` 通过变量替换后超过 500 个字符 - **THEN** 系统 SHALL 在契约校验阶段以错误退出,提示 description 字段长度错误 +### Requirement: target 分组 +系统 SHALL 支持在每个 target 上配置可选的 `group` 字段用于将目标归类到不同分组。未指定 `group` 的 target SHALL 归入 `"default"` 分组。系统 SHALL 保证 "default" 分组始终排在最前面,其余分组按配置文件中首次出现的顺序排列。系统 SHALL 在 API 响应中返回每个 target 的分组信息。系统 SHALL 在数据库 targets 表中持久化每个 target 的分组信息。 + +#### Scenario: 配置分组名称 +- **WHEN** YAML 配置中某个 target 指定 `group: "搜索引擎"` +- **THEN** 系统 SHALL 将该 target 归类到 "搜索引擎" 分组 + +#### Scenario: 不配置分组 +- **WHEN** YAML 配置中某个 target 未指定 `group` 字段 +- **THEN** 系统 SHALL 将该 target 归类到 "default" 分组 + +#### Scenario: default 分组排最前 +- **WHEN** 配置中存在多个分组(包括 "default" 和自定义分组) +- **THEN** API 返回的目标列表中 "default" 分组的目标 SHALL 排在其他分组之前 + +#### Scenario: 自定义分组按出现顺序 +- **WHEN** 配置中 "搜索引擎" 分组在 "后端服务" 分组之前首次出现 +- **THEN** API 返回中 "搜索引擎" 分组 SHALL 排在 "后端服务" 分组之前 + +#### Scenario: targets 列表包含分组 +- **WHEN** 客户端请求 `GET /api/targets` +- **THEN** 响应中每个目标 SHALL 包含 `group` 字段,值为该目标所属的分组名称 + +#### Scenario: 持久化分组信息 +- **WHEN** 系统同步 targets 到数据库 +- **THEN** 每个 target 的 `grp` 列 SHALL 存储其分组名称,未配置分组的存储 `"default"` + ### Requirement: 配置 schema 导出包含 target 元信息约束 系统 SHALL 在导出的 Authoring `probe-config.schema.json` 中包含 target `id`、`name` 和 `description` 的长度约束和可空类型,用于编辑器提示和外部校验。导出 schema SHALL 面向用户可书写规则文件,因此还 SHALL 接受支持变量替换字段中的完整变量引用字符串和 expect 简写。 diff --git a/openspec/specs/probe-dashboard/spec.md b/openspec/specs/probe-dashboard/spec.md deleted file mode 100644 index fb2f754..0000000 --- a/openspec/specs/probe-dashboard/spec.md +++ /dev/null @@ -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 chunk(react、tdesign、recharts 各自独立),而非单个 bundle - -#### Scenario: 业务代码变更不影响 vendor 缓存 -- **WHEN** 仅修改业务代码(src/web/ 下非 node_modules 文件)并重新构建 -- **THEN** vendor chunk 的文件名(含 hash)SHALL 保持不变,浏览器缓存 SHALL 继续有效 diff --git a/openspec/specs/probe-engine/spec.md b/openspec/specs/probe-engine/spec.md index b27ed5d..4359ac8 100644 --- a/openspec/specs/probe-engine/spec.md +++ b/openspec/specs/probe-engine/spec.md @@ -1,6 +1,6 @@ ## Purpose -定义拨测调度引擎的行为:按 interval 分组定时、组内并发拨测、expect 结果校验和结果持久化。 +定义拨测调度引擎的行为:按 interval 分组定时、组内并发拨测、通用 expect 校验、结果持久化和定期数据清理。各 checker 类型的执行语义和专属 expect 校验规则定义在各自 checker 的规范中。 ## Requirements @@ -38,181 +38,24 @@ - **WHEN** 调度器同时触发 10 个目标且 probes.execution.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 值。 +系统 SHALL 对每次 checker 执行实施超时控制,超时时间使用目标配置的 timeout 值。引擎 SHALL 通过 AbortController 向 checker 注入超时 signal。 -#### Scenario: HTTP 请求超时 -- **WHEN** HTTP 请求在 timeout 时间内未收到响应 -- **THEN** 系统 SHALL 中止该请求,记录为失败并标注超时错误 - -#### Scenario: cmd 执行超时 -- **WHEN** cmd 进程在 timeout 时间内未退出 -- **THEN** 系统 MUST 终止该子进程,记录为失败并标注超时错误 - -#### Scenario: 请求在超时前完成 +#### Scenario: checker 在超时前完成 - **WHEN** checker 在超时前完成执行 - **THEN** 系统 SHALL 正常记录执行结果并进入 expect 校验 +#### Scenario: checker 执行超时 +- **WHEN** checker 在 timeout 时间内未完成执行 +- **THEN** 系统 SHALL 中止该检查,记录为失败并标注超时错误 + ### Requirement: expect 校验 -系统 SHALL 在 checker 执行完成后根据目标类型的 Resolved expect 执行计划校验观测结果,校验结果和首个失败原因记入 check result。HTTP checker 的 `durationMs` SHALL 表示完整 checker 执行耗时,包括重定向、响应体读取、响应体解码和 expect 校验。HTTP `expect.durationMs` SHALL 使用 `RawValueExpectation` 输入并在 resolve 阶段转换为运行期 `ValueExpectation`;旧 `expect.maxDurationMs` MUST NOT 再作为运行期耗时阈值使用。 - -#### Scenario: HTTP 默认状态码 -- **WHEN** HTTP target 未配置 `expect.status` -- **THEN** 系统 SHALL 在 Resolved HTTP expect 中物化默认 `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` ContentExpectations 数组 -- **THEN** 系统 SHALL 按数组顺序执行 body expectations,任一失败立即记录 failure 并停止后续 expectation - -#### Scenario: 校验 HTTP 完整耗时阈值 -- **WHEN** 目标配置了 `expect.durationMs: {lte: 1000}`,且 HTTP checker 完整执行(含重定向、body 读取、解码和 expect)后的 durationMs 超过阈值 -- **THEN** 系统 SHALL 判定 duration 不匹配,记录完整 durationMs 和 duration failure - -#### Scenario: HTTP body 前耗时已不可能满足 durationMs 上界 -- **WHEN** HTTP target 配置了 body 校验和 `expect.durationMs` 上界 matcher(如 `{lte: 1000}`),且进入 body 读取前的已耗时已使该 matcher 不可能通过 -- **THEN** 系统 SHALL 直接返回 duration failure,且 MUST NOT 读取 response body - -#### Scenario: HTTP body 失败优先于后续 duration 检查 -- **WHEN** HTTP target 配置了 body 校验和 `expect.durationMs: {lte: 1000}`,body 阶段存在失败,且完整执行后 duration 也超过阈值 -- **THEN** 系统 SHALL 返回 body 阶段的失败(首个失败为准),durationMs SHALL 记录完整耗时 - -#### Scenario: HTTP 慢响应体计入耗时 -- **WHEN** HTTP target 配置了 body 校验和 `expect.durationMs: {lte: 1000}`,且响应头很快返回但响应体读取导致完整执行耗时超过阈值 -- **THEN** 系统 SHALL 判定 duration 不匹配并记录完整 durationMs +系统 SHALL 在 checker 执行完成后根据目标类型的 Resolved expect 执行计划校验观测结果,校验结果和首个失败原因记入 check result。各 checker 类型 SHALL 定义各自的 expect 执行顺序、默认状态语义和快速失败策略。`durationMs` SHALL 表示完整 checker 执行耗时。 #### Scenario: 多条 expect 规则 - **WHEN** 目标同时配置状态、duration、元数据和内容 expectations - **THEN** 系统 SHALL 所有 expectations 全部通过时 matched 为 true,任一不通过则为 false 并记录首个失败原因 -#### Scenario: cmd 默认 exitCode -- **WHEN** cmd target 未配置 `expect.exitCode` -- **THEN** 系统 SHALL 在 Resolved cmd expect 中物化默认 `exitCode: [0]` 并按该语义校验命令退出码 - -#### Scenario: 校验 cmd stdout -- **WHEN** cmd target 配置了有序 `expect.stdout` ContentExpectations 数组 -- **THEN** 系统 SHALL 按数组顺序执行 stdout expectations,任一失败立即记录 failure 并停止后续 expectation - -### Requirement: Body 校验按需解析 -系统 SHALL 仅在 HTTP target 配置了 body 校验,且 status、headers 阶段均通过,并且进入 body 前未确定 `expect.durationMs` 已失败时才读取并解析响应体,避免不必要的读取和解析开销。HTTP target 未配置 body 校验时,系统 SHALL NOT 读取 response body。仅当 Resolved `durationMs` 包含上界 matcher 且当前已耗时已经使其不可能通过时,系统 MAY 在读取 body 前返回 duration failure;其他 duration matcher SHALL 在完整执行耗时可用后校验。 - -#### 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 前 durationMs 上界已失败时不读取 body -- **WHEN** HTTP target 已配置 `expect.durationMs` 上界 matcher,且进入 body 读取前的已耗时已经使该 matcher 不可能通过 -- **THEN** 系统 SHALL 返回 duration failure,且 MUST NOT 读取 response body - -#### Scenario: 仅配置 contains 时不解析 JSON -- **WHEN** HTTP target 仅配置 body contains expectation 而未配置 json/css/xpath expectation -- **THEN** 系统 SHALL 不执行 JSON.parse 或 HTML/XML 解析 - -#### Scenario: 配置 json 时解析 JSON 失败 -- **WHEN** HTTP target 配置了 body json expectation 但响应体不是合法 JSON -- **THEN** 系统 SHALL 判定 matched 为 false,并记录 json expectation 对应的 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、observation、failure 字段。detail SHALL 为 API 层派生字段,不写入存储层;系统 SHALL NOT 写入 status_detail 字段。 @@ -229,15 +72,11 @@ HTTP checker SHALL 将运行期失败归属到实际失败阶段。请求、网 - **THEN** 系统 SHALL 记录 matched=false、observation、failure.kind="mismatch" 和具体不匹配信息 ### Requirement: runner 选择 -系统 SHALL 根据 target.type 选择对应 runner 执行检查。 +系统 SHALL 根据 target.type 通过 CheckerRegistry 选择对应 checker 执行检查。 -#### Scenario: 选择 HTTP runner -- **WHEN** target.type 为 `http` -- **THEN** 系统 SHALL 使用 HTTP runner 执行该目标 - -#### Scenario: 选择 cmd runner -- **WHEN** target.type 为 `cmd` -- **THEN** 系统 SHALL 使用 cmd runner 执行该目标 +#### Scenario: 根据 type 选择 checker +- **WHEN** target.type 为已注册的 checker 类型 +- **THEN** 系统 SHALL 通过 `checkerRegistry.get(type)` 获取对应 checker 并执行 ### Requirement: 定期数据清理 ProbeEngine SHALL 在启动时注册数据清理定时器,定期调用 ProbeStore.prune() 清理过期数据和空壳非活跃目标。 diff --git a/openspec/specs/refresh-control/spec.md b/openspec/specs/refresh-control/spec.md deleted file mode 100644 index d011954..0000000 --- a/openspec/specs/refresh-control/spec.md +++ /dev/null @@ -1,104 +0,0 @@ -## Purpose - -定义 Header 刷新频率选择器组件的交互行为:频率切换、倒计时显示、手动刷新按钮、布局稳定性。 - -## Requirements - -### Requirement: 刷新频率选择器 -HeadMenu operations 区域 SHALL 提供 RadioGroup 组件供用户选择刷新频率。 - -#### Scenario: RadioGroup 渲染 -- **WHEN** Dashboard 页面渲染 -- **THEN** HeadMenu operations 区域 SHALL 显示 RadioGroup(theme="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。自动倒计时数字 SHALL 使用 `@number-flow/react` 提供滚动过渡,非倒计时状态 SHALL 保持普通文本或按钮语义。 - -#### 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: NumberFlow 数字滚动 -- **WHEN** 自动刷新模式下已完成首次刷新且当前未处于刷新中状态 -- **THEN** 倒计时数字 SHALL 使用 `@number-flow/react` 的 `NumberFlow` 渲染,并使用向下滚动趋势表达倒计时递减 - -#### Scenario: 秒级间隔格式 -- **WHEN** 自动刷新间隔小于 60 秒 -- **THEN** 倒计时 SHALL 显示为"xx秒"格式(如"26秒") - -#### Scenario: 分钟级稳定格式 -- **WHEN** 自动刷新间隔大于等于 60 秒 -- **THEN** 倒计时 SHALL 显示为"x分xx秒"格式,秒数 SHALL 固定为两位(如"4分30秒"、"0分09秒") - -#### Scenario: 时间数字边界 -- **WHEN** 分钟级倒计时中的秒数在 59 到 00 边界变化 -- **THEN** 秒数十位 SHALL 按时间显示规则限制在 0 到 5 之间滚动 - -#### Scenario: 无前缀 -- **WHEN** 倒计时显示 -- **THEN** 可见倒计时文本 SHALL 不包含任何前缀(如"下一次刷新:"),直接显示时间 - -#### Scenario: 可访问文本 -- **WHEN** NumberFlow 倒计时渲染 -- **THEN** 倒计时容器 SHALL 暴露与当前倒计时等价的可访问文本,供测试和辅助技术读取 - -#### Scenario: 刷新中状态 -- **WHEN** 数据正在刷新(isFetching=true 且 isLoading=false) -- **THEN** 倒计时文本 SHALL 显示为"刷新中..." - -#### Scenario: 等待首次刷新状态 -- **WHEN** 自动刷新模式下尚未完成首次刷新 -- **THEN** 倒计时文本 SHALL 显示为"等待首次刷新" - -### Requirement: App 组件渲染隔离 -App 组件 SHALL NOT 持有任何高频更新的 state(如每秒更新的时钟),确保 App 的重渲染频率与数据刷新频率一致(默认 30 秒一次)。 - -#### 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 保持布局稳定,避免内容变化导致的抖动。NumberFlow 倒计时 SHALL 通过分组同步和等宽数字样式降低位数、单位和动画变化带来的布局偏移。 - -#### Scenario: 数字等宽 -- **WHEN** 倒计时数字变化 -- **THEN** 容器和 NumberFlow 倒计时 SHALL 使用 tabular-nums 字体特性,确保数字等宽不抖动 - -#### Scenario: NumberFlow 分组同步 -- **WHEN** 分钟级倒计时同时渲染分钟和秒数 -- **THEN** 分钟和秒数 SHALL 使用 `NumberFlowGroup` 同步布局变化 - -#### Scenario: 格式切换不抖动 -- **WHEN** 倒计时在按钮、秒级文本和分钟级文本之间切换 -- **THEN** 容器 SHALL 使用 min-width 确保最小宽度,避免 RadioGroup 位移 diff --git a/openspec/specs/server-bootstrap/spec.md b/openspec/specs/server-bootstrap/spec.md deleted file mode 100644 index 6be3b91..0000000 --- a/openspec/specs/server-bootstrap/spec.md +++ /dev/null @@ -1,50 +0,0 @@ -## Purpose - -TBD - 统一服务启动引导函数,封装开发和生产模式的完整启动序列。 - -## Requirements - -### Requirement: 统一启动引导函数 -系统 SHALL 提供 `src/server/bootstrap.ts` 导出 `bootstrap(options: BootstrapOptions)` 函数,封装完整的服务启动序列:加载配置、初始化正式 logger、创建 store、同步 targets、创建并启动 engine、启动 HTTP server、注册 shutdown handler。配置加载失败发生在正式 logger 初始化之前,系统 SHALL 使用 console fallback 输出启动失败信息。配置加载成功后的启动失败 SHALL 使用正式 logger 输出 `fatal` 后退出。 - -#### Scenario: 开发模式启动 -- **WHEN** `dev.ts` 调用 `bootstrap({ configPath, mode: "development" })` -- **THEN** 系统 SHALL 完成完整启动序列,不传入 staticAssets,并初始化运行时 logger - -#### Scenario: 生产模式启动(带静态资源) -- **WHEN** code-generated entry 调用 `bootstrap({ configPath, mode: "production", staticAssets })` -- **THEN** 系统 SHALL 完成完整启动序列,将 staticAssets 传递给 startServer,并初始化运行时 logger - -#### Scenario: 配置加载失败处理 -- **WHEN** 配置文件读取、YAML 解析或配置校验失败 -- **THEN** 系统 SHALL 通过 console fallback 输出错误信息并以非零退出码退出进程 - -#### Scenario: 配置加载后的启动失败处理 -- **WHEN** logger、store、engine 或 HTTP server 初始化失败 -- **THEN** 系统 SHALL 通过正式 logger 输出 `fatal` 日志并以非零退出码退出进程 - -#### Scenario: 优雅关机 -- **WHEN** 进程收到 SIGINT 或 SIGTERM 信号 -- **THEN** bootstrap 注册的 shutdown handler SHALL 调用 engine.stop()、store.close() 和 logger.flush() 后退出 - -### 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` 完成启动 diff --git a/openspec/specs/single-executable-packaging/spec.md b/openspec/specs/single-executable-packaging/spec.md index 7273699..c91d897 100644 --- a/openspec/specs/single-executable-packaging/spec.md +++ b/openspec/specs/single-executable-packaging/spec.md @@ -1,6 +1,6 @@ ## Purpose -定义将 Vite 构建的前端资源通过 code generation 嵌入 Bun 后端,打包为单个 standalone executable 的生产构建、运行配置和验证要求。 +定义将 Vite 构建的前端资源通过 code generation 嵌入 Bun 后端、静态资源服务与 Content-Type 处理、打包为单个 standalone executable 的生产构建、运行配置和验证要求。 ## Requirements @@ -89,3 +89,63 @@ executable MUST 将环境相关运行时配置保留在嵌入的前端和 server #### Scenario: 升迁后重新构建 - **WHEN** 开发者先升迁 `package.json.version` 再运行生产构建命令 - **THEN** 新生成的 standalone executable SHALL 返回升迁后的版本号 + +### 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 "" with { type: "file" }` 语句,并导出 `StaticAssets` 对象 + +#### Scenario: StaticAssets 对象结构 +- **WHEN** `static-assets.ts` 被生成 +- **THEN** 导出的对象 SHALL 包含 `indexHtml: Blob` 和 `files: Record` 两个字段,其中 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 返回对应 Blob,Content-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` + +### Requirement: 静态资源 import specifier SHALL 使用平台无关分隔符 +构建时静态资源 code generation SHALL 将文件系统相对路径转换为 ESM import specifier,并确保生成的 import 路径在 Windows、macOS、Linux 开发环境下都使用 `/` 作为分隔符。 + +#### Scenario: Windows 相对路径转换为 import specifier +- **WHEN** code generation 将 Windows 文件系统相对路径 `..\\dist\\web\\assets\\app.js` 转换为静态资源 import specifier +- **THEN** 生成的 import specifier SHALL 为 `../dist/web/assets/app.js`,且 MUST NOT 包含 `\\` + +#### Scenario: POSIX 相对路径保持 import specifier 形式 +- **WHEN** code generation 将 POSIX 文件系统相对路径 `../dist/web/assets/app.js` 转换为静态资源 import specifier +- **THEN** 生成的 import specifier SHALL 保持为 `../dist/web/assets/app.js` diff --git a/openspec/specs/static-asset-embedding/spec.md b/openspec/specs/static-asset-embedding/spec.md deleted file mode 100644 index c4f2782..0000000 --- a/openspec/specs/static-asset-embedding/spec.md +++ /dev/null @@ -1,69 +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 "" with { type: "file" }` 语句,并导出 `StaticAssets` 对象 - -#### Scenario: StaticAssets 对象结构 -- **WHEN** `static-assets.ts` 被生成 -- **THEN** 导出的对象 SHALL 包含 `indexHtml: Blob` 和 `files: Record` 两个字段,其中 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 返回对应 Blob,Content-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` - -### Requirement: 静态资源 import specifier SHALL 使用平台无关分隔符 -构建时静态资源 code generation SHALL 将文件系统相对路径转换为 ESM import specifier,并确保生成的 import 路径在 Windows、macOS、Linux 开发环境下都使用 `/` 作为分隔符。 - -#### Scenario: Windows 相对路径转换为 import specifier -- **WHEN** code generation 将 Windows 文件系统相对路径 `..\\dist\\web\\assets\\app.js` 转换为静态资源 import specifier -- **THEN** 生成的 import specifier SHALL 为 `../dist/web/assets/app.js`,且 MUST NOT 包含 `\\` - -#### Scenario: POSIX 相对路径保持 import specifier 形式 -- **WHEN** code generation 将 POSIX 文件系统相对路径 `../dist/web/assets/app.js` 转换为静态资源 import specifier -- **THEN** 生成的 import specifier SHALL 保持为 `../dist/web/assets/app.js` diff --git a/openspec/specs/target-grouping/spec.md b/openspec/specs/target-grouping/spec.md deleted file mode 100644 index 8a9d219..0000000 --- a/openspec/specs/target-grouping/spec.md +++ /dev/null @@ -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"` diff --git a/openspec/specs/target-identity/spec.md b/openspec/specs/target-identity/spec.md deleted file mode 100644 index 971b472..0000000 --- a/openspec/specs/target-identity/spec.md +++ /dev/null @@ -1,110 +0,0 @@ -## Purpose - -定义 target 的 id/name 双字段标识体系:id 作为唯一标识符,name 作为可选展示名称。 - -## Requirements - -### Requirement: target id 字段 -每个 target SHALL 包含必填的 `id` 字段作为唯一标识符。`id` SHALL 符合 `[a-zA-Z0-9][a-zA-Z0-9_-]*` 命名规则。`id` 长度 MUST 为 1 到 30 个字符。`id` MUST 在所有 targets 中全局唯一。`id` MUST NOT 参与变量替换。 - -#### Scenario: 合法 id -- **WHEN** target 配置 `id: "api-health"` -- **THEN** 系统 SHALL 使用该 id 作为 target 的唯一标识符 - -#### Scenario: id 包含下划线和连字符 -- **WHEN** target 配置 `id: "db_check-01"` -- **THEN** 系统 SHALL 使用该 id 作为 target 的唯一标识符 - -#### Scenario: id 缺失报错 -- **WHEN** target 未配置 `id` 字段 -- **THEN** 系统 SHALL 以配置错误退出,提示该 target 缺少 id 字段 - -#### Scenario: id 为空字符串报错 -- **WHEN** target 配置 `id: ""` -- **THEN** 系统 SHALL 以配置错误退出,提示 id 不能为空 - -#### Scenario: id 超过最大长度报错 -- **WHEN** target 配置超过 30 个字符的 `id` -- **THEN** 系统 SHALL 以配置错误退出,提示 id 长度不合法 - -#### Scenario: id 不合法报错 -- **WHEN** target 配置 `id: "_invalid"` 或 `id: "-start"` 或 `id: "has space"` -- **THEN** 系统 SHALL 以配置错误退出,提示 id 不符合命名规则 - -#### Scenario: id 重复报错 -- **WHEN** 两个 target 配置相同的 `id: "api-health"` -- **THEN** 系统 SHALL 以配置错误退出,提示 id 重复 - -### Requirement: target name 字段 -每个 target SHALL 支持可选的 `name` 字段作为展示名称元信息。`name` 缺省或显式配置为 `null` 时 SHALL 在配置解析、运行时模型、存储和 API 中保留为 null。前端展示目标名称时 SHALL 使用 `name ?? id`,但该 fallback MUST NOT 改变 target 本身的 name 值。显式配置的 `name` MUST 为长度 1 到 30 个字符的字符串,且去除首尾空白后 MUST 不为空。`name` SHALL 支持变量替换。`name` MUST NOT 要求全局唯一,MUST NOT 参与 target 唯一性判定。 - -#### Scenario: 配置 name -- **WHEN** target 配置 `id: "api-health"` 和 `name: "API 健康检查"` -- **THEN** 系统 SHALL 在解析后保留 name 为 "API 健康检查" - -#### Scenario: name 使用变量 -- **WHEN** target 配置 `name: "${env} API 健康检查"` 且 variables 中 `env: "生产"` -- **THEN** 系统 SHALL 将 name 解析为 "生产 API 健康检查" - -#### Scenario: name 缺省保留为 null -- **WHEN** target 配置 `id: "api-health"` 但未配置 `name` -- **THEN** 系统 SHALL 在解析、存储和 API 响应中保留 name 为 null - -#### Scenario: name 显式 null -- **WHEN** target 配置 `id: "api-health"` 和 `name: null` -- **THEN** 系统 SHALL 接受该配置,并在解析、存储和 API 响应中保留 name 为 null - -#### Scenario: name 空 YAML 值 -- **WHEN** target 配置 `id: "api-health"` 且 `name:` 后不提供值 -- **THEN** 系统 SHALL 将该 name 按 null 处理,并接受该配置 - -#### Scenario: name 为 null 时展示 fallback -- **WHEN** 前端展示 name 为 null 的 target -- **THEN** 前端 SHALL 显示该 target 的 id 作为目标名称文案 - -#### Scenario: name 为空字符串报错 -- **WHEN** target 配置 `name: ""` -- **THEN** 系统 SHALL 以配置错误退出,提示 name 不能为空 - -#### Scenario: name 仅包含空白字符报错 -- **WHEN** target 配置 `name: " "` -- **THEN** 系统 SHALL 以配置错误退出,提示 name 不能为空 - -#### Scenario: name 超过最大长度报错 -- **WHEN** target 配置超过 30 个字符的 `name` -- **THEN** 系统 SHALL 以配置错误退出,提示 name 长度不合法 - -#### Scenario: 多个 target 使用相同 name -- **WHEN** 两个 target 配置不同 id 但相同 `name: "健康检查"` -- **THEN** 系统 SHALL 接受该配置,不报错(name 不要求全局唯一) - -### Requirement: target description 字段 -每个 target SHALL 支持可选的 `description` 字段作为目标说明。`description` 缺省或显式配置为 `null` 时 SHALL 在配置解析、运行时模型、存储和 API 中保留为 null。`description` SHALL 支持变量替换。`description` 字符串长度 MUST 不超过 500 个字符,且允许为空字符串。`description` MUST NOT 参与 target 唯一性判定。 - -#### Scenario: 配置 description -- **WHEN** target 配置 `description: "检查生产 API 健康状态"` -- **THEN** 系统 SHALL 使用该值作为目标说明 - -#### Scenario: description 使用变量 -- **WHEN** target 配置 `description: "${env} 环境健康检查"` 且 variables 中 `env: "生产"` -- **THEN** 系统 SHALL 将目标说明解析为 "生产 环境健康检查" - -#### Scenario: description 缺省 -- **WHEN** target 未配置 `description` -- **THEN** 系统 SHALL 接受该配置,且目标说明为 null - -#### Scenario: description 显式 null -- **WHEN** target 配置 `description: null` -- **THEN** 系统 SHALL 接受该配置,且目标说明为 null - -#### Scenario: description 空 YAML 值 -- **WHEN** target 配置 `description:` 后不提供值 -- **THEN** 系统 SHALL 将该 description 按 null 处理,并接受该配置 - -#### Scenario: description 为空字符串 -- **WHEN** target 配置 `description: ""` -- **THEN** 系统 SHALL 接受该配置,且目标说明为空字符串 - -#### Scenario: description 超过最大长度报错 -- **WHEN** target 配置超过 500 个字符的 `description` -- **THEN** 系统 SHALL 以配置错误退出,提示 description 长度不合法 diff --git a/openspec/specs/target-metrics-api/spec.md b/openspec/specs/target-metrics-api/spec.md deleted file mode 100644 index 1f04671..0000000 --- a/openspec/specs/target-metrics-api/spec.md +++ /dev/null @@ -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 包含 targetId(number)、window(from/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 返回 null,availability 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=null,trend SHALL 返回空数组 diff --git a/openspec/specs/tcp-checker/spec.md b/openspec/specs/tcp-checker/spec.md index 554e1d8..fbebb57 100644 --- a/openspec/specs/tcp-checker/spec.md +++ b/openspec/specs/tcp-checker/spec.md @@ -23,14 +23,6 @@ - **WHEN** YAML 中 tcp target 的 `tcp.port` 不是 1 到 65535 之间的整数 - **THEN** 系统 SHALL 以配置错误退出,并提示 tcp.port 必须为合法 TCP 端口 -#### Scenario: tcp defaults 覆盖 banner 参数 -- **WHEN** YAML 中配置 `defaults.tcp.bannerReadTimeout: 1000` 和 `defaults.tcp.maxBannerBytes: "8KB"` -- **THEN** 未显式配置对应字段的 tcp target SHALL 使用 defaults.tcp 中的值 - -#### Scenario: per-target banner 参数覆盖 defaults -- **WHEN** defaults.tcp 配置了 banner 参数,且某个 tcp target 显式配置 `tcp.bannerReadTimeout` 或 `tcp.maxBannerBytes` -- **THEN** 该 target SHALL 使用自身 tcp 分组中的值 - #### Scenario: tcp 序列化展示摘要 - **WHEN** 系统同步 tcp target 到 targets 表 - **THEN** `target` 展示摘要 SHALL 为 `:`,`config` JSON SHALL 包含 resolved 后的 host、port、readBanner、bannerReadTimeout 和 maxBannerBytes diff --git a/openspec/specs/test-output-cleanliness/spec.md b/openspec/specs/test-output-cleanliness/spec.md index 93e8abb..f03b0e3 100644 --- a/openspec/specs/test-output-cleanliness/spec.md +++ b/openspec/specs/test-output-cleanliness/spec.md @@ -1,5 +1,3 @@ -# Test Output Cleanliness - ## Purpose 确保测试运行时输出干净、无噪音,便于开发者快速定位问题。 diff --git a/openspec/specs/udp-checker/spec.md b/openspec/specs/udp-checker/spec.md index b15c31c..d54dc73 100644 --- a/openspec/specs/udp-checker/spec.md +++ b/openspec/specs/udp-checker/spec.md @@ -27,14 +27,6 @@ - **WHEN** YAML 中 udp target 未配置 `udp.payload` - **THEN** 系统 SHALL 使用空字符串作为 payload,并在执行时发送零长度 UDP datagram -#### Scenario: udp defaults 覆盖通用 UDP 参数 -- **WHEN** YAML 中配置 `defaults.udp.encoding: "hex"`、`defaults.udp.responseEncoding: "hex"` 和 `defaults.udp.maxResponseBytes: "8KB"` -- **THEN** 未显式配置对应字段的 udp target SHALL 使用 defaults.udp 中的值 - -#### Scenario: per-target UDP 参数覆盖 defaults -- **WHEN** defaults.udp 配置了 encoding、responseEncoding 或 maxResponseBytes,且某个 udp target 显式配置对应字段 -- **THEN** 该 target SHALL 使用自身 udp 分组中的值 - #### Scenario: udp 分组未知字段失败 - **WHEN** YAML 中 udp target 的 `udp` 分组包含 `dnsQuery`、`expectResponse` 或其他未知字段 - **THEN** 系统 SHALL 以配置错误退出,提示 udp 分组包含未知字段 @@ -129,7 +121,7 @@ - **THEN** 系统 SHALL 返回 `matched=false`,failure 的 kind 为 `error`,phase 为 `response`,message 包含响应被截断的信息 #### Scenario: maxResponseBytes 格式非法 -- **WHEN** YAML 中 udp target 或 defaults.udp 的 `maxResponseBytes` 不是非负整数或合法 size string +- **WHEN** YAML 中 udp target 的 `maxResponseBytes` 不是非负整数或合法 size string - **THEN** 系统 SHALL 以配置错误退出,提示 maxResponseBytes 格式错误 ### Requirement: udp expect 校验 diff --git a/openspec/specs/vite-frontend-bundling/spec.md b/openspec/specs/vite-frontend-bundling/spec.md deleted file mode 100644 index 04fdb7b..0000000 --- a/openspec/specs/vite-frontend-bundling/spec.md +++ /dev/null @@ -1,58 +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,并通过 `React.lazy()` 动态导入实现按需加载。 - -#### 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 - -#### Scenario: TargetDetailDrawer 延迟加载 -- **WHEN** Vite 构建完成 -- **THEN** `TargetDetailDrawer` 及其依赖(recharts、D3、DateRangePicker 等)SHALL 通过 `React.lazy()` 动态导入,被 Rolldown 自动拆分为异步 chunk,不包含在初始加载的 JS 中 - -#### Scenario: Drawer 首次渲染无闪烁 -- **WHEN** 用户首次点击目标触发 Drawer 渲染 -- **THEN** Drawer SHALL 通过 `` 包裹,利用其默认 visible=false 状态避免加载期间的视觉闪烁 - -### Requirement: CSS 处理 -系统 SHALL 通过 Vite 处理 CSS 导入,产出独立的 CSS 文件。TDesign 组件样式 SHALL 保持全量导入方式。 - -#### Scenario: CSS 文件产出 -- **WHEN** Vite 构建完成 -- **THEN** 所有 CSS 导入 SHALL 被提取为独立的 `.css` 文件到 `assets/` 目录 - -#### Scenario: CSS 压缩 -- **WHEN** Vite 执行生产构建 -- **THEN** 产出的 CSS 文件 SHALL 经过压缩处理 - -#### Scenario: TDesign CSS 全量导入 -- **WHEN** 前端入口文件初始化样式 -- **THEN** 系统 SHALL 通过 `tdesign-react/dist/reset.css` 和 `tdesign-react/dist/tdesign.min.css` 全量导入 TDesign 组件样式 diff --git a/openspec/specs/windows-test-compat/spec.md b/openspec/specs/windows-test-compat/spec.md index d767bb2..37eb3e2 100644 --- a/openspec/specs/windows-test-compat/spec.md +++ b/openspec/specs/windows-test-compat/spec.md @@ -1,5 +1,3 @@ -# Capability: windows-test-compat - ## Purpose 确保测试在 Windows、macOS、Linux 平台上的兼容性,包括文件句柄释放后的目录清理重试机制和跨平台命令测试约定。