用户反馈的两点: 1. 遮罩看不出效果 → 人物与单车统一全局半透明 settings.decoOpacity(默认 .78) (此前 0.92/0.80 不一致且几乎无感),各自保留边缘渐隐 2. 切视图时人物比例变化 → 删掉卡片视图专用的 300px 高度,两种视图尺寸一致 3. 保留原始素材 → 原图归档 assets-src/,处理流程固化为 scripts/prepare-artwork.py 全局审阅发现并修复: - 无障碍对比度:两套主题的状态色当文字用时远低于 AA(最低 2.56:1)→ 双色体系 (填充色 ≥3:1 保持鲜亮 / 文字色 ≥4.5:1 同色系加深),暗色模式同步 - 暗色模式主题失效:顶栏/卡片/表头/分组行是硬编码白色 → 改为随模式切换的变量 - 平板竖屏 768px 表格需内部横滚 242px → 自动切卡片阈值 700 → 1024 - Cloudflare Worker 快照补齐 dataUpdatedAt/updateIntervalSec/dataFilter - 清理失效 CSS(.logo .halo) 测试脚本修掉两个自身 bug(均为误报):color(srgb/…) 与半透明底的解析、 渐变背景被误判为透明。verify-views 新增对比度断言 8 条,四套合计 89 项全通过。
851 lines
32 KiB
JavaScript
851 lines
32 KiB
JavaScript
const VERSION = '0.9.0';
|
||
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',
|
||
refreshTimer: null,
|
||
nextRefreshAt: 0,
|
||
loading: false,
|
||
// 服务端未开放/拒绝强制探测时置位,后续刷新退回只读轮询,避免每轮都撞 403
|
||
refreshDenied: false,
|
||
};
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
const SOURCE_LABEL = {
|
||
api: '边缘 API(实时)',
|
||
static: '静态快照',
|
||
browser: '浏览器直连',
|
||
};
|
||
|
||
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||
const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
|
||
|
||
async function fetchJson(url, timeoutMs = 8000) {
|
||
const res = await fetch(url, { 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}`;
|
||
}
|
||
|
||
async function resolveSource() {
|
||
const forced = new URLSearchParams(location.search).get('source');
|
||
const mode = forced || state.config.settings?.dataSource || 'auto';
|
||
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('api/status', 5000);
|
||
return setSource('api');
|
||
} catch {
|
||
return setSource('browser');
|
||
}
|
||
}
|
||
// auto:优先边缘 API,其次静态快照,最后浏览器直连
|
||
try {
|
||
await fetchJson('api/status', 3000);
|
||
return setSource('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;
|
||
}
|
||
|
||
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';
|
||
head.innerHTML = `<span class="dot"></span><h3>${escapeHtml(m.name)}</h3><span class="type-badge">${escapeHtml(typeLabel(m))}</span>`;
|
||
|
||
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);
|
||
|
||
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);
|
||
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';
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
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))}">${escapeHtml(targetText(m))}</td>`;
|
||
tr.innerHTML =
|
||
`<td class="t-state"><span class="dot"></span>${s.label}</td>` +
|
||
`<td class="t-name">${escapeHtml(m.name)}<span class="type-badge">${escapeHtml(typeLabel(m))}</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>` +
|
||
`<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>`;
|
||
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,
|
||
};
|
||
}
|
||
|
||
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;
|
||
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();
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
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 { /* 忽略 */ }
|
||
}
|
||
const icon = document.querySelector('link[rel="icon"]');
|
||
if (icon) icon.setAttribute('href', state.theme === 'shiroko' ? 'assets/favicon-shiroko.png' : 'assets/favicon.svg');
|
||
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'));
|
||
// 横竖屏切换 / 窗口缩放时重算视图(不覆盖用户显式选择,故 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;
|
||
}
|
||
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();
|