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
+19
View File
@@ -436,6 +436,16 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自
每次状态翻转(故障 / 恢复 / 证书阈值)都会追加到 `data/events.json`(私有目录,保留最近 500 条),并通过 `GET /api/events` 读取。快照里也会带上最近 20 条供面板使用;`redactDetails` 开启时,快照内事件只保留类型与时间。
**面板展示**:监控列表下方有一个「最近事件」区块(最多 20 条),每行显示 `时间 / 类型 / 目标 / 详情`;维护窗口内的事件会带「维护」标记(表示未计入可用率、未发告警)。无事件时区块自动隐藏。
### 响应时间趋势
当日分桶在 `{ total, up }` 之外还累计 `latSum / latCount / latMax`(仅在线样本),探针据此在快照里给出 `lat: { avg, max, days: [{ key, avg }] }`:
- **卡片视图**:可用率条下方多一条「90 天日均延迟」细柱图(取最近 30 天),悬停看单日数值,右侧为 90 天均值
- **表格视图**:可用率列的 tooltip 里追加 `90 天日均延迟 X ms(峰值 Y ms)`
- 数据从探针开始累计当天起累积,跑满 90 天后覆盖全周期;离线样本不计入延迟统计
## 部署方案
| 方案 | 架构 | 数据更新 | 成本 | 适合场景 |
@@ -529,6 +539,15 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自
- [x] 页脚改为展示「数据更新于 … + 下次检查 Ns」,tooltip 内含服务端更新节奏与快照生成时间
- [x] `scripts/verify.cjs` 适配纯外部数据源部署(自动跳过不适用的探测类断言),并新增「倒计时对齐服务端」断言
### v0.11.0 已实现(P1 收尾)
- [x] **「最近事件」面板区块**:监控列表下方展示最近 20 条状态翻转(故障 / 恢复 / 证书 / 维护),无事件自动隐藏;窄屏隐藏「详情」列
- [x] **90 天响应时间趋势**:当日分桶累计 `latSum/latCount/latMax`,快照输出 `lat: { avg, max, days[] }`;卡片视图在可用率条下新增延迟细柱图(最近 30 天 + 90 天均值),表格视图在可用率列 tooltip 追加日均/峰值
- [x] `--demo` 同步生成 90 天延迟样例(含起伏形状),静态部署也能看到趋势效果
- [x] `verify-views` 新增 [J] 段 5 条断言(趋势条渲染 / 30 格 / tooltip / 事件空态 / 无溢出),**P0+P1 全部完成**
> 至此最初规划的 P0(通知、证书)与 P1(事件时间线、维护窗口、响应时间趋势)全部落地。
### v0.10.0 已实现(P0:通知 + 证书)
- [x] **通知渠道①:邮件** —— 自写极简 SMTP 客户端(**零依赖**,不引 nodemailer):465 隐式 TLS / 587·25 自动 STARTTLS、AUTH LOGIN 与 PLAIN、UTF-8 主题按 RFC2047 编码 + base64 正文、多收件人、5xx 与连接错误可读;服务器不支持 STARTTLS 时**默认拒绝明文发信**(需显式 `allowInsecure`)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

After

Width:  |  Height:  |  Size: 595 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 266 KiB

