feat: target 软删除机制,配置移除时保留历史数据
This commit is contained in:
@@ -24,7 +24,7 @@
|
||||
- **THEN** 系统 SHALL 在配置校验阶段报错,拒绝启动
|
||||
|
||||
### Requirement: 定时清理调度
|
||||
系统 SHALL 以固定间隔(1 小时)定期执行数据清理,删除超过保留时长的历史检查结果。
|
||||
系统 SHALL 以固定间隔(1 小时)定期执行数据清理,删除超过保留时长的历史检查结果,并清理已无关联检查结果的非活跃目标行。
|
||||
|
||||
#### Scenario: 引擎启动后首次清理
|
||||
- **WHEN** ProbeEngine 启动
|
||||
@@ -34,6 +34,10 @@
|
||||
- **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 清除清理定时器,不再执行后续清理
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
## Requirements
|
||||
|
||||
### Requirement: Dashboard 聚合 API
|
||||
系统 SHALL 提供 `GET /api/dashboard` 端点,返回 Dashboard 首屏所需的总览统计和目标列表数据。
|
||||
系统 SHALL 提供 `GET /api/dashboard` 端点,返回 Dashboard 首屏所需的总览统计和目标列表数据。targets 列表 SHALL 仅包含活跃目标。
|
||||
|
||||
#### Scenario: 获取 Dashboard 数据
|
||||
- **WHEN** 客户端请求 `GET /api/dashboard?window=24h&recentLimit=30`
|
||||
- **THEN** 系统 SHALL 返回 JSON 包含 summary 和 targets 字段
|
||||
- **THEN** 系统 SHALL 返回 JSON 包含 summary 和 targets 字段,targets 仅包含 active=1 的目标
|
||||
|
||||
#### Scenario: summary 字段
|
||||
- **WHEN** Dashboard 响应包含 summary
|
||||
- **THEN** summary SHALL 包含 total(总目标数)、up(当前正常目标数)、down(当前异常目标数)、lastCheckTime(最近一次检查时间)、incidents(指定窗口内异常事件数)、window(from/to/label)字段
|
||||
- **THEN** summary SHALL 仅统计活跃目标:total(活跃目标数)、up(活跃正常目标数)、down(活跃异常目标数)、lastCheckTime(最近一次检查时间)、incidents(活跃目标在指定窗口内异常事件数)、window(from/to/label)字段
|
||||
|
||||
#### Scenario: targets 字段
|
||||
- **WHEN** Dashboard 响应包含 targets
|
||||
- **THEN** targets 数组中每个元素 SHALL 包含目标基本信息(id、name、description、group、type、target、interval)、latestCheck、stats、currentStreak 和 recentSamples 字段,其中 name 和 description 均为 null 或字符串
|
||||
- **THEN** targets 数组中每个元素 SHALL 为活跃目标,包含目标基本信息(id、name、description、group、type、target、interval)、latestCheck、stats、currentStreak 和 recentSamples 字段,其中 name 和 description 均为 null 或字符串
|
||||
|
||||
#### Scenario: target name 字段为 null
|
||||
- **WHEN** 某个 target 未配置 `name` 或显式配置 `name: null`
|
||||
@@ -75,25 +75,37 @@ Dashboard API SHALL 返回基于时间窗口计算的目标统计和连续状态
|
||||
- **THEN** 每个 recentSamples 元素 SHALL 包含 timestamp、durationMs、up 字段,其中 up 为 boolean 且等于 matched
|
||||
|
||||
### Requirement: 单目标指标 API
|
||||
系统 SHALL 提供 `GET /api/targets/:id/metrics` 端点,返回 Drawer 概览所需的单目标统计和趋势数据。端点的详细计算规则(P95/P99、MTTR、故障分析、趋势分桶等)定义在 `target-metrics-api` 能力中。
|
||||
系统 SHALL 提供 `GET /api/targets/:id/metrics` 端点,返回 Drawer 概览所需的单目标统计和趋势数据。仅活跃目标的指标 SHALL 可查询。端点的详细计算规则(P95/P99、MTTR、故障分析、趋势分桶等)定义在 `target-metrics-api` 能力中。
|
||||
|
||||
#### Scenario: 指定时间范围查询指标
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/metrics?from=ISO&to=ISO&bucket=1h`
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/metrics?from=ISO&to=ISO&bucket=1h` 且该目标为活跃目标
|
||||
- **THEN** 系统 SHALL 返回 targetId、window、stats、trend 字段
|
||||
|
||||
#### Scenario: from 或 to 参数缺失
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/metrics` 未提供 from 或 to 参数
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
#### Scenario: 目标不存在或非活跃
|
||||
- **WHEN** 客户端请求 `GET /api/targets/999/metrics?from=ISO&to=ISO` 且该目标不存在或 active=0
|
||||
- **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: 历史记录 API
|
||||
系统 SHALL 保留 `GET /api/targets/:id/history` 端点,支持时间范围筛选和分页返回指定目标的拨测记录。
|
||||
系统 SHALL 保留 `GET /api/targets/:id/history` 端点,支持时间范围筛选和分页返回指定目标的拨测记录。仅活跃目标的历史记录 SHALL 可查询。
|
||||
|
||||
#### Scenario: 获取指定时间范围内的历史记录
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&page=1&pageSize=20`
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&page=1&pageSize=20` 且该目标为活跃目标
|
||||
- **THEN** 系统 SHALL 返回带分页信息的历史记录,包含 items、total、page、pageSize,按时间倒序排列
|
||||
|
||||
#### Scenario: 使用默认分页参数
|
||||
@@ -104,6 +116,10 @@ Dashboard API SHALL 返回基于时间窗口计算的目标统计和连续状态
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history` 未提供 from 或 to 参数
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
#### Scenario: 目标不存在或非活跃
|
||||
- **WHEN** 客户端请求 `GET /api/targets/999/history?from=ISO&to=ISO` 且该目标不存在或 active=0
|
||||
- **THEN** 系统 SHALL 返回 404 状态码和错误信息
|
||||
|
||||
### Requirement: 新增共享类型
|
||||
系统 SHALL 在 `src/shared/api.ts` 中定义 Dashboard 和 Metrics 相关共享类型。CheckResult SHALL 包含 durationMs(null | number)、failure(CheckFailure | null)、matched(boolean)、detail(null | string)、observation(Record<string, unknown> | null)、timestamp(string)。其中 detail 替代原 statusDetail 字段名。
|
||||
|
||||
@@ -159,12 +175,16 @@ Dashboard API SHALL 返回基于时间窗口计算的目标统计和连续状态
|
||||
- **THEN** 系统 SHALL 返回与之前格式一致的健康检查响应
|
||||
|
||||
### Requirement: API 错误处理
|
||||
系统 SHALL 对不存在的目标 ID、无效参数和超出范围的分页参数返回适当的 HTTP 错误响应。
|
||||
系统 SHALL 对不存在的目标 ID、非活跃目标、无效参数和超出范围的分页参数返回适当的 HTTP 错误响应。非活跃目标与不存在的目标 SHALL 返回相同的 404 响应。
|
||||
|
||||
#### Scenario: 查询不存在的目标
|
||||
- **WHEN** 客户端请求 `GET /api/targets/999/metrics?from=ISO&to=ISO`
|
||||
- **THEN** 系统 SHALL 返回 404 状态码和错误信息
|
||||
|
||||
#### Scenario: 查询非活跃目标
|
||||
- **WHEN** 客户端请求 `GET /api/targets/<id>/metrics?from=ISO&to=ISO` 且该目标 active=0
|
||||
- **THEN** 系统 SHALL 返回 404 状态码和错误信息
|
||||
|
||||
#### Scenario: 无效的 from/to 参数
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/metrics?from=invalid&to=ISO`
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
## Requirements
|
||||
|
||||
### Requirement: SQLite 数据库初始化
|
||||
系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。数据库 schema MUST 支持 typed checker target 和结构化检查结果,targets 表 MUST 包含 `grp` 列存储分组信息,且 targets 表的 `name` 和 `description` 列 MUST 允许 NULL。
|
||||
系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。数据库 schema MUST 支持 typed checker target 和结构化检查结果,targets 表 MUST 包含 `grp` 列存储分组信息、`active` 列标记活跃状态,且 targets 表的 `name` 和 `description` 列 MUST 允许 NULL。check_results 表的外键约束 SHALL 使用 `ON DELETE RESTRICT`。
|
||||
|
||||
#### Scenario: 首次启动创建数据库
|
||||
- **WHEN** 指定的数据目录下不存在数据库文件
|
||||
- **THEN** 系统 SHALL 创建数据库文件并初始化 targets 表和 check_results 表,check_results 表包含 id(INTEGER PRIMARY KEY AUTOINCREMENT)、target_id(TEXT NOT NULL)、timestamp(TEXT NOT NULL)、matched(INTEGER NOT NULL)、duration_ms(REAL)、observation(TEXT)、failure(TEXT),不包含 status_detail 列,不包含 success 列
|
||||
- **THEN** 系统 SHALL 创建数据库文件并初始化 targets 表(含 active INTEGER NOT NULL DEFAULT 1 列)和 check_results 表(外键约束为 ON DELETE RESTRICT),check_results 表包含 id(INTEGER PRIMARY KEY AUTOINCREMENT)、target_id(TEXT NOT NULL)、timestamp(TEXT NOT NULL)、matched(INTEGER NOT NULL)、duration_ms(REAL)、observation(TEXT)、failure(TEXT),不包含 status_detail 列,不包含 success 列
|
||||
|
||||
#### Scenario: targets name 列允许 NULL
|
||||
- **WHEN** 系统首次创建 targets 表
|
||||
@@ -26,19 +26,27 @@
|
||||
#### Scenario: 外键约束
|
||||
- **THEN** 系统 SHALL 启用 `PRAGMA foreign_keys = ON`
|
||||
|
||||
#### Scenario: 级联删除
|
||||
- **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE CASCADE`,确保删除目标时自动清理关联结果记录
|
||||
#### Scenario: 级联删除改为限制删除
|
||||
- **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE RESTRICT`,确保删除 target 时数据库层面阻止操作而非级联删除关联记录
|
||||
|
||||
### Requirement: targets 表同步
|
||||
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示名称元信息、展示摘要、领域配置、调度配置、expect 配置、分组信息和目标说明。
|
||||
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示名称元信息、展示摘要、领域配置、调度配置、expect 配置、分组信息和目标说明。配置中不存在的 target SHALL 被标记为非活跃而非删除。
|
||||
|
||||
#### Scenario: 首次同步目标
|
||||
- **WHEN** 数据库为空且 YAML 中定义了 N 个 typed target
|
||||
- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、description、type、target、config、interval_ms、timeout_ms、expect 和 grp,其中 name 和 description 均可为 NULL
|
||||
- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、description、type、target、config、interval_ms、timeout_ms、expect、grp 和 active=1,其中 name 和 description 均可为 NULL
|
||||
|
||||
#### Scenario: 配置变更后重新同步
|
||||
- **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启
|
||||
- **THEN** 系统 SHALL 根据 id 字段匹配:新增的插入、删除的移除、修改的更新(含 name、description 和 grp 字段)
|
||||
- **THEN** 系统 SHALL 根据 id 字段匹配:新增的插入(active=1)、删除的设置 active=0(不删除行)、修改的更新(含 name、description 和 grp 字段),已存在的目标 SHALL 设置 active=1
|
||||
|
||||
#### Scenario: 配置中移除目标
|
||||
- **WHEN** YAML 配置中移除了某个 target,该 target 在数据库中存在且 active=1
|
||||
- **THEN** 系统 SHALL 将该 target 的 active 设置为 0,保留该行及所有关联的 check_results
|
||||
|
||||
#### Scenario: 配置中恢复已移除目标
|
||||
- **WHEN** YAML 配置中重新添加了之前移除的 target(数据库中 active=0)
|
||||
- **THEN** 系统 SHALL 将该 target 的 active 设置为 1,并更新其他字段,历史 check_results 保留不变
|
||||
|
||||
#### Scenario: 未配置 name
|
||||
- **WHEN** YAML target 未配置 `name`
|
||||
@@ -79,11 +87,11 @@
|
||||
- **THEN** 系统 SHALL 使用索引快速定位,无需全表扫描
|
||||
|
||||
### Requirement: 目标列表按分组排序
|
||||
系统 SHALL 保证 targets 查询结果按分组排序返回。
|
||||
系统 SHALL 保证 targets 查询结果按分组排序返回,且仅返回活跃目标。
|
||||
|
||||
#### Scenario: 分组排序查询
|
||||
- **WHEN** 查询所有 targets
|
||||
- **THEN** 结果 SHALL 将 "default" 分组目标排在首位,其余分组按 YAML 配置中首次出现的顺序(即 id 自增顺序)排列
|
||||
- **THEN** 结果 SHALL 仅返回 active=1 的目标,将 "default" 分组目标排在首位,其余分组按 YAML 配置中首次出现的顺序(即 id 自增顺序)排列
|
||||
|
||||
### Requirement: 聚合查询支持
|
||||
数据存储 SHALL 支持按时间段获取指标计算所需数据,用于后端应用层计算可用率、平均耗时、延迟范围、趋势分桶和可靠性指标。
|
||||
@@ -105,30 +113,34 @@
|
||||
- **THEN** 系统 SHALL 基于 latestCheck.matched 判定目标 UP 或 DOWN:matched=true 为 UP,matched=false 为 DOWN
|
||||
|
||||
### Requirement: Dashboard 数据查询支持
|
||||
ProbeStore SHALL 提供 Dashboard 聚合响应所需的批量取数能力。
|
||||
ProbeStore SHALL 提供 Dashboard 聚合响应所需的批量取数能力,且所有查询 SHALL 仅涉及活跃 target。
|
||||
|
||||
#### Scenario: 批量获取最新检查
|
||||
- **WHEN** Dashboard API 需要计算当前 up/down 和 lastCheckTime
|
||||
- **THEN** Store SHALL 支持批量获取每个 target 的最新检查记录,避免 N+1 查询
|
||||
- **THEN** Store SHALL 支持批量获取每个活跃 target 的最新检查记录,避免 N+1 查询
|
||||
|
||||
#### Scenario: 批量获取窗口统计基础数据
|
||||
- **WHEN** Dashboard API 需要计算各 target 在指定 window 内的 totalChecks、upChecks、downChecks 和 availability
|
||||
- **THEN** Store SHALL 支持按 target_id 批量返回指定时间窗口内的基础计数数据
|
||||
- **THEN** Store SHALL 支持按 target_id 批量返回指定时间窗口内的基础计数数据,仅包含活跃 target
|
||||
|
||||
#### Scenario: 批量获取最近样本
|
||||
- **WHEN** Dashboard API 需要展示 recentSamples 和计算 capped currentStreak
|
||||
- **THEN** Store SHALL 支持批量获取每个 target 最近 recentLimit 条检查记录,按 target_id 分组且每组按 timestamp 降序排列
|
||||
- **THEN** Store SHALL 支持批量获取每个活跃 target 最近 recentLimit 条检查记录,按 target_id 分组且每组按 timestamp 降序排列
|
||||
|
||||
#### Scenario: 获取 Dashboard 异常事件序列
|
||||
- **WHEN** Dashboard API 需要计算 incidents
|
||||
- **THEN** Store SHALL 支持获取指定时间窗口内所有 target 的 `{ target_id, timestamp, matched }` 序列,按 target_id 和 timestamp 升序排列,供后端应用层计算状态翻转
|
||||
- **THEN** Store SHALL 支持获取指定时间窗口内所有活跃 target 的 `{ target_id, timestamp, matched }` 序列,按 target_id 和 timestamp 升序排列,供后端应用层计算状态翻转
|
||||
|
||||
### Requirement: 单目标指标取数支持
|
||||
ProbeStore SHALL 提供单目标 metrics 响应所需的取数能力。
|
||||
ProbeStore SHALL 提供单目标 metrics 响应所需的取数能力。仅活跃 target 的指标 SHALL 可查询。
|
||||
|
||||
#### Scenario: 获取目标检查点序列
|
||||
- **WHEN** Metrics API 需要计算趋势分桶、故障段、MTTR、最长故障、故障次数和连续状态
|
||||
- **THEN** Store SHALL 支持获取指定 target 在 from 到 to 时间范围内的 `{ timestamp, matched, duration_ms }` 数组,按 timestamp 升序排列
|
||||
- **THEN** Store SHALL 支持获取指定活跃 target 在 from 到 to 时间范围内的 `{ timestamp, matched, duration_ms }` 数组,按 timestamp 升序排列
|
||||
|
||||
#### Scenario: 目标不活跃
|
||||
- **WHEN** 查询 inactive target 的指标
|
||||
- **THEN** Store SHALL 返回 null(getTargetById 不匹配 active=1 的条件)
|
||||
|
||||
#### Scenario: 无检查记录
|
||||
- **WHEN** 时间窗口内无检查记录
|
||||
@@ -180,7 +192,7 @@ ProbeStore SHALL 提供 `getTargetDurations(targetId, from, to)` 方法,返回
|
||||
- **THEN** targets.config SHALL 存储 JSON,包含 exec、args、cwd、env、maxOutputBytes
|
||||
|
||||
### Requirement: 数据清理方法
|
||||
ProbeStore SHALL 提供 `prune(retentionMs: number)` 方法,删除超过保留时长的历史检查结果并返回删除行数。
|
||||
ProbeStore SHALL 提供 `prune(retentionMs: number)` 方法,删除超过保留时长的历史检查结果并返回删除行数,同时清理已无关联检查结果的非活跃目标行。
|
||||
|
||||
#### Scenario: 清理过期数据
|
||||
- **WHEN** 调用 `prune(604800000)`(7 天毫秒数)
|
||||
@@ -193,3 +205,15 @@ ProbeStore SHALL 提供 `prune(retentionMs: number)` 方法,删除超过保留
|
||||
#### Scenario: 清理不影响保留期内数据
|
||||
- **WHEN** 调用 `prune()` 且存在保留期内和保留期外的记录
|
||||
- **THEN** 系统 SHALL 仅删除保留期外的记录,保留期内的记录 SHALL 不受影响
|
||||
|
||||
#### Scenario: 清理空壳非活跃目标
|
||||
- **WHEN** prune 执行完毕后,存在 active=0 的 target 且该 target 在 check_results 表中无任何关联记录
|
||||
- **THEN** 系统 SHALL 删除该空壳 target 行
|
||||
|
||||
#### Scenario: 非活跃目标仍有历史数据时不清理
|
||||
- **WHEN** 存在 active=0 的 target 但该 target 在 check_results 表中仍有关联记录
|
||||
- **THEN** 系统 SHALL NOT 删除该 target 行
|
||||
|
||||
#### Scenario: 活跃目标永不清理
|
||||
- **WHEN** 存在 active=1 的 target 且该 target 在 check_results 表中无关联记录
|
||||
- **THEN** 系统 SHALL NOT 删除该 target 行
|
||||
|
||||
@@ -240,7 +240,7 @@ HTTP checker SHALL 将运行期失败归属到实际失败阶段。请求、网
|
||||
- **THEN** 系统 SHALL 使用 cmd runner 执行该目标
|
||||
|
||||
### Requirement: 定期数据清理
|
||||
ProbeEngine SHALL 在启动时注册数据清理定时器,定期调用 ProbeStore.prune() 清理过期数据。
|
||||
ProbeEngine SHALL 在启动时注册数据清理定时器,定期调用 ProbeStore.prune() 清理过期数据和空壳非活跃目标。
|
||||
|
||||
#### Scenario: 引擎启动注册清理
|
||||
- **WHEN** ProbeEngine.start() 被调用且 retentionMs > 0
|
||||
|
||||
@@ -17,7 +17,8 @@ CREATE TABLE IF NOT EXISTS targets (
|
||||
interval_ms INTEGER NOT NULL,
|
||||
timeout_ms INTEGER NOT NULL,
|
||||
expect TEXT,
|
||||
grp TEXT NOT NULL DEFAULT 'default'
|
||||
grp TEXT NOT NULL DEFAULT 'default',
|
||||
active INTEGER NOT NULL DEFAULT 1
|
||||
)
|
||||
`;
|
||||
|
||||
@@ -30,7 +31,7 @@ CREATE TABLE IF NOT EXISTS check_results (
|
||||
duration_ms REAL,
|
||||
observation TEXT,
|
||||
failure TEXT,
|
||||
FOREIGN KEY (target_id) REFERENCES targets(id) ON DELETE CASCADE
|
||||
FOREIGN KEY (target_id) REFERENCES targets(id) ON DELETE RESTRICT
|
||||
)
|
||||
`;
|
||||
|
||||
@@ -58,6 +59,11 @@ export class ProbeStore {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
deleteTargetRaw(id: string): void {
|
||||
if (this.closed) return;
|
||||
this.db.run("DELETE FROM targets WHERE id = ?", [id]);
|
||||
}
|
||||
|
||||
getAllRecentSamples(
|
||||
limit: number,
|
||||
): Map<string, Array<{ duration_ms: null | number; matched: number; timestamp: string }>> {
|
||||
@@ -74,6 +80,7 @@ export class ProbeStore {
|
||||
matched,
|
||||
ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY timestamp DESC) as row_num
|
||||
FROM check_results
|
||||
WHERE target_id IN (SELECT id FROM targets WHERE active = 1)
|
||||
)
|
||||
WHERE row_num <= ?
|
||||
ORDER BY target_id, timestamp DESC`,
|
||||
@@ -107,6 +114,7 @@ export class ProbeStore {
|
||||
COALESCE(SUM(CASE WHEN matched = 0 THEN 1 ELSE 0 END), 0) as downChecks
|
||||
FROM check_results
|
||||
WHERE timestamp >= ? AND timestamp <= ?
|
||||
AND target_id IN (SELECT id FROM targets WHERE active = 1)
|
||||
GROUP BY target_id`,
|
||||
)
|
||||
.all(from, to) as Array<{ downChecks: number; target_id: string; totalChecks: number; upChecks: number }>;
|
||||
@@ -138,6 +146,7 @@ export class ProbeStore {
|
||||
`SELECT target_id, timestamp, matched
|
||||
FROM check_results
|
||||
WHERE timestamp >= ? AND timestamp <= ?
|
||||
AND target_id IN (SELECT id FROM targets WHERE active = 1)
|
||||
ORDER BY target_id ASC, timestamp ASC`,
|
||||
)
|
||||
.all(from, to) as Array<{ matched: number; target_id: string; timestamp: string }>;
|
||||
@@ -177,6 +186,7 @@ export class ProbeStore {
|
||||
INNER JOIN (
|
||||
SELECT target_id, MAX(timestamp) as max_ts
|
||||
FROM check_results
|
||||
WHERE target_id IN (SELECT id FROM targets WHERE active = 1)
|
||||
GROUP BY target_id
|
||||
) latest ON cr.target_id = latest.target_id AND cr.timestamp = latest.max_ts`,
|
||||
)
|
||||
@@ -199,9 +209,15 @@ export class ProbeStore {
|
||||
}>;
|
||||
}
|
||||
|
||||
getTargetActive(id: string): null | number {
|
||||
if (this.closed) return null;
|
||||
const row = this.db.query("SELECT active FROM targets WHERE id = ?").get(id) as null | { active: number };
|
||||
return row?.active ?? null;
|
||||
}
|
||||
|
||||
getTargetById(id: string): null | StoredTarget {
|
||||
if (this.closed) return null;
|
||||
return this.db.query("SELECT * FROM targets WHERE id = ?").get(id) as null | StoredTarget;
|
||||
return this.db.query("SELECT * FROM targets WHERE id = ? AND active = 1").get(id) as null | StoredTarget;
|
||||
}
|
||||
|
||||
getTargetCheckpoints(
|
||||
@@ -239,7 +255,7 @@ export class ProbeStore {
|
||||
getTargets(): StoredTarget[] {
|
||||
if (this.closed) return [];
|
||||
return this.db
|
||||
.query("SELECT * FROM targets ORDER BY CASE WHEN grp='default' THEN 0 ELSE 1 END, id")
|
||||
.query("SELECT * FROM targets WHERE active = 1 ORDER BY CASE WHEN grp='default' THEN 0 ELSE 1 END, id")
|
||||
.all() as StoredTarget[];
|
||||
}
|
||||
|
||||
@@ -277,6 +293,12 @@ export class ProbeStore {
|
||||
};
|
||||
}
|
||||
|
||||
hasTargetRow(id: string): boolean {
|
||||
if (this.closed) return false;
|
||||
const row = this.db.query("SELECT 1 FROM targets WHERE id = ?").get(id) as null | { "1": number };
|
||||
return row !== null;
|
||||
}
|
||||
|
||||
insertCheckResult(result: {
|
||||
durationMs: null | number;
|
||||
failure: CheckFailure | null;
|
||||
@@ -304,6 +326,9 @@ export class ProbeStore {
|
||||
if (this.closed) return 0;
|
||||
const cutoff = new Date(Date.now() - retentionMs).toISOString();
|
||||
const result = this.db.run("DELETE FROM check_results WHERE timestamp < ?", [cutoff]);
|
||||
this.db.run(
|
||||
"DELETE FROM targets WHERE active = 0 AND NOT EXISTS (SELECT 1 FROM check_results WHERE check_results.target_id = targets.id)",
|
||||
);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
@@ -317,9 +342,9 @@ export class ProbeStore {
|
||||
"INSERT INTO targets (id, name, description, type, target, config, interval_ms, timeout_ms, expect, grp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
);
|
||||
const updateStmt = this.db.prepare(
|
||||
"UPDATE targets SET name = ?, description = ?, type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ?, grp = ? WHERE id = ?",
|
||||
"UPDATE targets SET name = ?, description = ?, type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ?, grp = ?, active = 1 WHERE id = ?",
|
||||
);
|
||||
const deleteStmt = this.db.prepare("DELETE FROM targets WHERE id = ?");
|
||||
const deactivateStmt = this.db.prepare("UPDATE targets SET active = 0 WHERE id = ? AND active = 1");
|
||||
|
||||
const tx = this.db.transaction(() => {
|
||||
for (const t of targets) {
|
||||
@@ -338,7 +363,7 @@ export class ProbeStore {
|
||||
|
||||
for (const id of existingIds) {
|
||||
if (!configIds.has(id)) {
|
||||
deleteStmt.run(id);
|
||||
deactivateStmt.run(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -66,6 +66,7 @@ export interface StoredCheckResult {
|
||||
}
|
||||
|
||||
export interface StoredTarget {
|
||||
active: number;
|
||||
config: string;
|
||||
description: null | string;
|
||||
expect: null | string;
|
||||
|
||||
@@ -134,7 +134,7 @@ describe("ProbeStore", () => {
|
||||
expect(store.getTargets()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("同步删除 target", () => {
|
||||
test("同步软删除 target", () => {
|
||||
store.syncTargets([httpTarget]);
|
||||
const targets = store.getTargets();
|
||||
expect(targets).toHaveLength(1);
|
||||
@@ -346,7 +346,7 @@ describe("ProbeStore", () => {
|
||||
expect(closedStore.getTargetById("closed-target")).toBeNull();
|
||||
});
|
||||
|
||||
test("删除 target 级联删除 check_results", () => {
|
||||
test("移除 target 软删除保留 check_results", () => {
|
||||
const cascadeStore = new ProbeStore(join(tempDir, "cascade.db"));
|
||||
const cascadeTarget: ResolvedHttpTarget = {
|
||||
description: null,
|
||||
@@ -391,7 +391,8 @@ describe("ProbeStore", () => {
|
||||
cascadeStore.syncTargets([]);
|
||||
|
||||
expect(cascadeStore.getTargets()).toHaveLength(0);
|
||||
expect(cascadeStore.getLatestCheck(t.id)).toBeNull();
|
||||
expect(cascadeStore.getLatestCheck(t.id)).not.toBeNull();
|
||||
expect(cascadeStore.getHistory(t.id, "2000-01-01T00:00:00.000Z", "2099-12-31T23:59:59.999Z").total).toBe(2);
|
||||
|
||||
cascadeStore.close();
|
||||
});
|
||||
@@ -686,4 +687,185 @@ describe("ProbeStore", () => {
|
||||
expect(t.name).toBeNull();
|
||||
nullNameStore.close();
|
||||
});
|
||||
|
||||
test("targets 表 active 列默认值为 1", () => {
|
||||
const activeStore = new ProbeStore(join(tempDir, "active-default.db"));
|
||||
activeStore.syncTargets([{ ...httpTarget, id: "active-test", name: "active-test" }]);
|
||||
const t = activeStore.getTargets()[0]!;
|
||||
expect(t.active).toBe(1);
|
||||
activeStore.close();
|
||||
});
|
||||
|
||||
test("check_results 外键约束为 RESTRICT", () => {
|
||||
const restrictStore = new ProbeStore(join(tempDir, "restrict.db"));
|
||||
restrictStore.syncTargets([{ ...httpTarget, id: "restrict-test", name: "restrict-test" }]);
|
||||
const t = restrictStore.getTargets()[0]!;
|
||||
restrictStore.insertCheckResult({
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
});
|
||||
expect(() => {
|
||||
restrictStore.deleteTargetRaw(t.id);
|
||||
}).toThrow();
|
||||
restrictStore.close();
|
||||
});
|
||||
|
||||
test("syncTargets 新增目标 active=1", () => {
|
||||
const softStore = new ProbeStore(join(tempDir, "soft-insert.db"));
|
||||
softStore.syncTargets([{ ...httpTarget, id: "soft-a", name: "soft-a" }]);
|
||||
const t = softStore.getTargets()[0]!;
|
||||
expect(t.active).toBe(1);
|
||||
softStore.close();
|
||||
});
|
||||
|
||||
test("syncTargets 移除目标设置 active=0", () => {
|
||||
const softStore = new ProbeStore(join(tempDir, "soft-remove.db"));
|
||||
softStore.syncTargets([
|
||||
{ ...httpTarget, id: "soft-remove-a", name: "soft-remove-a" },
|
||||
{ ...httpTarget, id: "soft-remove-b", name: "soft-remove-b" },
|
||||
]);
|
||||
expect(softStore.getTargets()).toHaveLength(2);
|
||||
|
||||
softStore.syncTargets([{ ...httpTarget, id: "soft-remove-a", name: "soft-remove-a" }]);
|
||||
expect(softStore.getTargets()).toHaveLength(1);
|
||||
expect(softStore.getTargets()[0]!.id).toBe("soft-remove-a");
|
||||
|
||||
expect(softStore.getTargetActive("soft-remove-b")).toBe(0);
|
||||
|
||||
softStore.close();
|
||||
});
|
||||
|
||||
test("syncTargets 恢复已移除目标 active=1", () => {
|
||||
const restoreStore = new ProbeStore(join(tempDir, "soft-restore.db"));
|
||||
restoreStore.syncTargets([{ ...httpTarget, id: "restore-a", name: "restore-a" }]);
|
||||
|
||||
restoreStore.insertCheckResult({
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
observation: null,
|
||||
targetId: "restore-a",
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
restoreStore.syncTargets([]);
|
||||
expect(restoreStore.getTargets()).toHaveLength(0);
|
||||
|
||||
restoreStore.syncTargets([{ ...httpTarget, id: "restore-a", name: "restore-a-updated" }]);
|
||||
expect(restoreStore.getTargets()).toHaveLength(1);
|
||||
expect(restoreStore.getTargets()[0]!.active).toBe(1);
|
||||
expect(restoreStore.getTargets()[0]!.name).toBe("restore-a-updated");
|
||||
|
||||
const history = restoreStore.getHistory("restore-a", "2000-01-01T00:00:00.000Z", "2099-12-31T23:59:59.999Z");
|
||||
expect(history.total).toBe(1);
|
||||
|
||||
restoreStore.close();
|
||||
});
|
||||
|
||||
test("syncTargets 更新属性同时保持 active=1", () => {
|
||||
const updateStore = new ProbeStore(join(tempDir, "soft-update.db"));
|
||||
updateStore.syncTargets([{ ...httpTarget, id: "update-active", name: "old-name" }]);
|
||||
|
||||
updateStore.syncTargets([{ ...httpTarget, id: "update-active", name: "new-name" }]);
|
||||
const t = updateStore.getTargets()[0]!;
|
||||
expect(t.name).toBe("new-name");
|
||||
expect(t.active).toBe(1);
|
||||
|
||||
updateStore.close();
|
||||
});
|
||||
|
||||
test("getTargets 不返回 inactive target", () => {
|
||||
const filterStore = new ProbeStore(join(tempDir, "filter-targets.db"));
|
||||
filterStore.syncTargets([
|
||||
{ ...httpTarget, id: "filter-active", name: "filter-active" },
|
||||
{ ...httpTarget, id: "filter-inactive", name: "filter-inactive" },
|
||||
]);
|
||||
filterStore.syncTargets([{ ...httpTarget, id: "filter-active", name: "filter-active" }]);
|
||||
|
||||
const targets = filterStore.getTargets();
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]!.id).toBe("filter-active");
|
||||
|
||||
filterStore.close();
|
||||
});
|
||||
|
||||
test("getTargetById 对 inactive target 返回 null", () => {
|
||||
const filterStore = new ProbeStore(join(tempDir, "filter-byid.db"));
|
||||
filterStore.syncTargets([{ ...httpTarget, id: "filter-id-test", name: "filter-id-test" }]);
|
||||
expect(filterStore.getTargetById("filter-id-test")).not.toBeNull();
|
||||
|
||||
filterStore.syncTargets([]);
|
||||
expect(filterStore.getTargetById("filter-id-test")).toBeNull();
|
||||
|
||||
filterStore.close();
|
||||
});
|
||||
|
||||
test("prune 清理空壳 inactive target", () => {
|
||||
const pruneStore = new ProbeStore(join(tempDir, "prune-shell.db"));
|
||||
pruneStore.syncTargets([{ ...httpTarget, id: "prune-shell-target", name: "prune-shell-target" }]);
|
||||
|
||||
pruneStore.insertCheckResult({
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
observation: null,
|
||||
targetId: "prune-shell-target",
|
||||
timestamp: "2020-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
pruneStore.syncTargets([]);
|
||||
|
||||
expect(pruneStore.getTargetActive("prune-shell-target")).toBe(0);
|
||||
|
||||
pruneStore.prune(86400000);
|
||||
|
||||
expect(pruneStore.hasTargetRow("prune-shell-target")).toBeFalse();
|
||||
|
||||
pruneStore.close();
|
||||
});
|
||||
|
||||
test("prune 保留有历史数据的 inactive target", () => {
|
||||
const pruneStore = new ProbeStore(join(tempDir, "prune-keep-inactive.db"));
|
||||
pruneStore.syncTargets([{ ...httpTarget, id: "prune-keep-target", name: "prune-keep-target" }]);
|
||||
|
||||
pruneStore.insertCheckResult({
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
observation: null,
|
||||
targetId: "prune-keep-target",
|
||||
timestamp: "2020-01-01T00:00:00.000Z",
|
||||
});
|
||||
pruneStore.insertCheckResult({
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
observation: null,
|
||||
targetId: "prune-keep-target",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
pruneStore.syncTargets([]);
|
||||
|
||||
pruneStore.prune(86400000);
|
||||
|
||||
expect(pruneStore.getTargetActive("prune-keep-target")).toBe(0);
|
||||
|
||||
pruneStore.close();
|
||||
});
|
||||
|
||||
test("prune 不清理无数据的 active target", () => {
|
||||
const pruneStore = new ProbeStore(join(tempDir, "prune-no-active.db"));
|
||||
pruneStore.syncTargets([{ ...httpTarget, id: "prune-active-target", name: "prune-active-target" }]);
|
||||
|
||||
pruneStore.prune(86400000);
|
||||
|
||||
expect(pruneStore.getTargetActive("prune-active-target")).toBe(1);
|
||||
|
||||
pruneStore.close();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user