Files
tamakyi bf27f7b385 feat(ui): 最近事件随分组页签联动过滤 + favicon 提供 WebP
① 最近事件随筛选过滤

此前事件只受后端 dataFilter 约束,与面板上方的分组页签无关 —— 切页签后
监控列表变了,事件列表却纹丝不动。

两个真实缺陷(都是实测发现,不是推断):
1. setFilter() 只调 renderTabs() + renderView(),**没调 renderEvents()**
2. 过滤依据用 allMonitors(),它只受 dataFilter 约束、不受分组影响
   → 改为 visibleGroups().flatMap(...)
3. 无匹配事件时提前 return,没清空 list.innerHTML → 旧行仍留在 DOM 里,
   换个分组再切回来会看到过期内容

实测:「全部」8 条 → 「UptimeRobot」1 条(正是「狼的评论系统 /
UptimeRobot:403 Forbidden」)→「基础设施」0 条且区块自动隐藏。

过滤用「当前可见目标 id 集合」反查,而不是事件自身的 group 字段 ——
外部数据源(UptimeRobot)的事件在快照里没有 group 字段,按名字反查才准。

② favicon 提供 WebP(保留原图与 PNG)

先核实了实际情况:assets-src 下三个原图(bike 1.8MB / figure 366KB /
icon 277KB)此前已转成 WebP,public/assets 里唯一的 PNG 是 favicon-shiroko.png。
因此本次只处理 favicon:新增 64×64 WebP(2.7KB,比 PNG 的 5.6KB 省一半)。

**PNG 不删** —— favicon 是浏览器最早的请求,WebP 覆盖虽广但旧浏览器会直接
不显示图标,留 PNG 作回退代价只有几 KB,换来「任何浏览器都有图标」更稳妥。
原图 assets-src/ 完全未动,prepare-artwork.py 可随时重新生成。

页面用两个 <link rel="icon" type=...> 让浏览器按格式自动挑选
(认识 WebP 就取 WebP,否则回落 PNG / SVG)。

③ 顺带修一个既存缺陷
探针的 MIME 表**缺 .webp**,导致三张 webp 素材一直被当作
application/octet-stream 伺服(能显示但类型不对)。已补。

测试:verify-views 49→52 项(事件随页签过滤、切回全部恢复、无事件时清空 DOM)。
verify.cjs 实为 24 项(此前 README 记的 17 是旧数据)。全项目 8 套 346 项通过。
2026-10-14 17:35:01 +08:00

