const VERSION = '0.8.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', 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 '
暂无历史数据
'; 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 `${escapeHtml(tip)}`; }).join(''); return `${bars}`; } 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 ``; }).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 `
${cells}
${label}
`; } 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 = `

${escapeHtml(m.name)}

${escapeHtml(typeLabel(m))}`; 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 = `
${st?.latency != null ? `${st.latency} ms` : '–'}
` + `
${escapeHtml(resultText(st, m))}
` + `
${fmtRelative(st?.lastCheck)}
`; 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 = `当前展示过滤为 ${escapeHtml(dataFilter())},但服务端快照的 dataFilter 为 ${escapeHtml(serverFilter)}:其中 ${missing} 个目标不在快照中,因此显示为「等待检测」。` + `如需一致,请把 settings.dataFilter 设为相同值,或去掉地址栏里的 ?filter= 参数。`; container.appendChild(el); } function renderView() { const grid = $('monitor-grid'); grid.innerHTML = ''; document.body.dataset.view = state.view; if (!allMonitors().length) { grid.innerHTML = '
当前数据源下没有可展示的监控目标。
'; 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 = `状态服务${hide ? '' : '目标'}延迟结果最近检测心率(48 次)90 天可用率`; 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 ? '' : `${escapeHtml(targetText(m))}`; tr.innerHTML = `${s.label}` + `${escapeHtml(m.name)}${escapeHtml(typeLabel(m))}` + targetCell + `${st?.latency != null ? `${st.latency} ms` : '–'}` + `${escapeHtml(resultText(st, m))}` + `${fmtRelative(st?.lastCheck)}` + `${heartbeatSvg(st?.history)}` + `${up ? `${miniUptimeBar(st?.daily)}${up.pct.toFixed(1)}%` : '–'}`; 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 ``; }).join(''); return `${cells}`; } 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 isNarrowScreen() { return window.matchMedia('(max-width: 700px)').matches; } 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 = `
数据加载失败:${escapeHtml(err.message)}
请检查 monitors.json 与数据源配置。
`; } /* ---------- 刷新循环 ---------- */ 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 ? `${escapeHtml(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); } /* ---------- 主题 ---------- */ 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; 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();