feat: P0 通知(邮件 + MeoW)与 TLS 证书监控,事件时间线与维护窗口

通知(P0-1):
- 邮件:自写 SMTP 客户端,零依赖。465 隐式 TLS / 587·25 STARTTLS、
  AUTH LOGIN+PLAIN、RFC2047 中文主题、base64 正文、多收件人;
  服务器不支持 STARTTLS 时默认拒绝明文(需显式 allowInsecure)
- MeoW:按官方文档实现,昵称寻址、msgType/htmlHeight 走查询参数、
  以响应体 status 判定成功(HTTP 恒 200)、429/404/403 可读化、
  本地限流器遵守 3s/15min/60h
- 告警:连续 N 次失败去抖 + 恢复通知 + 同轮合并 + 去抖状态持久化
  (data/notify-state.json,gitignore)+ 静默时段(跨零点)
- --test-notify 自检命令、--data-dir / SHIROKO_DATA_DIR

证书(P0-2):
- 独立 TLS 握手取 notAfter(不动既有 fetch 探测路径),默认 6 小时一次
- 面板徽标 ≤14 天黄 / ≤7 天红,阈值 14/7/3 各告一次、已过期只报最严重一档
- 单个目标可用 tls: false 关闭

事件与维护(P1-1 的后端部分):
- data/events.json 追加式事件流(最近 500 条)+ GET /api/events +
  快照内附最近 20 条;redactDetails 时脱敏
- 维护窗口:窗口内不计入可用率、不发告警、显示「维护中」徽标

测试抓到并修复的三个真 bug:
1. SMTP 空闲超时无人处理 → 服务器不响应会把探针循环挂死
2. MeoW 限流器把空历史当 0 → 第一条通知永远被自己拦掉
3. 去抖状态在无事轮次不落盘 → 连续失败计数丢失、永远达不到阈值

