chore: v0.6.0 baseline — 主题美化前存档

已实现:HTTP/TCP/Banner 探测、90 天可用率、表格/卡片视图、
数据源过滤、UptimeRobot 双向兼容、服务端驱动的刷新节奏、
刷新接口滥用防护与信息泄露控制。
This commit is contained in:
tamakyi
2026-10-13 18:31:58 +08:00
commit 3db3ce570a
31 changed files with 5136 additions and 0 deletions
+778
View File
@@ -0,0 +1,778 @@
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();
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="12" fill="#16a34a"/>
<circle cx="16" cy="16" r="12" fill="none" stroke="#16a34a" stroke-opacity="0.3" stroke-width="5"/>
<path d="M11 16.5l3.5 3.5L21 13" stroke="#fff" stroke-width="2.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 349 B

+356
View File
@@ -0,0 +1,356 @@
:root {
--bg: #f5f6f8;
--surface: #ffffff;
--border: #e5e7eb;
--text: #111827;
--text-2: #6b7280;
--up: #16a34a;
--up-bg: #f0fdf4;
--down: #dc2626;
--down-bg: #fef2f2;
--pending: #d97706;
--pending-bg: #fffbeb;
--accent: #2563eb;
--uptime-ok: #16a34a;
--uptime-warn: #f59e0b;
--uptime-bad: #dc2626;
--uptime-none: #e5e7eb;
--mono: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, "Courier New", monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
--shadow: 0 1px 2px rgb(0 0 0 / 0.04);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1115;
--surface: #171a21;
--border: #2a2f3a;
--text: #e5e7eb;
--text-2: #9ca3af;
--up: #4ade80;
--up-bg: #10281c;
--down: #f87171;
--down-bg: #2a1517;
--pending: #fbbf24;
--pending-bg: #2a2113;
--accent: #60a5fa;
--uptime-none: #2a2f3a;
--shadow: none;
}
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
}
/* ---------- 顶栏 ---------- */
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 20px;
padding: 12px 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow);
}
.brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
.logo {
width: 26px;
height: 26px;
flex: none;
border-radius: 50%;
background: var(--up);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--up) 18%, transparent);
animation: pulse 2.4s ease-out infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--up) 30%, transparent); }
70% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--up) 0%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--up) 0%, transparent); }
}
.brand-text { min-width: 0; }
.brand-text h1 { margin: 0; font-size: 15px; font-weight: 650; white-space: nowrap; }
.brand-text p { margin: 0; font-size: 12px; color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.summary { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; }
.pill {
padding: 4px 12px;
border-radius: 999px;
font-size: 13px;
font-weight: 650;
white-space: nowrap;
}
.pill.ok { background: var(--up-bg); color: var(--up); }
.pill.bad { background: var(--down-bg); color: var(--down); }
.pill.pending { background: var(--pending-bg); color: var(--pending); }
.stat { text-align: center; min-width: 44px; }
.stat b { display: block; font-size: 16px; font-weight: 700; font-variant-numeric: tabular-nums; }
.stat label { font-size: 11px; color: var(--text-2); }
.stat-up b { color: var(--up); }
.stat-down b { color: var(--down); }
#refresh-btn {
padding: 6px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text);
font-size: 13px;
font-family: var(--sans);
cursor: pointer;
transition: background 0.15s;
}
#refresh-btn:hover { background: var(--bg); }
#refresh-btn:disabled { opacity: 0.6; cursor: default; }
/* ---------- 视图切换 ---------- */
.view-switch { display: flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.view-btn {
padding: 5px 10px;
border: 0;
background: var(--surface);
color: var(--text-2);
font-size: 12px;
font-family: var(--sans);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.view-btn + .view-btn { border-left: 1px solid var(--border); }
.view-btn:hover { color: var(--text); }
.view-btn.active { background: var(--accent); color: #fff; }
/* ---------- 分组页签 ---------- */
.group-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-width: 1200px;
margin: 0 auto;
padding: 16px 20px 0;
}
.tab {
padding: 5px 14px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--text-2);
font-size: 13px;
font-family: var(--sans);
cursor: pointer;
transition: all 0.15s;
}
.tab:hover { color: var(--text); }
.tab.active { background: var(--accent); border-color: var(--accent); color: #fff; }
/* ---------- 卡片网格 ---------- */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 14px;
max-width: 1200px;
margin: 0 auto;
padding: 16px 20px 8px;
}
.group-title {
grid-column: 1 / -1;
margin: 8px 0 0;
font-size: 13px;
font-weight: 650;
color: var(--text-2);
}
.card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 16px;
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--pending);
border-radius: 12px;
box-shadow: var(--shadow);
}
.card[data-status="up"] { border-left-color: var(--up); }
.card[data-status="down"] { border-left-color: var(--down); }
.card-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
.card-head h3 { flex: 1; margin: 0; font-size: 14px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dot { width: 10px; height: 10px; flex: none; border-radius: 50%; background: var(--pending); }
/* 通用着色:卡片 / 表格行 / 极简列表 都以 data-status 标记状态 */
[data-status="up"] .dot { background: var(--up); }
[data-status="down"] .dot { background: var(--down); }
[data-status="pending"] .dot { background: var(--pending); }
.card[data-status="up"] .dot { animation: pulse-dot 2.4s ease-out infinite; }
@keyframes pulse-dot {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--up) 35%, transparent); }
70% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--up) 0%, transparent); }
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--up) 0%, transparent); }
}
.type-badge {
flex: none;
padding: 1px 7px;
border: 1px solid var(--border);
border-radius: 6px;
font-family: var(--mono);
font-size: 11px;
color: var(--text-2);
}
.card-target {
font-family: var(--mono);
font-size: 12px;
color: var(--text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.metrics { display: flex; gap: 8px; }
.metric { flex: 1; min-width: 0; }
.metric b { display: block; font-size: 14px; font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; }
.metric label { font-size: 11px; color: var(--text-2); }
.heartbeat { height: 32px; }
.heartbeat svg { display: block; width: 100%; height: 100%; }
.hb-empty { display: flex; align-items: center; height: 100%; font-size: 12px; color: var(--text-2); }
/* 90 天可用率 */
.uptime { display: flex; align-items: center; gap: 8px; }
.uptime-bar { display: flex; gap: 1px; flex: 1; min-width: 0; height: 10px; }
.uptime-cell { flex: 1; min-width: 0; border-radius: 1px; background: var(--uptime-none); }
.uptime-cell.lv-ok { background: var(--uptime-ok); }
.uptime-cell.lv-warn { background: var(--uptime-warn); }
.uptime-cell.lv-bad { background: var(--uptime-bad); }
.uptime-label { font-size: 11px; color: var(--text-2); white-space: nowrap; font-variant-numeric: tabular-nums; }
.card-detail {
font-family: var(--mono);
font-size: 12px;
color: var(--text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card[data-status="down"] .card-detail { color: var(--down); }
.empty {
grid-column: 1 / -1;
padding: 60px 0;
text-align: center;
color: var(--text-2);
}
/* 过滤口径不一致时的提示条 */
.notice {
grid-column: 1 / -1;
margin-bottom: 10px;
padding: 10px 14px;
border: 1px solid color-mix(in srgb, var(--pending) 45%, var(--border));
border-radius: 10px;
background: var(--pending-bg);
color: var(--pending);
font-size: 12px;
line-height: 1.7;
}
.notice code { font-family: var(--mono); background: color-mix(in srgb, var(--pending) 14%, transparent); padding: 0 4px; border-radius: 4px; }
/* ---------- 底栏 ---------- */
.footer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 16px;
max-width: 1200px;
margin: 0 auto;
padding: 8px 20px 28px;
font-size: 12px;
color: var(--text-2);
}
.footer .version { margin-left: auto; }
.demo-chip { padding: 1px 8px; border-radius: 999px; background: var(--pending-bg); color: var(--pending); font-weight: 600; }
.warn-chip { padding: 1px 8px; border-radius: 999px; background: var(--down-bg); color: var(--down); font-weight: 600; }
@media (max-width: 640px) {
.topbar { padding: 10px 14px; }
.summary { gap: 6px 12px; }
.grid { grid-template-columns: 1fr; padding: 12px 14px 8px; }
.group-tabs { padding: 12px 14px 0; }
.footer { padding: 8px 14px 24px; }
}
/* ================= 视图二:表格(高密度列表) ================= */
body[data-view="table"] .grid { display: block; }
.table-wrap {
overflow-x: auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow);
}
.monitor-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.monitor-table th {
padding: 10px 12px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: var(--text-2);
white-space: nowrap;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.monitor-table td { padding: 9px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; }
.monitor-table tbody tr:last-child td { border-bottom: 0; }
.monitor-table tbody tr:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
.monitor-table tbody tr[data-status="down"] { background: color-mix(in srgb, var(--down) 6%, transparent); }
.group-row td { padding: 7px 12px; font-size: 12px; font-weight: 650; color: var(--text-2); background: var(--bg); }
.t-state { white-space: nowrap; }
.t-state .dot { display: inline-block; margin-right: 6px; vertical-align: middle; }
tr[data-status="up"] .t-state { color: var(--up); }
tr[data-status="down"] .t-state { color: var(--down); }
tr[data-status="pending"] .t-state { color: var(--pending); }
.t-name { white-space: nowrap; }
.t-name .type-badge { margin-left: 8px; }
.t-target {
max-width: 280px;
font-family: var(--mono);
font-size: 12px;
color: var(--text-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.t-num { font-variant-numeric: tabular-nums; white-space: nowrap; }
.t-hb { width: 160px; }
.t-hb svg { display: block; width: 150px; height: 24px; }
.t-uptime { white-space: nowrap; }
.uptime-bar-mini { display: inline-flex; width: 110px; height: 8px; margin-right: 8px; vertical-align: middle; }
.t-pct { font-size: 12px; color: var(--text-2); font-variant-numeric: tabular-nums; }
+49
View File
@@ -0,0 +1,49 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="轻量级服务监控面板:HTTP 可用性与 TCP 端口探测">
<title>Shiroko Monitor</title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="logo" aria-hidden="true"></span>
<div class="brand-text">
<h1 id="site-title">Shiroko Monitor</h1>
<p id="site-desc">轻量级服务监控面板</p>
</div>
</div>
<div class="summary">
<span id="overall-pill" class="pill pending">检测中…</span>
<div class="stat"><b id="stat-total">–</b><label>监控数</label></div>
<div class="stat stat-up"><b id="stat-up">–</b><label>在线</label></div>
<div class="stat stat-down"><b id="stat-down">–</b><label>离线</label></div>
<div class="stat"><b id="stat-latency">–</b><label>平均延迟</label></div>
<div class="stat"><b id="stat-uptime">–</b><label>90天可用率</label></div>
<div class="view-switch" id="view-switch" role="group" aria-label="视图切换"></div>
<button id="refresh-btn" type="button">刷新</button>
</div>
</header>
<nav class="group-tabs" id="group-tabs" aria-label="分组筛选"></nav>
<main class="grid" id="monitor-grid">
<div class="empty">正在加载监控配置…</div>
</main>
<footer class="footer">
<span id="source-badge" class="footer-item">数据源:–</span>
<span id="demo-chip" class="footer-item demo-chip" hidden>演示数据</span>
<span id="ur-chip" class="footer-item warn-chip" hidden>UptimeRobot 同步异常</span>
<span id="updated-at" class="footer-item">–</span>
<span id="countdown" class="footer-item">–</span>
<span class="footer-item version">shiroko-monitor v0.6.0</span>
</footer>
<script type="module" src="assets/app.js"></script>
</body>
</html>
+100
View File
@@ -0,0 +1,100 @@
{
"settings": {
"title": "Shiroko Monitor",
"description": "轻量级服务监控面板",
"view": "table",
"dataFilter": "uptimerobot",
"refreshInterval": 300,
"dataSource": "auto",
"uptimeRobot": {
"enabled": false,
"apiKey": "",
"pollIntervalSec": 300,
"group": "UptimeRobot"
}
},
"monitors": [
{
"id": "example-com",
"name": "Example 站点",
"group": "公共服务",
"type": "http",
"url": "https://www.example.com",
"method": "GET",
"expectStatus": [200],
"interval": 60,
"timeout": 10
},
{
"id": "baidu",
"name": "百度",
"group": "公共服务",
"type": "http",
"url": "https://www.baidu.com",
"expectStatus": [200],
"interval": 60,
"timeout": 10
},
{
"id": "cloudflare-site",
"name": "Cloudflare 官网",
"group": "公共服务",
"type": "http",
"url": "https://www.cloudflare.com",
"expectStatus": [200],
"interval": 60,
"timeout": 10
},
{
"id": "github-api",
"name": "GitHub API(限流目标示例)",
"group": "公共服务",
"type": "http",
"url": "https://api.github.com",
"expectStatus": [200],
"interval": 300,
"timeout": 10,
"probeOnRefresh": false
},
{
"id": "httpbin-500",
"name": "HTTPBin 500(演示 expectStatus)",
"group": "公共服务",
"type": "http",
"url": "https://httpbin.org/status/500",
"expectStatus": [500],
"interval": 120,
"timeout": 10
},
{
"id": "badssl-expired",
"name": "BadSSL 过期证书(演示离线)",
"group": "公共服务",
"type": "http",
"url": "https://expired.badssl.com/",
"expectStatus": [200],
"interval": 120,
"timeout": 10
},
{
"id": "cloudflare-dns-tcp",
"name": "Cloudflare 443 端口",
"group": "基础设施",
"type": "tcp",
"host": "1.1.1.1",
"port": 443,
"interval": 60,
"timeout": 10
},
{
"id": "google-dns-tcp",
"name": "Google DNS 53 端口",
"group": "基础设施",
"type": "tcp",
"host": "8.8.8.8",
"port": 53,
"interval": 60,
"timeout": 10
}
]
}
+1100
View File
File diff suppressed because it is too large Load Diff