1049 lines
42 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const VERSION = '0.12.2';
const HB_LIMIT = 48;
const UPTIME_DAYS = 90;
const BROWSER_CONCURRENCY = 6;
// 表格为默认视图(目标多时可一屏扫完);卡片视图保留给需要心率图细节的场景
const VIEWS = { table: '表格', cards: '卡片' };
const DEFAULT_VIEW = 'table';
const DEFAULT_REFRESH_SEC = 30;
// 只看 UptimeRobot 时,其自身检测间隔就是 5 分钟,刷新再快也没有新数据
const EXTERNAL_REFRESH_SEC = 300;
const state = {
config: null,
status: null,
source: null,
filter: 'all',
view: 'cards',
theme: 'default',
eventsExpanded: false,
refreshTimer: null,
nextRefreshAt: 0,
loading: false,
// 服务端未开放/拒绝强制探测时置位,后续刷新退回只读轮询,避免每轮都撞 403
refreshDenied: false,
};
const $ = (id) => document.getElementById(id);
const SOURCE_LABEL = {
api: '边缘 API(实时)',
remote: '远程探针(跨域)',
static: '静态快照',
browser: '浏览器直连',
};
const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
async function fetchJson(url, timeoutMs = 8000) {
// 相对路径 + 存在远程数据源时,自动改写到该地址(跨域读取由对方开放 CORS)
const base = remoteBase();
const full = base && url.startsWith('api/') ? `${base}/${url}` : url;
const res = await fetch(full, { cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) });
if (!res.ok) {
const err = new Error(`HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return res.json();
}
function fmtRelative(iso) {
if (!iso) return '–';
const diff = Date.now() - new Date(iso).getTime();
if (Number.isNaN(diff)) return '–';
const s = Math.max(0, Math.round(diff / 1000));
if (s < 10) return '刚刚';
if (s < 60) return `${s} 秒前`;
const m = Math.floor(s / 60);
if (m < 60) return `${m} 分钟前`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} 小时前`;
return `${Math.floor(h / 24)} 天前`;
}
function fmtTime(iso) {
if (!iso) return '–';
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? '–' : d.toLocaleString('zh-CN', { hour12: false });
}
async function mapLimit(items, limit, fn) {
let idx = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (idx < items.length) {
const i = idx++;
await fn(items[i], i);
}
});
await Promise.all(workers);
}
/* ---------- 数据源 ---------- */
function setSource(s) {
state.source = s;
$('source-badge').textContent = `数据源:${SOURCE_LABEL[s] ?? s}`;
// 跨域模式下「刷新」没有意义:/api/refresh 会被服务端的 refreshLocalOnly 挡住
// (对端是代理地址而非私网),前端也会退回只读。与其给一个必然失败的按钮,不如隐藏。
const crossOrigin = s === 'remote';
const btn = $('refresh-btn');
if (btn) {
btn.hidden = crossOrigin;
btn.title = crossOrigin ? '' : '立即重新跑一轮探测(需要服务端允许)';
}
const ctrl = document.querySelector('.controls');
if (ctrl) ctrl.classList.toggle('no-refresh', crossOrigin);
}
// 跨域数据源:settings.dataUrl 指向另一台机器上的探针(通常是本机,但页面部署在别处)。
// 例:"https://monitor.example.com" —— 前端会从 https://monitor.example.com/api/status 取数据。
// 该地址必须与本页同为 HTTPS,否则浏览器会以「混合内容」为由拦截(与 CORS 无关)。
function remoteBase() {
const raw = String(state.config?.settings?.dataUrl ?? '').trim();
if (!raw) return '';
try {
const u = new URL(raw);
return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin + u.pathname.replace(/\/+$/, '') : '';
} catch {
return '';
}
}
async function resolveSource() {
const forced = new URLSearchParams(location.search).get('source');
const mode = forced || state.config.settings?.dataSource || 'auto';
// 配置了远程数据源时,「api」与「auto」都优先尝试远程地址
const remote = remoteBase();
const apiUrl = remote ? `${remote}/api/status` : 'api/status';
if (mode === 'browser') return setSource('browser');
if (mode === 'static') {
try {
await fetchJson('status.json', 5000);
return setSource('static');
} catch {
return setSource('browser');
}
}
if (mode === 'api') {
try {
await fetchJson(apiUrl, 6000);
return setSource(remote ? 'remote' : 'api');
} catch {
return setSource('browser');
}
}
// auto:优先远程/边缘 API,其次静态快照,最后浏览器直连
try {
await fetchJson(apiUrl, 4000);
return setSource(remote ? 'remote' : 'api');
} catch { /* 无边缘/远程 API */ }
try {
await fetchJson('status.json', 3000);
return setSource('static');
} catch { /* 无静态快照 */ }
return setSource('browser');
}
/* ---------- 浏览器直连探测 ---------- */
async function probeBrowserOne(m) {
const started = Date.now();
const timeoutMs = (m.timeout ?? 10) * 1000;
if (m.type === 'tcp') {
// 浏览器无原始 TCP 能力,以 HTTP 连接行为近似:快速拒绝=端口关闭;超时无响应=端口开放但非 HTTP 服务(启发式)
const t = Math.min(timeoutMs, 4000);
try {
await fetch(`http://${m.host}:${m.port}/`, { mode: 'no-cors', cache: 'no-store', signal: AbortSignal.timeout(t) });
return { status: 'up', latency: Date.now() - started, code: null, detail: `端口 ${m.port} 可连接(启发式)` };
} catch (err) {
if (err.name === 'TimeoutError') {
return { status: 'up', latency: Date.now() - started, code: null, detail: `端口 ${m.port} 开放(无 HTTP 响应,启发式)` };
}
return { status: 'down', latency: Date.now() - started, code: null, detail: '端口不可连接(或被防火墙拒绝)' };
}
}
// 先尝试 cors 模式读取真实状态码;目标未开放跨域时降级为 no-cors 仅判断可达性
try {
const res = await fetch(m.url, {
method: m.method || 'GET',
mode: 'cors',
redirect: 'manual',
cache: 'no-store',
signal: AbortSignal.timeout(timeoutMs),
});
const expect = m.expectStatus ?? [200];
const ok = expect.includes(res.status);
return {
status: ok ? 'up' : 'down',
latency: Date.now() - started,
code: res.status,
detail: ok ? `HTTP ${res.status}` : `HTTP ${res.status}(期望 ${expect.join('/')})`,
};
} catch { /* 跨域被拒或网络不可达,降级 */ }
try {
await fetch(m.url, { mode: 'no-cors', cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) });
return { status: 'up', latency: Date.now() - started, code: null, detail: '跨域可达(浏览器模式无法读取状态码)' };
} catch (err) {
return { status: 'down', latency: Date.now() - started, code: null, detail: err.name === 'TimeoutError' ? '请求超时' : '不可达' };
}
}
async function probeFromBrowser(config) {
const monitors = {};
let up = 0;
let down = 0;
let latencySum = 0;
await mapLimit(config.monitors, BROWSER_CONCURRENCY, async (m) => {
const r = await probeBrowserOne(m);
if (r.status === 'up') { up++; latencySum += r.latency; } else { down++; }
monitors[m.id] = { ...r, lastCheck: new Date().toISOString(), history: [] };
});
return {
prober: 'shiroko-monitor-browser',
demo: false,
generatedAt: new Date().toISOString(),
stats: { total: config.monitors.length, up, down, pending: 0, avgLatency: up ? Math.round(latencySum / up) : null },
monitors,
};
}
/* ---------- 渲染 ---------- */
function heartbeatSvg(history) {
const pts = (history ?? []).slice(-HB_LIMIT);
if (!pts.length) return '<div class="hb-empty">暂无历史数据</div>';
const w = 100;
const h = 30;
const max = Math.max(...pts.map((p) => p.ms || 0), 1);
const bw = w / pts.length;
const bars = pts.map((p, i) => {
const bh = p.ok ? Math.max(2, (p.ms / max) * (h - 2)) : 2;
const color = p.ok ? 'var(--up)' : 'var(--down)';
const tip = `${fmtTime(p.t)} · ${p.ok ? `在线 ${p.ms}ms` : '离线'}`;
return `<rect x="${(i * bw).toFixed(2)}" y="${h - bh}" width="${Math.max(bw - 0.6, 0.4).toFixed(2)}" height="${bh}" rx="0.8" fill="${color}"><title>${escapeHtml(tip)}</title></rect>`;
}).join('');
return `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" role="img" aria-label="最近检测心率">${bars}</svg>`;
}
function statusText(st) {
if (!st || st.status === 'pending') return { label: '等待检测', cls: 'pending' };
return st.status === 'up' ? { label: '在线', cls: 'up' } : { label: '离线', cls: 'down' };
}
function lastNDays(n) {
const out = [];
const now = new Date();
const p = (x) => String(x).padStart(2, '0');
for (let i = n - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i);
out.push(`${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`);
}
return out;
}
function uptimeSummary(st) {
const daily = st?.daily ?? {};
let total = 0;
let up = 0;
for (const k of Object.keys(daily)) {
total += daily[k].total ?? 0;
up += daily[k].up ?? 0;
}
if (total) return { pct: (up / total) * 100, total, up, days: Object.keys(daily).length, source: 'local' };
// 没有逐日数据时回退到数据源提供的官方聚合比率(UptimeRobot 镜像目标自带 90 天可用率)
const provided = Number(st?.uptimeRatios?.[90]);
if (Number.isFinite(provided)) return { pct: provided, total: 0, up: 0, days: 90, source: 'external' };
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>`;
}
// 最近事件区块(故障 / 恢复 / 证书阈值 / 维护窗口内的事件)
// 默认只列最近 EVENTS_COLLAPSED 条,其余折叠在「展开全部」后面,避免长列表把页面撑得过长
const EVENTS_COLLAPSED = 8;
function renderEvents() {
const box = $('events');
if (!box) return;
const list = $('events-list');
const empty = $('events-empty');
const count = $('events-count');
// 事件随分组页签联动:只展示当前页签下目标的事件。
// 用「当前可见目标 id 集合」反查,而不是事件自身的 group 字段 ——
// 外部数据源(UptimeRobot)的事件在快照里没有 group,按名字反查才准。
const visibleIds = new Set(visibleGroups().flatMap(([, list]) => list.map((m) => m.id)));
const all = (state.status?.events ?? []).filter((e) => visibleIds.has(e.id));
box.hidden = all.length === 0;
empty.hidden = all.length > 0;
if (!all.length) {
// 必须清空列表:仅隐藏区块的话旧行仍留在 DOM 里(换个分组再切回来会看到过期内容)
list.innerHTML = '';
if (count) count.textContent = '';
const more = $('events-more');
if (more) more.hidden = true;
return;
}
const expanded = state.eventsExpanded === true;
const shown = expanded ? all : all.slice(0, EVENTS_COLLAPSED);
if (count) count.textContent = all.length > shown.length ? `最近 ${shown.length} / ${all.length} 条` : `最近 ${all.length} 条`;
const label = { down: '故障', up: '恢复', tls: '证书', maintenance: '维护' };
// 与监控表格同一套结构(tr/td + data-status),因此行高、边框、hover、状态底色天然一致
const statusOf = { down: 'down', up: 'up', tls: 'pending', maintenance: 'up' };
list.innerHTML = shown.map((e) => {
const maint = e.maintenance ? '<span class="ev-maint" title="维护窗口内,未计入可用率、未发送告警">维护</span>' : '';
return `<tr class="ev" data-status="${escapeHtml(statusOf[e.type] ?? 'up')}">
<td class="ev-time" title="${escapeHtml(fmtTime(e.t))}">${escapeHtml(fmtRelative(e.t))}</td>
<td class="ev-type"><span class="ev-badge">${escapeHtml(label[e.type] ?? e.type)}</span>${maint}</td>
<td class="ev-name" title="${escapeHtml(e.name)}"><span class="clamp2">${escapeHtml(e.name)}</span></td>
<td class="ev-detail" title="${escapeHtml(e.detail ?? '')}"><span class="clamp2">${escapeHtml(e.detail ?? '')}</span></td>
</tr>`;
}).join('');
const more = $('events-more');
if (more) {
const hidden = all.length - shown.length;
more.hidden = all.length <= EVENTS_COLLAPSED;
more.textContent = expanded ? '收起' : `展开全部 ${all.length} 条`;
more.dataset.hidden = String(hidden);
}
}
function uptimeBar(st) {
const daily = st?.daily; const cells = lastNDays(UPTIME_DAYS).map((key) => {
const b = daily?.[key];
let cls = 'lv-none';
let tip = `${key} · 无数据`;
if (b && b.total > 0) {
const pct = (b.up / b.total) * 100;
cls = pct >= 99 ? 'lv-ok' : pct >= 95 ? 'lv-warn' : 'lv-bad';
tip = `${key} · 可用率 ${pct.toFixed(1)}%(${b.up}/${b.total} 次)`;
}
return `<span class="uptime-cell ${cls}" title="${escapeHtml(tip)}"></span>`;
}).join('');
const s = uptimeSummary(st);
const label = s ? `90天 ${s.pct.toFixed(1)}%` : '90天 —';
let labelTip;
if (!s) labelTip = '可用率数据积累中(探针需持续运行)';
else if (s.source === 'external') labelTip = `由数据源提供(UptimeRobot 官方 90 天可用率);逐日色条由本探针同步后累积`;
else labelTip = `按已积累的 ${s.days} 天统计:${s.up}/${s.total} 次在线`;
return `<div class="uptime"><div class="uptime-bar">${cells}</div><span class="uptime-label" title="${escapeHtml(labelTip)}">${label}</span></div>`;
}
function hideTargets() {
return state.config?.settings?.hideTargets === true;
}
function targetText(m) {
if (hideTargets()) return '已隐藏';
if (m.url) return m.url;
if (m.host) return m.port ? `${m.host}:${m.port}` : m.host;
return '—';
}
function typeLabel(m) {
// 外部数据源(UptimeRobot)标注来源与协议,便于与自建探测区分
if (isExternal(m)) return `UR·${m.sourceLabel ?? ''}`.replace(/·$/, '');
if (m.type === 'tcp') return 'TCP';
if (m.type === 'banner') return String(m.protocol || 'BANNER').toUpperCase();
return m.method || 'GET';
}
function resultText(st, m) {
if (st?.code != null) return String(st.code);
if (isExternal(m)) return '–';
if (m.type === 'tcp') return '端口';
if (m.type === 'banner') return '标识串';
return '–';
}
function renderCard(m) {
const st = state.status?.monitors?.[m.id];
const s = statusText(st);
const card = document.createElement('article');
card.className = 'card';
card.dataset.status = s.cls;
card.dataset.id = m.id;
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>${certBadge(st)}${maintBadge(st)}`;
const target = document.createElement('div');
target.className = 'card-target';
target.title = targetText(m);
target.textContent = targetText(m);
const metrics = document.createElement('div');
metrics.className = 'metrics';
// 外部数据源只有「最近一次状态变更」时间,语义与自建探测的「最近检测」不同,tooltip 里说明
const checkTitle = isExternal(m) && st?.lastCheck
? `UptimeRobot 最近一次状态变更:${fmtTime(st.lastCheck)}`
: fmtTime(st?.lastCheck);
metrics.innerHTML =
`<div class="metric"><b>${st?.latency != null ? `${st.latency} ms` : '–'}</b><label>响应延迟</label></div>` +
`<div class="metric"><b>${escapeHtml(resultText(st, m))}</b><label>结果</label></div>` +
`<div class="metric"><b title="${escapeHtml(checkTitle)}">${fmtRelative(st?.lastCheck)}</b><label>最近检测</label></div>`;
const hb = document.createElement('div');
hb.className = 'heartbeat';
hb.innerHTML = heartbeatSvg(st?.history);
const up = document.createElement('div');
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, detail);
return card;
}
function groupMonitors(monitors) {
const groups = new Map();
for (const m of monitors) {
const g = m.group || '未分组';
if (!groups.has(g)) groups.set(g, []);
groups.get(g).push(m);
}
return groups;
}
// 面板展示的目标 = 配置文件里的自建目标 + 快照中带 meta 的外部数据源目标(如 UptimeRobot),
// 过滤优先级:URL ?filter= > 快照 dataFilter(服务端口径,私有配置覆盖后仍一致)> 配置 settings.dataFilter
function dataFilter() {
const q = new URLSearchParams(location.search).get('filter');
if (['both', 'config', 'uptimerobot'].includes(q)) return q;
const f = state.status?.dataFilter ?? state.config?.settings?.dataFilter;
return ['both', 'config', 'uptimerobot'].includes(f) ? f : 'both';
}
function allMonitors() {
const filter = dataFilter();
const cfg = state.config?.monitors ?? [];
const out = filter === 'uptimerobot' ? [] : [...cfg];
if (filter === 'config') return out;
const known = new Set(cfg.map((m) => m.id));
for (const [id, st] of Object.entries(state.status?.monitors ?? {})) {
if (known.has(id) || !st?.meta) continue;
out.push({ id, ...st.meta });
}
return out;
}
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();
const groups = groupMonitors(monitors);
nav.innerHTML = '';
const all = document.createElement('button');
all.type = 'button';
all.className = `tab${state.filter === 'all' ? ' active' : ''}`;
all.textContent = `全部 ${monitors.length}`;
all.addEventListener('click', () => setFilter('all'));
nav.appendChild(all);
for (const [g, list] of groups) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `tab${state.filter === g ? ' active' : ''}`;
btn.textContent = `${g} ${list.length}`;
btn.addEventListener('click', () => setFilter(g));
nav.appendChild(btn);
}
}
function setFilter(g) {
state.filter = g;
renderTabs();
renderView();
// 事件按当前页签过滤,切页签后必须一并重渲染
renderEvents();
}
function visibleGroups() {
const all = [...groupMonitors(allMonitors())];
return state.filter === 'all' ? all : all.filter(([g]) => g === state.filter);
}
/* ---------- 视图:卡片 / 表格 / 大屏 / 极简 ---------- */
// 展示过滤(URL ?filter=)与服务端快照的 dataFilter 不一致时,被过滤出来的目标不在快照里,
// 会整页显示「等待检测」——这里给出明确解释,避免看起来像故障
function renderFilterNotice(container) {
const snapMonitors = state.status?.monitors ?? {};
const shown = allMonitors();
const missing = shown.filter((m) => !snapMonitors[m.id]).length;
const serverFilter = state.status?.dataFilter;
if (!missing || !shown.length || !serverFilter || serverFilter === dataFilter()) return;
const el = document.createElement('div');
el.className = 'notice';
el.innerHTML =
`当前展示过滤为 <b>${escapeHtml(dataFilter())}</b>,但服务端快照的 dataFilter 为 <b>${escapeHtml(serverFilter)}</b>:其中 ${missing} 个目标不在快照中,因此显示为「等待检测」。` +
`如需一致,请把 <code>settings.dataFilter</code> 设为相同值,或去掉地址栏里的 <code>?filter=</code> 参数。`;
container.appendChild(el);
}
function renderView() {
const grid = $('monitor-grid');
grid.innerHTML = '';
document.body.dataset.view = state.view;
if (!allMonitors().length) {
grid.innerHTML = '<div class="empty">当前数据源下没有可展示的监控目标。</div>';
return;
}
renderFilterNotice(grid);
({ table: renderTableView, cards: renderCardsView }[state.view] ?? renderTableView)(grid);
}
function renderCardsView(container) {
const groups = visibleGroups();
for (const [g, list] of groups) {
if (groups.length > 1) container.appendChild(groupTitle(g));
for (const m of list) container.appendChild(renderCard(m));
}
}
function groupTitle(text) {
const h = document.createElement('h2');
h.className = 'group-title';
h.textContent = text;
return h;
}
function renderTableView(container) {
const hide = hideTargets();
const wrap = document.createElement('div');
wrap.className = 'table-wrap';
const table = document.createElement('table');
table.className = 'monitor-table';
const thead = document.createElement('thead');
thead.innerHTML = `<tr><th>状态</th><th>服务</th>${hide ? '' : '<th>目标</th>'}<th>延迟</th><th>结果</th><th>最近检测</th><th>心率(48 次)</th><th>90 天可用率</th></tr>`;
const tbody = document.createElement('tbody');
const groups = visibleGroups();
for (const [g, list] of groups) {
if (groups.length > 1) {
const tr = document.createElement('tr');
tr.className = 'group-row';
const td = document.createElement('td');
td.colSpan = hide ? 7 : 8;
td.textContent = g;
tr.appendChild(td);
tbody.appendChild(tr);
}
for (const m of list) tbody.appendChild(tableRow(m, hide));
}
table.append(thead, tbody);
wrap.appendChild(table);
container.appendChild(wrap);
}
function tableRow(m, hide) {
const st = state.status?.monitors?.[m.id];
const s = statusText(st);
const up = uptimeSummary(st);
const tr = document.createElement('tr');
tr.dataset.status = s.cls;
tr.dataset.id = m.id;
const targetCell = hide ? '' : `<td class="t-target" title="${escapeHtml(targetText(m))}"><span class="clamp2">${escapeHtml(targetText(m))}</span></td>`;
// 名称与徽标分行:名称限两行、徽标独占一行,两者高度都固定,行高不再随名称/地址长度变化
const tags = `${typeLabel(m) ? `<span class="type-badge">${escapeHtml(typeLabel(m))}</span>` : ''}${certBadge(st)}${maintBadge(st)}`;
tr.innerHTML =
`<td class="t-state"><span class="dot"></span>${s.label}</td>` +
`<td class="t-name" title="${escapeHtml(m.name)}"><span class="clamp2">${escapeHtml(m.name)}</span><span class="t-tags">${tags}</span></td>` +
targetCell +
`<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>`;
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;
}
function miniUptimeBar(daily) {
const cells = lastNDays(UPTIME_DAYS).map((key) => {
const b = daily?.[key];
let cls = 'lv-none';
if (b && b.total > 0) {
const pct = (b.up / b.total) * 100;
cls = pct >= 99 ? 'lv-ok' : pct >= 95 ? 'lv-warn' : 'lv-bad';
}
return `<span class="uptime-cell ${cls}"></span>`;
}).join('');
return `<span class="uptime-bar uptime-bar-mini">${cells}</span>`;
}
function renderViewSwitch() {
const box = $('view-switch');
if (!box) return;
box.innerHTML = '';
for (const [key, label] of Object.entries(VIEWS)) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `view-btn${state.view === key ? ' active' : ''}`;
btn.textContent = label;
btn.title = `${label}视图`;
btn.addEventListener('click', () => setView(key));
box.appendChild(btn);
}
}
// 表格在窄屏必须横向滚动。实测表格最小舒适宽度约 1010px(列含心率图与可用率条),
// 因此视口窄于 1024px 时默认落到卡片;用户显式选择过视图则以选择为准。
const TABLE_MIN_WIDTH = 1024;
function isNarrowScreen() {
return window.innerWidth < TABLE_MIN_WIDTH;
}
function resolveView() {
const q = new URLSearchParams(location.search).get('view');
if (q && VIEWS[q]) return q;
let saved = null;
try {
saved = localStorage.getItem('shiroko.view');
} catch { /* 隐私模式下 localStorage 不可用 */ }
const cfg = state.config?.settings?.view;
const resolved = [saved, cfg, DEFAULT_VIEW].find((v) => v && VIEWS[v]) ?? DEFAULT_VIEW;
// 用户没在这个设备上显式选过视图时,窄屏自动落到卡片(表格在手机上要横向拖)
if (!saved && resolved === 'table' && isNarrowScreen()) return 'cards';
return resolved;
}
function setView(view, { persist = true } = {}) {
state.view = VIEWS[view] ? view : 'cards';
if (persist) {
try {
localStorage.setItem('shiroko.view', state.view);
} catch { /* 忽略存储失败 */ }
}
renderViewSwitch();
renderView();
}
// 顶部汇总按「当前实际展示的目标」计算:URL ?filter= 覆盖服务端口径时,统计也不会与列表不一致
function localStats() {
const monitors = allMonitors();
let up = 0;
let down = 0;
let pending = 0;
let latSum = 0;
let latCount = 0;
let uptimeUp = 0;
let uptimeTotal = 0;
// 与探针口径一致:混合来源时平均延迟只统计自建探测;若展示集合只有外部数据源,则以其延迟为唯一口径
const hasProbe = monitors.some((m) => !isExternal(m));
for (const m of monitors) {
const st = state.status?.monitors?.[m.id];
if (!st || st.status === 'pending') {
pending++;
continue;
}
if (st.status === 'up') up++;
else down++;
if (st.latency != null && (!isExternal(m) || !hasProbe)) {
latSum += st.latency;
latCount++;
}
const s = uptimeSummary(st);
if (s) {
uptimeTotal += s.total;
uptimeUp += s.up;
}
}
return {
total: monitors.length,
up,
down,
pending,
avgLatency: latCount ? Math.round(latSum / latCount) : null,
avgUptime90: uptimeTotal ? Math.round((uptimeUp / uptimeTotal) * 1000) / 10 : null,
};
}
// 管理入口:地址写在 settings.adminUrl(可为内网地址、Tailscale 地址、VPS 域名等,
// 由使用者自己决定怎么暴露)。默认不配置即不渲染,避免在公网面板上泄露管理面位置。
function renderAdminLink() {
const el = $('admin-link');
if (!el) return;
const raw = String(state.config?.settings?.adminUrl ?? '').trim();
// 只接受 http/https,避免 javascript: 之类被当作可点击链接注入
let url = null;
try {
const u = new URL(raw);
if (u.protocol === 'http:' || u.protocol === 'https:') url = u;
} catch { /* 不是合法绝对 URL 则视为未配置 */ }
if (!url) { el.hidden = true; return; }
el.href = url.href;
el.textContent = '管理';
el.title = `管理后台:${url.href}\n需绑定 2FA 并通过两步验证才能进入`;
el.hidden = false;
}
function renderSummary() {
const st = state.status;
const stats = localStats();
const down = stats.down;
const pending = stats.pending;
const pill = $('overall-pill');
pill.className = 'pill';
if (!st) pill.textContent = '检测中…';
else if (!stats.total) {
pill.classList.add('pending');
pill.textContent = '无展示目标';
} else if (down > 0) {
pill.classList.add('bad');
pill.textContent = `${down} 个服务异常`;
} else if (pending > 0) {
pill.classList.add('pending');
pill.textContent = '检测中…';
} else {
pill.classList.add('ok');
pill.textContent = '全部服务正常';
}
$('stat-total').textContent = stats.total;
$('stat-up').textContent = stats.up;
$('stat-down').textContent = stats.down;
$('stat-latency').textContent = stats.avgLatency != null ? `${stats.avgLatency} ms` : '–';
$('stat-uptime').textContent = stats.avgUptime90 != null ? `${stats.avgUptime90}%` : '–';
// 展示「数据最后更新时刻」(服务端产出新数据的时间)而非快照生成时间:后者每次请求都变,无法反映新鲜度
const updatedMs = dataUpdatedMs();
$('updated-at').textContent = updatedMs ? `数据更新于 ${fmtTime(state.status?.dataUpdatedAt)}` : '数据更新于 –';
$('updated-at').title = updatedMs
? `服务端数据最后变更:${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) {
urChip.hidden = !(ur?.enabled && ur.ok === false);
if (!urChip.hidden) urChip.title = `UptimeRobot 同步失败:${ur.error ?? '未知错误'}(保留上次数据,目标状态未被误判)`;
}
}
function renderAll() {
renderSummary();
renderTabs();
renderViewSwitch();
renderView();
renderEvents();
renderAdminLink();
}
function renderError(err) {
const grid = $('monitor-grid');
grid.innerHTML = `<div class="empty">数据加载失败:${escapeHtml(err.message)}<br>请检查 monitors.json 与数据源配置。</div>`;
}
/* ---------- 刷新循环 ---------- */
function refreshIntervalSec() {
const configured = Number(state.config?.settings?.refreshInterval);
if (Number.isFinite(configured) && configured > 0) return Math.max(5, configured);
// 未显式配置时:只看 UptimeRobot 的话,其自身 5 分钟才更新一次,刷新更快没有意义
return dataFilter() === 'uptimerobot' ? EXTERNAL_REFRESH_SEC : DEFAULT_REFRESH_SEC;
}
// 服务端产出新数据的节奏(秒),由快照提供;浏览器直连模式没有服务端节奏
function serverCadenceSec() {
const n = Number(state.status?.updateIntervalSec);
return Number.isFinite(n) && n > 0 ? n : null;
}
function dataUpdatedMs() {
const t = state.status?.dataUpdatedAt ? new Date(state.status.dataUpdatedAt).getTime() : 0;
return Number.isFinite(t) ? t : 0;
}
// 服务端数据是否已明显过期(超过 1.5 个周期没更新,例如探针刚启动或被休眠过)
function isDataStale() {
const cadence = serverCadenceSec();
const updated = dataUpdatedMs();
if (!cadence || !updated) return false;
return Date.now() - updated > cadence * 1500;
}
// 下一次检查的间隔:api 模式下对齐「服务端上次更新 + 一个周期」,refreshInterval 只作为上限;
// 静态快照与浏览器直连没有可对齐的服务端节奏,直接按 refreshInterval 轮询
function nextCheckInSec() {
const cap = refreshIntervalSec();
const cadence = serverCadenceSec();
const updated = dataUpdatedMs();
if (state.source !== 'api' || !cadence || !updated) return cap;
const left = Math.round((updated + cadence * 1000 - Date.now()) / 1000);
if (left <= 0) return Math.min(cap, Math.max(5, cadence)); // 已过预期更新时刻:稍后重试
return Math.max(5, Math.min(left, cap));
}
// 强制检测需等待各目标探测返回(含超时目标,服务端另有 22s 兜底),故用更宽松的超时
const REFRESH_TIMEOUT_MS = 30000;
function setLoading(on, text = '刷新中…') {
state.loading = on;
const btn = $('refresh-btn');
btn.disabled = on;
// 车轮图标(砂狼白子骑行意象,主题里会转起来)
btn.innerHTML = on
? `<svg class="wheel" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" opacity="0.35"/><g stroke="currentColor" stroke-width="1.3"><line x1="12" y1="2" x2="12" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="4.9" y1="4.9" x2="19.1" y2="19.1"/><line x1="19.1" y1="4.9" x2="4.9" y2="19.1"/></g><circle cx="12" cy="12" r="2.2" fill="currentColor"/></svg><span>${escapeHtml(text)}</span>`
: '刷新';
}
async function refresh({ force = false } = {}) {
if (state.loading) return;
// 自动刷新只读取服务端已产出的数据(探测由服务端按各自 interval 持续驱动,不因访客而变);
// 仅两种情况触发重新检测:① 手动点击刷新 ② 服务端数据明显过期时自愈一次
const recheck = state.source === 'api' && !state.refreshDenied && (force || isDataStale());
setLoading(true, recheck ? '检测中…' : '刷新中…');
try {
if (state.source === 'browser') {
state.status = await probeFromBrowser(state.config);
} else if (state.source === 'api') {
if (recheck) {
// 首次加载:先用服务端已有快照立即出图,避免强制检测(含超时目标,可能十几秒)期间显示空白面板
if (!state.status) {
try {
state.status = await fetchJson('api/status');
renderAll();
} catch { /* 拿不到旧快照就继续等强制检测结果 */ }
}
try {
// 只看 UptimeRobot 时不需要跑本地探测轮次,只同步 UR 数据
const scope = dataFilter() === 'uptimerobot' ? '?scope=external' : '';
state.status = await fetchJson(`api/refresh${scope}`, REFRESH_TIMEOUT_MS);
} catch (err) {
// 服务端未开放强制探测(403/401)→ 本次会话退回只读轮询;限流(429)→ 仅是这一轮降级
if (err.status === 403 || err.status === 401) markRefreshDenied();
else if (err.status === 429) $('refresh-btn').title = '刷新过于频繁,已显示最近一次数据(服务端限流保护)';
else throw err;
state.status = await fetchJson('api/status');
}
} else {
state.status = await fetchJson('api/status');
}
} else {
state.status = await fetchJson('status.json');
}
renderAll();
} catch (err) {
if (state.source !== 'browser') {
// 数据源失效(如静态部署缺少 status.json)时降级为浏览器直连
setSource('browser');
setLoading(false);
return refresh({ force });
}
renderError(err);
} finally {
setLoading(false);
resetCountdown();
}
}
function markRefreshDenied() {
if (state.refreshDenied) return;
state.refreshDenied = true;
const btn = $('refresh-btn');
btn.title = '服务端未开放「重新检测」,刷新仅重新读取最新数据';
$('source-badge').textContent = `${SOURCE_LABEL[state.source]}(只读)`;
}
function resetCountdown() {
state.nextRefreshAt = Date.now() + nextCheckInSec() * 1000;
updateCountdownText();
}
function updateCountdownText() {
const left = Math.max(0, Math.round((state.nextRefreshAt - Date.now()) / 1000));
$('countdown').textContent = `下次检查 ${left}s`;
$('countdown').title = '自动刷新只读取服务端已产出的数据,节奏对齐服务端的更新时间;点击「刷新」可立即重新检测';
}
function startAutoRefresh() {
clearInterval(state.refreshTimer);
resetCountdown();
state.refreshTimer = setInterval(() => {
// 以截止时间计算剩余秒数,而非累加 tick:浏览器对后台标签页会节流定时器,计数法会导致刷新严重滞后
if (Date.now() >= state.nextRefreshAt) refresh();
else updateCountdownText();
}, 1000);
}
/* ---------- 主题 ---------- */
const THEMES = { default: '默认', shiroko: '砂狼白子' };
function resolveTheme() {
const q = new URLSearchParams(location.search).get('theme');
let saved = null;
try {
saved = localStorage.getItem('shiroko.theme');
} catch { /* 隐私模式 */ }
return [q, saved, state.config?.settings?.theme].find((t) => t && THEMES[t]) ?? 'default';
}
// 角色立绘位:主题为白子且配置了 artwork 时才插入;文件不存在则静默移除(不留破图)
function applyArtwork() {
document.querySelector('.artwork')?.remove();
const src = state.config?.settings?.artwork;
if (state.theme !== 'shiroko' || !src) return;
const img = document.createElement('img');
img.className = 'artwork';
img.alt = '';
img.src = src;
img.addEventListener('error', () => img.remove());
document.body.appendChild(img);
}
// favicon 格式协商:同尺寸的 WebP 体积更小,浏览器按 type 自动挑第一个认识的;
// 不认识 WebP 的旧浏览器会跳到 PNG,两者并存保证任何环境都有图标。
function setFavicon(shiroko) {
const head = document.head;
if (!head) return;
for (const el of head.querySelectorAll('link[data-favicon]')) el.remove();
const links = shiroko
? [
{ href: 'assets/favicon-shiroko.webp', type: 'image/webp' },
{ href: 'assets/favicon-shiroko.png', type: 'image/png' },
]
: [{ href: 'assets/favicon.svg', type: 'image/svg+xml' }];
for (const l of links) {
const el = document.createElement('link');
el.rel = 'icon';
el.href = l.href;
el.type = l.type;
el.dataset.favicon = '1';
head.appendChild(el);
}
}
function applyTheme(theme, { persist = true } = {}) {
state.theme = THEMES[theme] ? theme : 'default';
if (state.theme === 'default') delete document.body.dataset.theme;
else document.body.dataset.theme = state.theme;
// 装饰(人物/单车)的全局透明度:一个值同时作用于两者,避免其中一个更抢眼
const decoOpacity = Number(state.config?.settings?.decoOpacity);
document.body.style.setProperty('--sh-deco-opacity', String(Number.isFinite(decoOpacity) && decoOpacity > 0 && decoOpacity <= 1 ? decoOpacity : 0.78));
if (persist) {
try {
localStorage.setItem('shiroko.theme', state.theme);
} catch { /* 忽略 */ }
}
setFavicon(state.theme === 'shiroko');
applyArtwork();
const btn = $('theme-btn');
if (btn) {
btn.classList.toggle('active', state.theme === 'shiroko');
btn.title = state.theme === 'shiroko' ? '当前主题:砂狼白子(点击切回默认)' : '当前主题:默认(点击切换到砂狼白子主题)';
}
}
function bindEvents() {
// 只有手动点「刷新」才会触发一次重新检测;自动刷新是只读的
$('refresh-btn').addEventListener('click', () => refresh({ force: true }));
$('theme-btn')?.addEventListener('click', () => applyTheme(state.theme === 'shiroko' ? 'default' : 'shiroko'));
$('events-more')?.addEventListener('click', () => {
state.eventsExpanded = !state.eventsExpanded;
renderEvents();
});
// 横竖屏切换 / 窗口缩放时重算视图(不覆盖用户显式选择,故 persist: false)
window.addEventListener('resize', () => {
const next = resolveView();
if (next !== state.view) setView(next, { persist: false });
});
}
/* ---------- 启动 ---------- */
async function init() {
try {
state.config = await fetchJson('monitors.json');
} catch (err) {
renderError(err);
return;
}
// 配置以服务端合并后的版本为准(/api/monitors 含 private overlay):
// 静态 monitors.json 是不入库的公开层,管理端写入的设置只落在私有层,
// 不合并的话面板读不到(例如 adminUrl)。
// 纯静态部署没有这个接口,此时沿用已读到的公开配置即可。
try {
const merged = await fetchJson('api/monitors', 5000);
if (merged?.settings) state.config = { ...state.config, settings: { ...state.config.settings, ...merged.settings } };
} catch { /* 无边缘接口(静态部署):保留公开配置 */ }
document.title = state.config.settings?.title || 'Shiroko Monitor';
$('site-title').textContent = state.config.settings?.title || 'Shiroko Monitor';
$('site-desc').textContent = state.config.settings?.description || '';
const versionEl = document.querySelector('.version');
if (versionEl) versionEl.textContent = `shiroko-monitor v${VERSION}`;
applyTheme(resolveTheme());
await resolveSource();
state.view = resolveView();
bindEvents();
renderAll();
await refresh();
startAutoRefresh();
}
init();