+1 -1
View File
@@ -11,7 +11,7 @@
import { connect } from 'cloudflare:sockets';
const VERSION = '0.10.0';
const VERSION = '0.11.0';
const HISTORY_LIMIT = 48;
const UPTIME_DAYS = 90;
const USER_AGENT = 'shiroko-monitor/0.3 (lightweight uptime panel)';
+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;
}
+51 -7
View File
@@ -1,4 +1,4 @@
const VERSION = '0.10.0';
const VERSION = '0.11.0';
const HB_LIMIT = 48;
const UPTIME_DAYS = 90;
const BROWSER_CONCURRENCY = 6;
@@ -226,9 +226,47 @@ function uptimeSummary(st) {
return null;
}
// 90 天日均延迟趋势(卡片视图):取最近 30 天的日均延迟画成细柱,右侧给 90 天均值
function latTrendBar(lat) {
const days = lat?.days?.slice(-30) ?? [];
if (days.length < 2) return '';
const max = Math.max(...days.map((d) => d.avg), 1);
const bars = days.map((d) => {
const h = Math.max(10, Math.round((d.avg / max) * 100));
return `<span class="lat-cell" style="height:${h}%" title="${escapeHtml(d.key)}:日均 ${d.avg} ms"></span>`;
}).join('');
return `<div class="lat-trend" title="90 天日均延迟 ${lat.avg} ms · 峰值 ${lat.max} ms(数据来自当日分桶累计)">
<span class="lat-label">延迟</span>
<span class="lat-bars">${bars}</span>
<span class="lat-avg">${lat.avg} ms</span>
</div>`;
}
// 最近事件区块(故障 / 恢复 / 证书阈值 / 维护窗口内的事件)
function renderEvents() {
const box = $('events');
if (!box) return;
const list = $('events-list');
const empty = $('events-empty');
const events = (state.status?.events ?? []).slice(0, 20);
box.hidden = events.length === 0;
empty.hidden = events.length > 0;
const count = $('events-count');
if (count) count.textContent = events.length ? `最近 ${events.length} 条` : '';
const label = { down: '故障', up: '恢复', tls: '证书', maintenance: '维护' };
list.innerHTML = events.map((e) => {
const maint = e.maintenance ? '<span class="ev-maint" title="维护窗口内,未计入可用率、未发送告警">维护</span>' : '';
return `<li class="ev ev-${escapeHtml(e.type)}">
<span class="ev-time" title="${escapeHtml(fmtTime(e.t))}">${escapeHtml(fmtRelative(e.t))}</span>
<span class="ev-type">${escapeHtml(label[e.type] ?? e.type)}${maint}</span>
<span class="ev-name" title="${escapeHtml(e.name)}">${escapeHtml(e.name)}</span>
<span class="ev-detail" title="${escapeHtml(e.detail ?? '')}">${escapeHtml(e.detail ?? '')}</span>
</li>`;
}).join('');
}
function uptimeBar(st) {
const daily = st?.daily;
const cells = lastNDays(UPTIME_DAYS).map((key) => {
const daily = st?.daily; const cells = lastNDays(UPTIME_DAYS).map((key) => {
const b = daily?.[key];
let cls = 'lv-none';
let tip = `${key} · 无数据`;
@@ -309,14 +347,14 @@ function renderCard(m) {
hb.innerHTML = heartbeatSvg(st?.history);
const up = document.createElement('div');
up.innerHTML = uptimeBar(st);
up.innerHTML = `${uptimeBar(st)}${latTrendBar(st?.lat)}`;
const detail = document.createElement('div');
detail.className = 'card-detail';
detail.title = st?.detail ?? '';
detail.textContent = st?.detail ?? '等待检测';
card.append(head, target, metrics, hb, up.firstElementChild ?? document.createElement('div'), detail);
card.append(head, target, metrics, hb, up, detail);
return card;
}
@@ -493,8 +531,13 @@ function tableRow(m, hide) {
`<td class="t-num">${st?.latency != null ? `${st.latency} ms` : '–'}</td>` +
`<td class="t-num">${escapeHtml(resultText(st, m))}</td>` +
`<td class="t-num" title="${escapeHtml(fmtTime(st?.lastCheck))}">${fmtRelative(st?.lastCheck)}</td>` +
`<td class="t-hb">${heartbeatSvg(st?.history)}</td>` +
`<td class="t-uptime">${up ? `<span class="u-mini">${miniUptimeBar(st?.daily)}</span><span class="t-pct">${up.pct.toFixed(1)}%</span>` : '<span class="t-pct">–</span>'}</td>`;
`<td class="t-hb">${heartbeatSvg(st?.history)}</td>`;
const latTip = st?.lat ? ` · 90 天日均延迟 ${st.lat.avg} ms(峰值 ${st.lat.max} ms)` : '';
const uptimeCell = document.createElement('td');
uptimeCell.className = 't-uptime';
uptimeCell.title = `${up ? `可用率 ${up.pct.toFixed(1)}%` : '可用率数据积累中'}${latTip}`;
uptimeCell.innerHTML = up ? `<span class="u-mini">${miniUptimeBar(st?.daily)}</span><span class="t-pct">${up.pct.toFixed(1)}%</span>` : '<span class="t-pct">–</span>';
tr.appendChild(uptimeCell);
return tr;
}
@@ -661,6 +704,7 @@ function renderAll() {
renderTabs();
renderViewSwitch();
renderView();
renderEvents();
}
function renderError(err) {
+11 -1
View File
@@ -50,6 +50,16 @@
<div class="empty">正在加载监控配置…</div>
</main>
<!-- 最近事件:故障 / 恢复 / 证书提醒(含维护窗口内的事件) -->
<section class="events" id="events" hidden>
<div class="events-head">
<h2>最近事件</h2>
<span class="events-count" id="events-count"></span>
</div>
<ul class="events-list" id="events-list"></ul>
<p class="events-empty" id="events-empty" hidden>暂无事件记录(故障、恢复与证书提醒会出现在这里)</p>
</section>
<footer class="footer">
<span id="source-badge" class="footer-item">数据源:–</span>
<span id="demo-chip" class="footer-item demo-chip" hidden>演示数据</span>
@@ -57,7 +67,7 @@
<span id="notify-badge" class="footer-item" hidden>通知 –</span>
<span id="updated-at" class="footer-item">–</span>
<span id="countdown" class="footer-item">–</span>
<span class="footer-item version">shiroko-monitor v0.10.0</span>
<span class="footer-item version">shiroko-monitor v0.11.0</span>
</footer>
<script type="module" src="assets/app.js"></script>
+377 -279
View File
File diff suppressed because it is too large Load Diff
+61
View File
@@ -6,7 +6,10 @@
* 用法:NODE_PATH=<playwright node_modules> node scripts/verify-views.cjs [baseUrl]
*/
const fs = require('node:fs');
const http = require('node:http');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { chromium } = require('playwright');
const BASE = process.argv[2] || 'http://localhost:8787';
@@ -244,6 +247,64 @@ async function main() {
check(`${c.label}对比度 ≥${need}`, c.r >= need, `${c.r}:1`);
}
// ---------- J. 事件区块与延迟趋势(演示快照,进程内临时服务) ----------
console.log('\n[J] 事件区块与延迟趋势');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-events-'));
try {
fs.cpSync(path.join(__dirname, '..', 'public'), tmpDir, { recursive: true });
const cfgPath = path.join(tmpDir, 'monitors.json');
const demoCfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
demoCfg.settings.dataFilter = 'config';
fs.writeFileSync(cfgPath, `${JSON.stringify(demoCfg, null, 2)}\n`);
await new Promise((resolve) => {
const child = spawn(process.execPath, [path.join(__dirname, '..', 'prober', 'node', 'index.js'),
'--once', '--demo', `--config=${cfgPath}`, `--local-config=${path.join(tmpDir, 'none.json')}`,
`--out=${path.join(tmpDir, 'status.json')}`], { stdio: 'ignore' });
child.on('exit', (code) => resolve(code));
});
const demoServer = http.createServer((req, res) => {
const rel = decodeURIComponent(req.url.split('?')[0]);
const file = path.join(tmpDir, rel === '/' ? 'index.html' : rel);
fs.readFile(file, (err, data) => {
if (err) {
res.writeHead(404);
res.end();
return;
}
const mime = { '.html': 'text/html;charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.png': 'image/png' }[path.extname(file)] ?? 'application/octet-stream';
res.writeHead(200, { 'content-type': mime });
res.end(data);
});
});
await new Promise((r) => demoServer.listen(8894, r));
const dp = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await dp.goto('http://localhost:8894/?source=static&view=cards', { waitUntil: 'domcontentloaded' });
await dp.waitForSelector('.card', { timeout: 20000 });
await dp.waitForTimeout(1200);
const dj = await dp.evaluate(() => ({
cards: document.querySelectorAll('.card').length,
trends: document.querySelectorAll('.lat-trend').length,
cellsPerTrend: document.querySelector('.lat-trend')?.children[1]?.children.length ?? 0,
trendTip: document.querySelector('.lat-trend')?.title ?? '',
eventsHidden: document.getElementById('events').hidden,
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
}));
check('卡片视图渲染 90 天延迟趋势条', dj.trends > 0 && dj.cellsPerTrend === 30, `${dj.trends} 条 × ${dj.cellsPerTrend} 格`);
check('趋势条 tooltip 含日均与峰值', /90 天日均延迟 \d+ ms · 峰值 \d+ ms/.test(dj.trendTip), dj.trendTip.slice(0, 60));
check('无事件时区块隐藏', dj.eventsHidden === true, '');
check('演示视图无横向溢出', dj.overflow <= 0, `${dj.overflow}px`);
// 表格视图:可用率列 tooltip 应带日均延迟
await dp.evaluate(() => document.querySelector('.view-btn').click());
await dp.waitForSelector('table.monitor-table', { timeout: 10000 });
await dp.waitForTimeout(500);
const dt = await dp.evaluate(() => document.querySelector('tr[data-status] .t-uptime')?.title ?? '');
check('表格可用率 tooltip 含 90 天日均延迟', /90 天日均延迟 \d+ ms/.test(dt), dt.slice(0, 60));
await dp.close();
await new Promise((r) => demoServer.close(r));
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
// 429 是刷新限流保护在起作用(快速连续导航各触发一次强制探测),面板已优雅降级为只读数据;
// 这里只把 429 以外的错误视为失败
const hardErrors = errors.filter((e) => !/429/.test(e));