用户诉求:管理端与前端完全分离,本地探针只跑 /admin,前端部署到 GitHub Pages 并硬编码管理地址,探测仍在本地常驻。用户自己搭隧道给本地套 HTTPS。 硬限制(务必先看):GitHub Pages 强制 HTTPS,而浏览器「混合内容」策略禁止 HTTPS 页面 fetch HTTP 资源 —— 这与 CORS 无关,改设置也没用。 因此 settings.dataUrl 必须与面板页面同为 HTTPS。 服务端 - 只读接口(/api/status、/api/monitors、/api/events、/api/v2/getMonitors、 /api/refresh)按 settings.corsOrigins 白名单回显 Access-Control-Allow-Origin - /api/admin/* 一律不放行跨域,预检直接 403 —— 外部页面既读不到也改不了 - 绝不使用 '*':白名单里写 '*' 不放行任何来源 前端 - settings.dataUrl 存在时,所有 api/ 相对路径自动改写到该地址 - 数据源标记为「远程探针(跨域)」,页脚可区分 - 管理界面新增两项配置:跨域数据源地址、允许跨域的页面地址(改完立即生效) 关键设计:origin 匹配必须考虑端口 origin 是「协议 + 主机 + 端口」的完整组合。最初白名单存 http://localhost(无端口), 导致本地前端跑在 localhost:5173 时被拒 —— 浏览器实测 Failed to fetch 而 curl 能过。 修法:回环主机(localhost / 127.0.0.1)忽略端口放行,其余要求完整 origin 精确匹配。 测试:verify-security 25→40 项,新增 [D3] CORS 白名单 14 条 + [D4] '*' 写法 1 条。 全项目 8 套 334 项通过。 附记一个测试上的教训:跨域 fetch 时 JS 读不到 Access-Control-Allow-Origin (CORS 规范规定只有同源能读),所以断言「是否放行」要看 fetch 有没有拿到数据, 不能看响应头 —— 我一开始用 headers.get() 判断,拿到 null 误以为失败。
1005 lines
40 KiB
JavaScript
1005 lines
40 KiB
JavaScript
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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||
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}`;
|
||
}
|
||
|
||
// 跨域数据源: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 all = state.status?.events ?? [];
|
||
box.hidden = all.length === 0;
|
||
empty.hidden = all.length > 0;
|
||
if (!all.length) return;
|
||
|
||
const expanded = state.eventsExpanded === true;
|
||
const shown = expanded ? all : all.slice(0, EVENTS_COLLAPSED);
|
||
const count = $('events-count');
|
||
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();
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
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'));
|
||
$('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();
|