feat: P1 收尾 —— 事件面板区块与 90 天响应时间趋势

事件时间线(面板侧):
- 监控列表下方「最近事件」区块(最近 20 条:时间/类型/目标/详情),
  无事件自动隐藏;维护窗口内事件带「维护」标记;窄屏隐藏详情列

响应时间趋势(P1-2):
- 当日分桶在 {total,up} 外累计 latSum/latCount/latMax(仅在线样本)
- 快照输出 lat: { avg, max, days:[{key,avg}] }
- 卡片视图:可用率条下方新增「90 天日均延迟」细柱图(最近 30 天 + 均值)
- 表格视图:可用率列 tooltip 追加「90 天日均延迟 X ms(峰值 Y ms)」
- --demo 同步生成 90 天延迟样例(含起伏),静态部署也能看到效果

验证:verify-views 新增 [J] 段 5 条断言(趋势条/30 格/tooltip/事件空态/无溢出),
六套合计 159 项全通过(17+35+26+20+39+22)。

至此 P0(通知、证书)与 P1(事件时间线、维护窗口、响应时间趋势)全部落地。
This commit is contained in:
tamakyi
2026-10-14 00:32:59 +08:00
parent bdaa31f10c
commit 4bb5296525
14 changed files with 561 additions and 294 deletions
+41 -6
View File
@@ -37,7 +37,7 @@ const CONCURRENCY = 8;
const WRITE_DEBOUNCE_MS = 300;
const DEFAULT_INTERVAL_SEC = 60;
const PORT = Number(process.env.PORT || 8787);
const VERSION = '0.10.0';
const VERSION = '0.11.0';
const UR_DEFAULT_POLL_SEC = 300;
const UR_MIN_REFRESH_GAP_MS = 60000;
@@ -118,14 +118,43 @@ function pruneDaily(daily) {
while (keys.length > UPTIME_DAYS) delete daily[keys.shift()];
}
function recordDaily(daily, ok) {
// 记录一次探测到「当日分桶」;latency 仅在在线时传入(离线结果没有有意义的延迟)
// 同时累计延迟统计(sum/count/max),用于面板的 90 天延迟趋势
function recordDaily(daily, ok, latency = null) {
const key = dayKey();
const bucket = daily[key] ?? (daily[key] = { total: 0, up: 0 });
bucket.total++;
if (ok) bucket.up++;
if (ok) {
bucket.up++;
if (Number.isFinite(latency) && latency >= 0) {
bucket.latSum = (bucket.latSum ?? 0) + latency;
bucket.latCount = (bucket.latCount ?? 0) + 1;
bucket.latMax = Math.max(bucket.latMax ?? 0, latency);
}
}
pruneDaily(daily);
}
// 从分桶聚合延迟趋势(90 天):返回 { avg, max, days: [{ key, avg }] }
function latencyTrend(daily) {
const keys = Object.keys(daily ?? {}).sort();
const days = [];
let sum = 0;
let count = 0;
let max = 0;
for (const key of keys) {
const b = daily[key];
if (!b?.latCount) continue;
const avg = Math.round(b.latSum / b.latCount);
days.push({ key, avg });
sum += b.latSum;
count += b.latCount;
max = Math.max(max, b.latMax ?? 0);
}
if (!count) return null;
return { avg: Math.round(sum / count), max, days };
}
function intervalMs(monitor) {
const sec = Number(monitor.interval);
// 非法或缺失的 interval 一律回落到默认值,避免算出 NaN 让该目标永久不再被调度
@@ -180,7 +209,7 @@ async function syncUptimeRobot(runtime, { minGapMs = 0 } = {}) {
s.history = item.history;
s.uptimeRatios = item.uptimeRatios;
s.downSeconds = item.downSeconds;
if (item.status !== 'pending') recordDaily(s.daily, item.status === 'up');
if (item.status !== 'pending') recordDaily(s.daily, item.status === 'up', item.latency);
}
// 清理 UR 侧已删除的监控
for (const [id, s] of runtime.state) {
@@ -287,7 +316,7 @@ async function probeDue(runtime, force = false) {
s.lastResult = result;
s.history.push({ t: result.checkedAt, ok: result.status === 'up', ms: result.latency ?? 0 });
if (s.history.length > HISTORY_LIMIT) s.history.shift();
recordDaily(s.daily, result.status === 'up');
recordDaily(s.daily, result.status === 'up', result.latency);
const tag = result.status === 'up' ? '[up] ' : '[down]';
console.log(`${tag} ${m.id.padEnd(20)} ${String(result.latency ?? '-').padStart(5)}ms ${result.detail}`);
} catch (err) {
@@ -633,6 +662,9 @@ function snapshot(runtime, demo = false) {
const maintenance = activeMaintenanceFor(config, id);
if (maintenance) entry.maintenance = { note: maintenance.note ?? null, to: maintenance.to ?? null };
}
// 90 天延迟趋势(自当日分桶聚合,自建与 UR 镜像目标都适用)
const trend = latencyTrend(s.daily);
if (trend) entry.lat = trend;
monitors[id] = entry;
}
@@ -762,7 +794,10 @@ function writeDemo(config) {
const key = dayKey(new Date(Date.now() - d * 86400000));
const missRatio = alwaysDown ? 1 : (seed + d * 13) % 23 === 0 ? 0.05 : (seed + d * 7) % 11 === 0 ? 0.012 : 0;
const miss = Math.round(perDay * missRatio);
daily[key] = { total: perDay, up: perDay - miss };
// 演示用延迟:随日期缓慢起伏,让 90 天趋势有形状可看
const up = perDay - miss;
const avg = alwaysDown ? 0 : 40 + ((seed + d * 5) % 160);
daily[key] = { total: perDay, up, latSum: avg * up, latCount: up, latMax: avg + 60 };
}
s.daily = daily;
}