9.3 KiB
Context
DiAL 是一个基于 Bun 的全栈拨测监控系统,当前前端统计指标体系存在以下问题:
- 计算逻辑缺陷:可用率基于全量历史数据计算(
store.ts:getAllTargetStats无 WHERE 时间条件),随运行时间增长近期变化被稀释;computeTrendStats从已截断的百分比反推整数有累积精度损失;lastCheckTime已返回但前端未展示 - 指标维度单一:Summary 仅 3 个计数卡片;Drawer 统计区 4 个指标(总检查/正常/异常/可用率)本质是同一维度的冗余表达
- 缺少关键运维指标:无 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 窗口函数检测前后状态变化:
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 <Line> 的 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 <Area> 组件渲染 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 窗口内的事件计数,该误差可接受且难以避免(需要额外查询窗口外数据才能消除)