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(事件时间线、维护窗口、响应时间趋势)全部落地。
@@ -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`)
|
||||
|
||||
|
Before Width: | Height: | Size: 313 KiB After Width: | Height: | Size: 324 KiB |
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 304 KiB |
|
Before Width: | Height: | Size: 428 KiB After Width: | Height: | Size: 595 KiB |
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 318 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 266 KiB |
@@ -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)';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||