Files
shiroko-monitor/public/assets/app.js
T
tamakyi 3db3ce570a chore: v0.6.0 baseline — 主题美化前存档
已实现:HTTP/TCP/Banner 探测、90 天可用率、表格/卡片视图、
数据源过滤、UptimeRobot 双向兼容、服务端驱动的刷新节奏、
刷新接口滥用防护与信息泄露控制。
2026-10-13 18:31:58 +08:00

779 lines
28 KiB
JavaScript
Raw 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.6.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',
refreshTimer: null,
nextRefreshAt: 0,
loading: false,
// 服务端未开放/拒绝强制探测时置位,后续刷新退回只读轮询,避免每轮都撞 403
refreshDenied: false,
};
const $ = (id) => document.getElementById(id);
const SOURCE_LABEL = {
api: '边缘 API(实时)',
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) {
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);
}
}
function resolveView() {
const q = new URLSearchParams(location.search).get('view');
let saved = null;
try {
saved = localStorage.getItem('shiroko.view');
} catch { /* 隐私模式下 localStorage 不可用 */ }
const cfg = state.config?.settings?.view;
return [q, saved, cfg, DEFAULT_VIEW].find((v) => v && VIEWS[v]) ?? DEFAULT_VIEW;
}
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;
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 (!isExternal(m) && st.latency != null) {
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.textContent = on ? text : '刷新';
}
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);
}
function bindEvents() {
// 只有手动点「刷新」才会触发一次重新检测;自动刷新是只读的
$('refresh-btn').addEventListener('click', () => refresh({ force: true }));
}
/* ---------- 启动 ---------- */
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}`;
await resolveSource();
state.view = resolveView();
bindEvents();
renderAll();
await refresh();
startAutoRefresh();
}
init();