验证:新增 verify-notify.cjs 39 项(假 SMTP / 假 MeoW / 三轮集成 / 维护窗口 /
真实证书握手),六套合计 154 项全通过。
This commit is contained in:
tamakyi
2026-10-13 23:54:46 +08:00
parent ea846dee31
commit bdaa31f10c
17 changed files with 1545 additions and 291 deletions
+38 -3
View File
@@ -1,4 +1,4 @@
const VERSION = '0.9.1';
const VERSION = '0.10.0';
const HB_LIMIT = 48;
const UPTIME_DAYS = 90;
const BROWSER_CONCURRENCY = 6;
@@ -286,7 +286,7 @@ function renderCard(m) {
const head = document.createElement('div');
head.className = 'card-head';
// 名称列在窄屏会被截断,title 保证鼠标悬停仍能看到完整名称
head.innerHTML = `<span class="dot"></span><h3 title="${escapeHtml(m.name)}">${escapeHtml(m.name)}</h3><span class="type-badge">${escapeHtml(typeLabel(m))}</span>`;
head.innerHTML = `<span class="dot"></span><h3 title="${escapeHtml(m.name)}">${escapeHtml(m.name)}</h3><span class="type-badge">${escapeHtml(typeLabel(m))}</span>${certBadge(st)}${maintBadge(st)}`;
const target = document.createElement('div');
target.className = 'card-target';
@@ -356,6 +356,25 @@ function isExternal(m) {
return m.source === 'uptimerobot';
}
// 证书徽标:仅在临近到期时出现(≤14 天黄色、≤7 天红色),避免常驻打扰
function certBadge(st) {
const t = st?.tls;
if (!t || t.daysLeft == null) return '';
if (t.error) return `<span class="tls-badge danger" title="证书检查失败:${escapeHtml(t.error)}">证书异常</span>`;
if (t.daysLeft > 14) return '';
const cls = t.daysLeft <= 7 ? 'danger' : 'warn';
const tip = `证书到期:${escapeHtml(String(t.validTo ?? '').slice(0, 10))}${t.issuer ? `(${escapeHtml(t.issuer)})` : ''}`;
return `<span class="tls-badge ${cls}" title="${tip}">证书 ${t.daysLeft} 天</span>`;
}
// 维护窗口徽标:该时段内不计入可用率统计、不发告警
function maintBadge(st) {
const m = st?.maintenance;
if (!m) return '';
const tip = `维护窗口${m.to ? `至 ${escapeHtml(String(m.to).replace('T', ' ').slice(0, 16))}` : ''}${m.note ? `:${escapeHtml(m.note)}` : ''}\n期间不计入可用率、不发告警`;
return `<span class="maint-badge" title="${tip}">维护中</span>`;
}
function renderTabs() {
const nav = $('group-tabs');
const monitors = allMonitors();
@@ -469,7 +488,7 @@ function tableRow(m, hide) {
tr.dataset.id = m.id;
const targetCell = hide ? '' : `<td class="t-target" title="${escapeHtml(targetText(m))}">${escapeHtml(targetText(m))}</td>`; tr.innerHTML =
`<td class="t-state"><span class="dot"></span>${s.label}</td>` +
`<td class="t-name" title="${escapeHtml(m.name)}">${escapeHtml(m.name)}<span class="type-badge">${escapeHtml(typeLabel(m))}</span></td>` +
`<td class="t-name" title="${escapeHtml(m.name)}">${escapeHtml(m.name)}<span class="type-badge">${escapeHtml(typeLabel(m))}</span>${certBadge(st)}${maintBadge(st)}</td>` +
targetCell +
`<td class="t-num">${st?.latency != null ? `${st.latency} ms` : '–'}</td>` +
`<td class="t-num">${escapeHtml(resultText(st, m))}</td>` +
@@ -613,6 +632,22 @@ function renderSummary() {
? `服务端数据最后变更:${fmtTime(state.status?.dataUpdatedAt)}(距现在 ${fmtRelative(state.status?.dataUpdatedAt)})\n快照生成:${fmtTime(st?.generatedAt)}\n服务端更新节奏:每 ${serverCadenceSec() ?? '—'} 秒`
: '尚无服务端数据';
$('demo-chip').hidden = !st?.demo;
// 通知渠道状态:有失败渠道时给出具体原因(点开 title 看)
const notify = st?.notify;
const nb = $('notify-badge');
if (nb) {
const failed = (notify?.lastResult?.results ?? []).filter((r) => !r.ok);
if (!notify?.enabled) {
nb.hidden = true;
} else {
nb.hidden = false;
nb.classList.toggle('warn-chip', failed.length > 0);
nb.textContent = `通知:${(notify.channels ?? []).join(' + ') || '无'}${notify.lastResult ? ` · 最近 ${fmtRelative(notify.lastResult.at)}` : ''}${failed.length ? ` · ${failed.length} 个渠道失败` : ''}`;
nb.title = failed.length
? `发送失败:${failed.map((r) => `${r.channel}: ${r.error}`).join('\n')}`
: (notify.lastResult ? `最近一次:${notify.lastResult.at},${(notify.lastResult.results ?? []).map((r) => `${r.channel} ${r.ok ? '成功' : '失败'}`).join('、')}` : '尚未发送过通知');
}
}
const ur = st?.sources?.uptimerobot;
const urChip = $('ur-chip');
if (urChip) {
+16
View File
@@ -412,6 +412,22 @@ tr[data-status="pending"] .t-state { color: var(--pending-text); }
white-space: nowrap;
}
.t-num { font-variant-numeric: tabular-nums; white-space: nowrap; }
/* 证书 / 维护徽标:出现在名称旁,不占独立列 */
.tls-badge, .maint-badge {
margin-left: 8px;
padding: 0 6px;
border-radius: 999px;
font-size: 10px;
font-weight: 650;
vertical-align: middle;
white-space: nowrap;
cursor: help;
}
.tls-badge.warn { background: var(--pending-bg); color: var(--pending-text); }
.tls-badge.danger { background: var(--down-bg); color: var(--down-text); }
.maint-badge { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
.card-head .tls-badge, .card-head .maint-badge { flex: none; }
.t-hb svg { display: block; width: 100%; max-width: 150px; height: 24px; }
.t-uptime { white-space: nowrap; }
/* 可用率条随视口伸缩;极窄时只留百分比,避免挤压其他列 */