## Context DiAL 是一个基于 Bun 的全栈拨测监控系统,当前前端统计指标体系存在以下问题: 1. **计算逻辑缺陷**:可用率基于全量历史数据计算(`store.ts:getAllTargetStats` 无 WHERE 时间条件),随运行时间增长近期变化被稀释;`computeTrendStats` 从已截断的百分比反推整数有累积精度损失;`lastCheckTime` 已返回但前端未展示 2. **指标维度单一**:Summary 仅 3 个计数卡片;Drawer 统计区 4 个指标(总检查/正常/异常/可用率)本质是同一维度的冗余表达 3. **缺少关键运维指标**:无 P95 延迟、无 MTTR、无故障分析、无连续状态信息 当前技术栈:后端 Bun + SQLite(bun:sqlite),前端 React + TDesign + recharts + TanStack Query。 ## Goals / Non-Goals **Goals:** - 修复可用率时间窗口、趋势数据精度损失、lastCheckTime 未展示三个计算逻辑问题 - Summary 增加「24h 异常事件数」卡片 - 表格增加「连续状态」列(Tag 样式,按次数) - Drawer 统计区重构为 2×4 多维度布局(可用率/平均延迟/P95/检查总数 + MTTR/最长故障/故障次数/连续正常) - 趋势图增加延迟范围面积(min/max),去掉可用率线改为异常时刻红色标记点 - 新增 `/api/targets/:id/stats` 端点,职责清晰(单目标非时序聚合指标) - Drawer 统计区支持时间窗口切换(24h/7d/30d),联动统计+趋势 **Non-Goals:** - 不做整体可用率(不同分组不同目的的 target 算到一起无意义) - 不做延迟 sparkline(表格已有状态条,信息密度够了) - 不做趋势对比(vs 上周) - 不做连续状态按时长展示(不同间隔的目标无法统一) ## Decisions ### Decision 1:P95 在应用层计算,不在 SQL 层 **选择**:新增 `getTargetDurations(targetId, from, to)` 方法,一次性取出时间窗口内所有成功检查的 `duration_ms`,在 TypeScript 层排序取 P95/P99。 **理由**:SQLite 无原生 PERCENTILE 函数,用子查询模拟的 SQL 复杂且性能不可控。应用层排序对于单目标时间窗口内的数据量(24h × 每分钟 1 次 = 1440 条)完全可接受。 **替代方案**:SQLite 扩展函数 / 窗口函数模拟 — 复杂度高,可移植性差。 **命名**:方法名统一为 `getTargetDurations`(非 `getTargetPercentiles`),因为该方法职责是取原始数据,百分位计算在调用方完成。 ### Decision 2:新增独立 `/api/targets/:id/stats` 端点 **选择**:创建新端点而非扩展现有 `/api/targets/:id/trend`。 **理由**: - `/trend` 的职责是时序聚合数据(按小时分组),返回数组 - `/stats` 的职责是非时序聚合指标(P95、MTTR、故障分析),返回单个对象 - 两者语义清晰,避免一个大而全的端点 - `/stats` 只在 Drawer 打开时请求,不影响列表页性能 **替代方案**:扩展 `/trend` 在响应中附加 summary 字段 — 混淆了时序和聚合两种数据语义。 ### Decision 3:异常事件数按「状态翻转」计数 **选择**:统计 `matched` 从 1→0 的转换次数(跨所有目标),而非每次 `matched=0` 的检查次数。 **理由**:一个目标连续异常 10 次只算 1 次事件,反映的是「发生了几次故障」而非「有多少次检查失败」。后者已经在可用率中体现。 **实现**:SQL 使用 LAG 窗口函数检测前后状态变化: ```sql SELECT COUNT(*) FROM ( SELECT matched, LAG(matched) OVER (PARTITION BY target_id ORDER BY timestamp) as prev FROM check_results WHERE timestamp >= ? ) WHERE matched = 0 AND (prev = 1 OR prev IS NULL) ``` ### Decision 4:连续状态从 recentSamples 前端计算 **选择**:不新增 API,从已有的 `recentSamples`(30 条)在前端计算连续状态次数。 **理由**: - `recentSamples` 已经按时间倒序返回,遍历到第一个状态不同的即可 - 无需额外网络请求 - 30 条样本对于连续状态计数足够(超过 30 次连续正常/异常的场景下,显示 "30+" 即可) ### Decision 5:趋势图去掉可用率线,改为异常标记点 **选择**:移除 availability 折线和右侧 Y 轴(%),改为单 Y 轴(ms)。在 avgDurationMs 线上,对 availability < 100 的时间点渲染红色 dot 标记异常。 **理由**:可用率通常是 100% 或接近 100%,作为连续曲线信息量极低(大部分时间是一条直线)。改为离散标记点后,异常时刻一目了然,且不占用 Y 轴空间。 **实现**:使用 recharts `` 的 `dot` 回调函数,对 `availability < 100` 的点渲染红色圆点(`fill: var(--td-error-color)`),其余点不渲染 dot。移除右侧 Y 轴和 availability Line 组件。 ### Decision 6:时间窗口切换联动机制 **选择**:Drawer 中的时间窗口切换同时影响统计区和趋势图,stats 和 trend 同时刷新。 **实现**: - stats 请求直接复用 Drawer 现有的 `timeFrom`/`timeTo` 状态,不引入额外时间状态 - 统计区数据来自 `/api/targets/:id/stats?from=&to=` - 趋势图数据来自 `/api/targets/:id/trend?from=&to=` - 切换快捷按钮(1h/6h/24h/7d)时,`timeFrom`/`timeTo` 更新,stats 和 trend 的 queryKey 变化触发同时刷新 - 默认选中 24h - 表格的可用率固定 24h 窗口:前端 `useTargets` 请求 `/api/targets?window=24h`,后端解析 `window` 查询参数并转换为时间范围传递给 `getAllTargetStats(from, to)`,列标题改为"可用率(24h)" ### Decision 7:Drawer 统计区 2×4 布局 **选择**:统计区和可靠性区合并为一个 2 行 × 4 列的 Statistic 网格。 ``` ┌────────────┬────────────┬────────────┬────────────┐ │ 可用率 │ 平均延迟 │ P95延迟 │ 检查总数 │ ├────────────┼────────────┼────────────┼────────────┤ │ MTTR │ 最长故障 │ 故障次数 │ 连续正常 │ └────────────┴────────────┴────────────┴────────────┘ ``` **理由**:原来的「总检查/正常/异常/可用率」4 指标信息冗余,正常/异常计数已在环形图中展示。重构后每个格子都是独立维度,信息密度大幅提升。 ### Decision 8:TrendPoint 增加 min/max 延迟字段 **选择**:在 SQL 聚合层直接计算 `MIN(duration_ms)` 和 `MAX(duration_ms)`,零额外成本。 **实现**:趋势图使用 recharts `` 组件渲染 min-max 范围(半透明品牌色填充),叠加 avg 实线。 ### Decision 9:Summary lastCheckTime 展示为相对时间 **选择**:在 Summary 区域底部展示 "最后更新: X秒前" 文本,前端每秒更新。 **实现**:使用 `useState` + `setInterval` 每秒计算相对时间差。超过 60 秒时文字变为警告色(--td-warning-color),提示数据可能不新鲜。 ### Decision 10:StatusDonut 数据来源改为 statsData **选择**:StatusDonut 的 `up`/`down` 改为使用 `statsData.upChecks` / `statsData.downChecks`,不再从 trendData 反推。 **理由**:statsData 的 upChecks 是精确值(直接从 SQL COUNT 返回),与统计区的"检查总数"一致,消除了之前从百分比反推的精度损失。 **影响**:`computeTrendStats` 工具函数不再有调用方,直接删除。 ### Decision 11:MTTR 窗口边界截断处理 **选择**:如果时间窗口内第一条记录即为 matched=0(故障跨越了 from 边界),该故障段不计入 MTTR 平均值,但计入 incidentCount。 **理由**:无法确定故障的真实开始时间,计入 MTTR 会低估实际恢复时间。incidentCount 计数是因为用户确实在窗口内经历了这次故障。 ### Decision 12:getIncidents24h 作为独立方法 **选择**:`getIncidents24h()` 是 ProbeStore 的独立方法(单条 SQL),在 `handleSummary` 路由中调用并附加到响应。 **理由**:职责分离,getSummary() 保持原有的目标状态快照逻辑,incidents24h 是独立的时序分析查询。 ## Risks / Trade-offs - **[P95 数据量]** 30d 窗口下单目标可能有 ~43200 条记录需要排序 → 对于内存排序仍然可接受(<1MB),但如果未来数据量增长可考虑近似算法 - **[异常事件计数的 LAG 查询]** 窗口函数在大数据量下可能较慢 → 24h 窗口内数据量有限(所有目标 × 24h ÷ 间隔),可接受;如果性能不佳可改为应用层遍历 - **[前端连续状态上限 30]** recentSamples 固定 30 条,连续状态超过 30 次时显示 "30+" → 对于运维场景足够,真正需要精确值时可查看 Drawer 详情 - **[趋势图去掉可用率线]** 用户可能习惯看可用率曲线 → 异常标记点提供了等价信息且更直观,环形图仍展示可用率分布 - **[LAG 窗口边界误差]** 使用 LAG 窗口函数检测状态翻转时,若故障跨越时间窗口 from 边界(窗口内第一条即为 matched=0),会被计为一次新事件,实际可能是窗口外已开始的故障延续 → 对于 24h 窗口内的事件计数,该误差可接受且难以避免(需要额外查询窗口外数据才能消除)