硬要求(用户明确):不允许出现需要滚动才能看全的情况。
改动:
- 表格 table-layout: fixed + 百分比列宽(8/16/21/8/7/11/17/12%),
表头允许换行并裁剪、单元格裁剪、心率图与可用率条随宽度伸缩
- 卡片网格 minmax(min(300px,100%),1fr) + .card{min-width:0}
(修 grid 的 min-width:auto 撑破:320px 下曾溢出 495px)
- 截断的名称/目标补 title,悬停可看完整值
- 新增 scripts/verify-layout.cjs:自带极端配置(40 目标 / 6 分组 /
超长名称与 URL)+ 临时静态服务,扫描 13 视口 × 2 视图,26 项断言零横向滚动
验证:五套 115 项全通过(17+30+26+20+22)
749 lines
31 KiB
JavaScript
749 lines
31 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* shiroko-monitor 本地探针 + 面板服务器(零依赖,Node >= 18)
|
||
*
|
||
* 模式:
|
||
* 默认 常驻巡检:按各 monitor 的 interval 周期探测,结果写入 public/status.json,同时伺服面板
|
||
* --once 执行一轮探测后退出(供 GitHub Actions 等 CI 使用,历史心率从已有 status.json 恢复)
|
||
* --demo 生成一份演示状态数据后退出(用于静态部署的首版占位)
|
||
* --out=<path> 指定快照输出路径,默认 public/status.json(可避免演示数据覆盖真实快照)
|
||
* --config=<path> / --local-config=<path> 指定公开配置与私有覆盖的路径(多实例、测试用)
|
||
*
|
||
* 私有配置:若存在 local.monitors.json(已在 .gitignore 中),其 monitors 按 id 覆盖公开配置,
|
||
* 用于把真实地址/端口从会公开的 public/monitors.json 中拆出去。
|
||
*/
|
||
import http from 'node:http';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { probeHttp } from './lib/http-probe.js';
|
||
import { probeTcp } from './lib/tcp-probe.js';
|
||
import { probeBanner } from './lib/banner-probe.js';
|
||
import { fetchUptimeRobotMonitors } from './lib/uptimerobot.js';
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
const ROOT = path.resolve(__dirname, '../..');
|
||
const PUBLIC_DIR = path.join(ROOT, 'public');
|
||
const CONFIG_PATH = path.join(PUBLIC_DIR, 'monitors.json');
|
||
// 私有配置(不入库):用于把真实地址/端口从公开的 monitors.json 中拆出去
|
||
const LOCAL_CONFIG_PATH = path.join(ROOT, 'local.monitors.json');
|
||
const STATUS_PATH = path.join(PUBLIC_DIR, 'status.json');
|
||
|
||
const HISTORY_LIMIT = 48;
|
||
const UPTIME_DAYS = 90;
|
||
const CONCURRENCY = 8;
|
||
const WRITE_DEBOUNCE_MS = 300;
|
||
const DEFAULT_INTERVAL_SEC = 60;
|
||
const PORT = Number(process.env.PORT || 8787);
|
||
const VERSION = '0.9.1';
|
||
const UR_DEFAULT_POLL_SEC = 300;
|
||
const UR_MIN_REFRESH_GAP_MS = 60000;
|
||
|
||
const argv = process.argv.slice(2);
|
||
const ONCE = argv.includes('--once');
|
||
const DEMO = argv.includes('--demo');
|
||
|
||
// --out=<path> 或 --out <path>:指定快照输出路径(默认 public/status.json)
|
||
function argValue(name) {
|
||
const inline = argv.find((a) => a.startsWith(`${name}=`));
|
||
if (inline) return inline.slice(name.length + 1);
|
||
const idx = argv.indexOf(name);
|
||
return idx >= 0 && argv[idx + 1] && !argv[idx + 1].startsWith('--') ? argv[idx + 1] : null;
|
||
}
|
||
const OUT_PATH = path.resolve(argValue('--out') || STATUS_PATH);
|
||
// --config / --local-config:多实例或测试时指定配置文件(默认 public/monitors.json 与仓库根的私有覆盖)
|
||
const CONFIG_OVERRIDE = argValue('--config');
|
||
const LOCAL_CONFIG_OVERRIDE = argValue('--local-config');
|
||
const ACTIVE_CONFIG_PATH = CONFIG_OVERRIDE ? path.resolve(CONFIG_OVERRIDE) : CONFIG_PATH;
|
||
const ACTIVE_LOCAL_CONFIG_PATH = LOCAL_CONFIG_OVERRIDE ? path.resolve(LOCAL_CONFIG_OVERRIDE) : LOCAL_CONFIG_PATH;
|
||
|
||
const MIME = {
|
||
'.html': 'text/html; charset=utf-8',
|
||
'.css': 'text/css; charset=utf-8',
|
||
'.js': 'text/javascript; charset=utf-8',
|
||
'.mjs': 'text/javascript; charset=utf-8',
|
||
'.json': 'application/json; charset=utf-8',
|
||
'.svg': 'image/svg+xml',
|
||
'.png': 'image/png',
|
||
'.jpg': 'image/jpeg',
|
||
'.ico': 'image/x-icon',
|
||
'.woff2': 'font/woff2',
|
||
};
|
||
|
||
function loadConfig() {
|
||
const pub = JSON.parse(fs.readFileSync(ACTIVE_CONFIG_PATH, 'utf8'));
|
||
const local = loadLocalOverlay();
|
||
if (!local) return pub;
|
||
// 私有配置按 id 覆盖公开条目(前者可只写 url/host/port 等敏感字段),settings 同样按需覆盖
|
||
const byId = new Map((local.monitors ?? []).map((m) => [m.id, m]));
|
||
return {
|
||
...pub,
|
||
settings: { ...pub.settings, ...(local.settings ?? {}) },
|
||
monitors: (pub.monitors ?? []).map((m) => ({ ...m, ...(byId.get(m.id) ?? {}) })),
|
||
};
|
||
}
|
||
|
||
function loadLocalOverlay() {
|
||
try {
|
||
return JSON.parse(fs.readFileSync(ACTIVE_LOCAL_CONFIG_PATH, 'utf8'));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function loadPersistedStatus() {
|
||
try {
|
||
return JSON.parse(fs.readFileSync(STATUS_PATH, 'utf8'));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function dayKey(d = new Date()) {
|
||
const p = (n) => String(n).padStart(2, '0');
|
||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
||
}
|
||
|
||
// 只保留最近 UPTIME_DAYS 天;YYYY-MM-DD 的字典序即时间序
|
||
function pruneDaily(daily) {
|
||
const keys = Object.keys(daily).sort();
|
||
while (keys.length > UPTIME_DAYS) delete daily[keys.shift()];
|
||
}
|
||
|
||
function recordDaily(daily, ok) {
|
||
const key = dayKey();
|
||
const bucket = daily[key] ?? (daily[key] = { total: 0, up: 0 });
|
||
bucket.total++;
|
||
if (ok) bucket.up++;
|
||
pruneDaily(daily);
|
||
}
|
||
|
||
function intervalMs(monitor) {
|
||
const sec = Number(monitor.interval);
|
||
// 非法或缺失的 interval 一律回落到默认值,避免算出 NaN 让该目标永久不再被调度
|
||
return Math.max(5, Number.isFinite(sec) && sec > 0 ? sec : DEFAULT_INTERVAL_SEC) * 1000;
|
||
}
|
||
|
||
/* ---------- UptimeRobot 只读数据源(异地探测视角) ---------- */
|
||
|
||
// API Key 建议放 local.monitors.json(不入库)或环境变量 UPTIMEROBOT_API_KEY;两者都无则视为未启用
|
||
function uptimeRobotConfig(config) {
|
||
const ur = config.settings?.uptimeRobot ?? {};
|
||
const apiKey = String(ur.apiKey || process.env.UPTIMEROBOT_API_KEY || '').trim();
|
||
const ratios = Array.isArray(ur.ratios) && ur.ratios.length ? ur.ratios.map(Number).filter(Number.isFinite) : [1, 7, 30, 90];
|
||
return {
|
||
enabled: ur.enabled === true && Boolean(apiKey),
|
||
apiKey,
|
||
pollIntervalSec: Math.max(60, Number(ur.pollIntervalSec) || UR_DEFAULT_POLL_SEC),
|
||
group: ur.group || 'UptimeRobot',
|
||
includeTypes: Array.isArray(ur.includeTypes) && ur.includeTypes.length ? ur.includeTypes.map(Number) : null,
|
||
ratios: ratios.length ? ratios : [1, 7, 30, 90],
|
||
};
|
||
}
|
||
|
||
// UR 侧不探测,只做「拉取 + 映射」;失败时保留上一次数据,绝不因为读不到 UR 就把服务判为离线
|
||
async function syncUptimeRobot(runtime, { minGapMs = 0 } = {}) {
|
||
const ur = uptimeRobotConfig(runtime.config);
|
||
runtime.ur.enabled = ur.enabled;
|
||
if (!ur.enabled || runtime.ur.syncing) return;
|
||
// minGapMs 用于「面板手动刷新时顺带同步」的场景,避免被反复点击打爆 UR 接口
|
||
if (minGapMs && Date.now() - runtime.ur.lastSyncMs < minGapMs) return;
|
||
runtime.ur.lastSyncMs = Date.now();
|
||
runtime.ur.syncing = true;
|
||
try {
|
||
let list = await fetchUptimeRobotMonitors({ apiKey: ur.apiKey, ratios: ur.ratios });
|
||
if (ur.includeTypes) list = list.filter((m) => ur.includeTypes.includes(m.sourceType));
|
||
const seen = new Set();
|
||
for (const item of list) {
|
||
seen.add(item.id);
|
||
let s = runtime.state.get(item.id);
|
||
if (!s) {
|
||
s = { monitor: item, nextDue: 0, inFlight: false, lastResult: null, history: [], daily: {} };
|
||
runtime.state.set(item.id, s);
|
||
}
|
||
s.monitor = item;
|
||
s.lastResult = {
|
||
status: item.status,
|
||
latency: item.latency,
|
||
code: item.code,
|
||
detail: item.detail,
|
||
checkedAt: item.lastCheckIso,
|
||
};
|
||
s.history = item.history;
|
||
s.uptimeRatios = item.uptimeRatios;
|
||
s.downSeconds = item.downSeconds;
|
||
if (item.status !== 'pending') recordDaily(s.daily, item.status === 'up');
|
||
}
|
||
// 清理 UR 侧已删除的监控
|
||
for (const [id, s] of runtime.state) {
|
||
if (s.monitor?.source === 'uptimerobot' && !seen.has(id)) runtime.state.delete(id);
|
||
}
|
||
runtime.ur.ok = true;
|
||
runtime.ur.error = null;
|
||
runtime.ur.count = list.length;
|
||
runtime.ur.lastSyncAt = new Date().toISOString();
|
||
markDataUpdated(runtime);
|
||
console.log(`[uptimerobot] 同步完成:${list.length} 个监控(在线 ${list.filter((m) => m.status === 'up').length})`);
|
||
} catch (err) {
|
||
runtime.ur.ok = false;
|
||
runtime.ur.error = err.message;
|
||
console.error(`[uptimerobot] 同步失败(保留上次数据):${err.message}`);
|
||
} finally {
|
||
runtime.ur.syncing = false;
|
||
}
|
||
}
|
||
|
||
// 恢复 lastResult / history / daily:优先复用内存态(配置热重载),否则读持久化的 status.json(重启)
|
||
function hydrate(config, prevState = null) {
|
||
const persisted = prevState ? null : loadPersistedStatus();
|
||
const state = new Map();
|
||
for (const m of config.monitors) {
|
||
const own = prevState?.get(m.id);
|
||
const saved = own
|
||
? {
|
||
status: own.lastResult?.status,
|
||
latency: own.lastResult?.latency,
|
||
code: own.lastResult?.code,
|
||
detail: own.lastResult?.detail,
|
||
lastCheck: own.lastResult?.checkedAt,
|
||
history: own.history,
|
||
daily: own.daily,
|
||
}
|
||
: persisted?.monitors?.[m.id];
|
||
state.set(m.id, {
|
||
monitor: m,
|
||
nextDue: 0,
|
||
inFlight: false,
|
||
lastResult: saved?.status && saved.status !== 'pending'
|
||
? { status: saved.status, latency: saved.latency, code: saved.code, detail: saved.detail, checkedAt: saved.lastCheck }
|
||
: null,
|
||
history: Array.isArray(saved?.history) ? saved.history.slice(-HISTORY_LIMIT) : [],
|
||
daily: saved?.daily && typeof saved.daily === 'object' ? { ...saved.daily } : {},
|
||
// UR 侧的聚合可用率(用于在逐日分桶填满前也能显示官方数字)
|
||
uptimeRatios: own?.uptimeRatios ?? saved?.uptimeRatios ?? null,
|
||
downSeconds: own?.downSeconds ?? saved?.downSeconds ?? null,
|
||
});
|
||
}
|
||
// UptimeRobot 镜像目标不在 config 里,热重载时需整体保留,否则会丢掉已累积的逐日分桶
|
||
if (prevState) {
|
||
for (const [id, s] of prevState) {
|
||
if (s.monitor?.source === 'uptimerobot') state.set(id, s);
|
||
}
|
||
}
|
||
return state;
|
||
}
|
||
|
||
async function probeOne(monitor) {
|
||
let result;
|
||
if (monitor.type === 'tcp') result = await probeTcp(monitor);
|
||
else if (monitor.type === 'banner') result = await probeBanner(monitor);
|
||
else if (monitor.type === 'http') result = await probeHttp(monitor);
|
||
else return { status: 'down', latency: 0, code: null, detail: `不支持的探测类型:${monitor.type}`, checkedAt: new Date().toISOString() };
|
||
result.checkedAt = new Date().toISOString();
|
||
return result;
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
async function probeDue(runtime, force = false) {
|
||
const { config, state } = runtime;
|
||
const now = Date.now();
|
||
const due = [];
|
||
for (const m of config.monitors) {
|
||
const s = state.get(m.id);
|
||
if (!s || s.inFlight) continue;
|
||
// UptimeRobot 镜像目标由其自身节点探测,本探针只拉取结果
|
||
if (m.type === 'ur' || m.source === 'uptimerobot') continue;
|
||
// 目标可单独退出「刷新即检测」(如有限流的 API),只按自身 interval 探测
|
||
if (force && m.probeOnRefresh === false) continue;
|
||
if (!force && s.nextDue > now) continue;
|
||
// 先同步占位再探测:否则慢目标(如 10s 超时)尚未返回时会被下一秒的 tick 重复调度,形成探测风暴
|
||
s.inFlight = true;
|
||
s.nextDue = Date.now() + intervalMs(m);
|
||
due.push(m);
|
||
}
|
||
await mapLimit(due, CONCURRENCY, async (m) => {
|
||
const s = state.get(m.id);
|
||
const task = (async () => {
|
||
try {
|
||
const result = await probeOne(m);
|
||
s.lastResult = result;
|
||
s.history.push({ t: result.checkedAt, ok: result.status === 'up', ms: result.latency ?? 0 });
|
||
if (s.history.length > HISTORY_LIMIT) s.history.shift();
|
||
recordDaily(s.daily, result.status === 'up');
|
||
const tag = result.status === 'up' ? '[up] ' : '[down]';
|
||
console.log(`${tag} ${m.id.padEnd(20)} ${String(result.latency ?? '-').padStart(5)}ms ${result.detail}`);
|
||
} catch (err) {
|
||
console.error(`[probe] ${m.id} 探测异常:${err.message}`);
|
||
} finally {
|
||
s.inFlight = false;
|
||
s.inFlightPromise = null;
|
||
}
|
||
})();
|
||
s.inFlightPromise = task;
|
||
await task;
|
||
});
|
||
if (due.length) markDataUpdated(runtime);
|
||
}
|
||
|
||
// 等待在途探测收尾(强制刷新前调用,避免"正在探测中"的目标被跳过而显示旧数据)
|
||
async function settleInflight(state) {
|
||
const pending = [...state.values()].map((s) => s.inFlightPromise).filter(Boolean);
|
||
if (pending.length) await Promise.allSettled(pending);
|
||
}
|
||
|
||
// 探针自报内存占用(MB):便于观察这个「监控器」自身的资源开销
|
||
function memoryStats() {
|
||
const m = process.memoryUsage();
|
||
const mb = (v) => Math.round((v / 1048576) * 10) / 10;
|
||
return { rssMB: mb(m.rss), heapUsedMB: mb(m.heapUsed), heapTotalMB: mb(m.heapTotal) };
|
||
}
|
||
|
||
/* ---------- 数据源过滤 ---------- */
|
||
|
||
// 只影响「展示与快照输出」,不影响探测:配置文件里的目标始终按各自 interval 探测
|
||
function dataFilter(config) {
|
||
const f = config.settings?.dataFilter;
|
||
return ['both', 'config', 'uptimerobot'].includes(f) ? f : 'both';
|
||
}
|
||
|
||
/* ---------- 服务端更新节奏 ---------- */
|
||
|
||
// 快照里回报「数据最后变更时刻」与「服务端产出新数据的节奏」,供面板把倒计时对齐到服务端,
|
||
// 而不是从用户打开页面那一刻重新计时(否则每次进来都要重等一整个周期,最坏会看到近两倍周期的旧数据)
|
||
function markDataUpdated(runtime) {
|
||
runtime.dataUpdatedAt = Date.now();
|
||
}
|
||
|
||
// 当前展示集合下,服务端多久产出一次新数据:取各目标 interval 与 UR 拉取间隔的最小值
|
||
function updateIntervalSec(config, filter) {
|
||
const intervals = [];
|
||
if (filter !== 'uptimerobot') {
|
||
for (const m of config.monitors) {
|
||
const n = Number(m.interval);
|
||
if (Number.isFinite(n) && n > 0) intervals.push(n);
|
||
}
|
||
}
|
||
if (filter !== 'config') {
|
||
const ur = uptimeRobotConfig(config);
|
||
if (ur.enabled) intervals.push(ur.pollIntervalSec);
|
||
}
|
||
return Math.max(5, Math.round(intervals.length ? Math.min(...intervals) : DEFAULT_INTERVAL_SEC));
|
||
}
|
||
|
||
function snapshot(runtime, demo = false) {
|
||
const { config, state } = runtime;
|
||
// redactDetails:公开部署时可只输出「正常/异常」,避免 detail 中的版本、地址等细节被公开读取
|
||
const redact = config.settings?.redactDetails === true;
|
||
const filter = dataFilter(config);
|
||
const hideUrls = config.settings?.hideTargets === true;
|
||
const includeConfig = filter !== 'uptimerobot';
|
||
const includeExternal = filter !== 'config';
|
||
// 混合来源时平均延迟只统计自建探测(UR 的延迟来自其探测节点,与本机视角不可比);
|
||
// 若展示集合只有外部数据源,则 UR 延迟就是唯一口径,照常统计
|
||
const latencyEligible = (isUr) => !isUr || filter === 'uptimerobot';
|
||
const monitors = {};
|
||
let up = 0;
|
||
let down = 0;
|
||
let pending = 0;
|
||
let latencySum = 0;
|
||
let latencyCount = 0;
|
||
let uptimeUp = 0;
|
||
let uptimeTotal = 0;
|
||
|
||
// 配置里的探测目标 + UptimeRobot 镜像目标(后者只存在于 state 中,由同步产生)
|
||
const entries = [];
|
||
if (includeConfig) for (const m of config.monitors) entries.push([m.id, m, state.get(m.id)]);
|
||
if (includeExternal) {
|
||
for (const [id, s] of state) {
|
||
if (s.monitor?.source === 'uptimerobot') entries.push([id, s.monitor, s]);
|
||
}
|
||
}
|
||
|
||
for (const [id, m, s] of entries) {
|
||
const r = s?.lastResult;
|
||
const isUr = m.source === 'uptimerobot';
|
||
if (!r) {
|
||
pending++;
|
||
monitors[id] = { status: 'pending', latency: null, code: null, detail: '等待检测', lastCheck: null, history: [], daily: s?.daily ?? {} };
|
||
continue;
|
||
}
|
||
if (r.status === 'up') {
|
||
up++;
|
||
if (latencyEligible(isUr) && r.latency != null) {
|
||
latencySum += r.latency;
|
||
latencyCount++;
|
||
}
|
||
} else if (r.status === 'down') {
|
||
down++;
|
||
} else {
|
||
pending++;
|
||
}
|
||
for (const bucket of Object.values(s.daily ?? {})) {
|
||
uptimeTotal += bucket.total ?? 0;
|
||
uptimeUp += bucket.up ?? 0;
|
||
}
|
||
const entry = {
|
||
status: r.status,
|
||
latency: r.latency,
|
||
code: r.code,
|
||
detail: redact ? (r.status === 'up' ? '正常' : r.status === 'down' ? '异常' : '等待检测') : r.detail,
|
||
lastCheck: r.checkedAt,
|
||
history: s.history,
|
||
daily: s.daily,
|
||
};
|
||
if (isUr) {
|
||
// UR 镜像目标不在 config 中,需要把展示元信息随快照下发,面板据此合并渲染;
|
||
// hideTargets 开启时不下发地址:面板反正不渲染,公开的 status.json 里也就不该有
|
||
entry.meta = {
|
||
name: m.name,
|
||
group: m.group,
|
||
type: 'ur',
|
||
url: hideUrls ? '' : m.url,
|
||
source: 'uptimerobot',
|
||
sourceLabel: m.sourceLabel,
|
||
interval: m.interval,
|
||
};
|
||
entry.uptimeRatios = s.uptimeRatios ?? null;
|
||
entry.downSeconds = s.downSeconds ?? null;
|
||
}
|
||
monitors[id] = entry;
|
||
}
|
||
|
||
return {
|
||
prober: `shiroko-monitor-node/${VERSION}`,
|
||
demo,
|
||
generatedAt: new Date().toISOString(),
|
||
// 数据最后变更时刻 + 服务端更新节奏:面板据此把刷新对齐到服务端,而不是每访客重新计时
|
||
dataUpdatedAt: new Date(runtime.dataUpdatedAt ?? Date.now()).toISOString(),
|
||
updateIntervalSec: updateIntervalSec(config, filter),
|
||
// 让面板与服务端共用同一份过滤口径,避免私有配置覆盖后两边不一致
|
||
dataFilter: filter,
|
||
stats: {
|
||
total: entries.length,
|
||
up,
|
||
down,
|
||
pending,
|
||
avgLatency: latencyCount ? Math.round(latencySum / latencyCount) : null,
|
||
avgUptime90: uptimeTotal ? Math.round((uptimeUp / uptimeTotal) * 1000) / 10 : null,
|
||
memory: memoryStats(),
|
||
},
|
||
sources: { uptimerobot: { ...(runtime.ur ?? {}) } },
|
||
monitors,
|
||
};
|
||
}
|
||
|
||
function writeStatus(data) {
|
||
const payload = `${JSON.stringify(data, null, 2)}\n`;
|
||
const tmp = `${OUT_PATH}.tmp`;
|
||
try {
|
||
fs.writeFileSync(tmp, payload);
|
||
// 原子替换:避免面板读到写了一半的 JSON
|
||
fs.renameSync(tmp, OUT_PATH);
|
||
} catch {
|
||
// Windows 下 rename 可能因目标文件被短暂占用而失败(EPERM,常见于杀软扫描),
|
||
// 退化为直接写入;监控探针绝不能因为一次写文件抖动而整个进程退出
|
||
try {
|
||
fs.writeFileSync(OUT_PATH, payload);
|
||
fs.rmSync(tmp, { force: true });
|
||
} catch (err) {
|
||
console.error(`[write] status.json 写入失败:${err.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function scheduleWrite(runtime) {
|
||
if (scheduleWrite.timer) return;
|
||
scheduleWrite.timer = setTimeout(() => {
|
||
scheduleWrite.timer = null;
|
||
writeStatus(snapshot(runtime));
|
||
}, WRITE_DEBOUNCE_MS);
|
||
}
|
||
|
||
// 刷新即重新检测:多客户端/多标签页同时刷新时共享同一轮探测,避免对目标重复施压;
|
||
// 间隔过近的重复请求直接复用当前快照,只有真正需要时才新起一轮
|
||
const FORCED_MIN_GAP_MS = 2000;
|
||
// 兜底上限:正常情况下轮次在目标超时内完成;若有 socket 卡死,也不让请求无限挂起
|
||
const FORCED_MAX_WAIT_MS = 22000;
|
||
|
||
function forceRound(runtime) {
|
||
if (runtime.forced) return runtime.forced;
|
||
if (runtime.forcedAt && Date.now() - runtime.forcedAt < FORCED_MIN_GAP_MS) return Promise.resolve();
|
||
runtime.forced = (async () => {
|
||
try {
|
||
// 先等在途探测收尾再统一探测,保证返回时每个目标都是本轮的新结果
|
||
await settleInflight(runtime.state);
|
||
await probeDue(runtime, true);
|
||
await settleInflight(runtime.state);
|
||
runtime.forcedAt = Date.now();
|
||
scheduleWrite(runtime);
|
||
} catch (err) {
|
||
console.error(`[refresh] 强制探测异常:${err.message}`);
|
||
} finally {
|
||
runtime.forced = null;
|
||
}
|
||
})();
|
||
return runtime.forced;
|
||
}
|
||
|
||
function writeDemo(config) {
|
||
if (OUT_PATH === STATUS_PATH && fs.existsSync(STATUS_PATH) && !loadPersistedStatus()?.demo) {
|
||
console.warn('[demo] 目标已是真实快照,将被演示数据覆盖(如不希望覆盖,请用 --out=<path> 指定其他输出路径)');
|
||
}
|
||
const state = hydrate(config);
|
||
for (const m of config.monitors) {
|
||
const s = state.get(m.id);
|
||
// 过期证书目标在演示数据中固定为离线,其余按 id 哈希产生稳定的伪随机结果
|
||
const seed = [...m.id].reduce((a, c) => a + c.charCodeAt(0), 0);
|
||
const alwaysDown = m.id.includes('badssl');
|
||
const history = Array.from({ length: HISTORY_LIMIT }, (_, i) => {
|
||
const ok = alwaysDown ? false : (seed + i * 7) % 23 !== 0;
|
||
return { t: new Date(Date.now() - (HISTORY_LIMIT - i) * (m.interval ?? 60) * 1000).toISOString(), ok, ms: ok ? 30 + ((seed * (i + 3)) % 240) : 0 };
|
||
});
|
||
const lastOk = history[history.length - 1].ok;
|
||
s.lastResult = {
|
||
status: lastOk ? 'up' : 'down',
|
||
latency: lastOk ? history[history.length - 1].ms : 0,
|
||
code: m.type === 'tcp' ? null : lastOk ? 200 : 0,
|
||
detail: m.type === 'tcp'
|
||
? (lastOk ? '端口可连接' : '连接被拒绝(端口关闭)')
|
||
: m.type === 'banner'
|
||
? (lastOk ? `${m.protocol ?? 'BANNER'} 响应正常` : '标识串与期望规则不符')
|
||
: lastOk ? 'HTTP 200' : 'CERT_HAS_EXPIRED',
|
||
checkedAt: history[history.length - 1].t,
|
||
};
|
||
s.history = history;
|
||
|
||
// 90 天可用率演示数据:多数日期满分,少量日期按档位抖落,便于观察分级配色
|
||
const perDay = Math.max(1, Math.round(86400 / (Number(m.interval) > 0 ? Number(m.interval) : DEFAULT_INTERVAL_SEC)));
|
||
const daily = {};
|
||
for (let d = UPTIME_DAYS - 1; d >= 0; d--) {
|
||
const key = dayKey(new Date(Date.now() - d * 86400000));
|
||
const missRatio = alwaysDown ? 1 : (seed + d * 13) % 23 === 0 ? 0.05 : (seed + d * 7) % 11 === 0 ? 0.012 : 0;
|
||
const miss = Math.round(perDay * missRatio);
|
||
daily[key] = { total: perDay, up: perDay - miss };
|
||
}
|
||
s.daily = daily;
|
||
}
|
||
writeStatus(snapshot({ config, state }, true));
|
||
console.log(`[demo] 演示状态已写入 ${path.relative(ROOT, OUT_PATH) || OUT_PATH}`);
|
||
}
|
||
|
||
function sendJson(res, data, { status = 200, headers = {} } = {}) {
|
||
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers });
|
||
res.end(JSON.stringify(data));
|
||
}
|
||
|
||
/* ---------- /api/refresh 滥用防护 ---------- */
|
||
|
||
// 仅信任 socket 的对端地址,不解析 X-Forwarded-For(可被伪造);若前方挂了反向代理,请改用 allowRefresh:false
|
||
export function isPrivateAddress(addr) {
|
||
const ip = String(addr || '').replace(/^::ffff:/i, '');
|
||
if (ip === '::1' || ip.startsWith('127.')) return true;
|
||
if (ip.startsWith('10.') || ip.startsWith('192.168.')) return true;
|
||
if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
|
||
if (/^f[cd][0-9a-f]{2}:/i.test(ip) || /^fe80:/i.test(ip)) return true;
|
||
return false;
|
||
}
|
||
|
||
const refreshHits = new Map();
|
||
const DEFAULT_COOLDOWN_SEC = 5;
|
||
|
||
function refreshDecision(config, ip) {
|
||
const s = config.settings ?? {};
|
||
if (s.allowRefresh === false) return { ok: false, status: 403, error: 'refresh-disabled' };
|
||
if (s.refreshLocalOnly !== false && !isPrivateAddress(ip)) return { ok: false, status: 403, error: 'refresh-local-only' };
|
||
const cooldown = Math.max(1, Number(s.refreshCooldownSec) || DEFAULT_COOLDOWN_SEC) * 1000;
|
||
const wait = (refreshHits.get(ip) ?? 0) + cooldown - Date.now();
|
||
if (wait > 0) return { ok: false, status: 429, error: 'refresh-throttled', retryAfter: Math.ceil(wait / 1000) };
|
||
refreshHits.set(ip, Date.now());
|
||
// 简单清理,避免长期运行下 Map 无界增长
|
||
const expire = Date.now() - 10 * 60 * 1000;
|
||
for (const [k, ts] of refreshHits) if (ts < expire) refreshHits.delete(k);
|
||
return { ok: true };
|
||
}
|
||
|
||
/* ---------- UptimeRobot 兼容输出(只读) ---------- */
|
||
|
||
// 把内部快照映射成 getMonitors 的响应形状,便于既有 UptimeRobot 生态的脚本/看板直接复用本服务数据
|
||
function toUptimeRobotResponse(runtime) {
|
||
const snap = snapshot(runtime);
|
||
const cfgById = new Map(runtime.config.monitors.map((m) => [m.id, m]));
|
||
// 字符串 id → 稳定数字 id(UptimeRobot 侧 id 为数字)
|
||
const numericId = (id) => {
|
||
let h = 0;
|
||
for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) % 1e9;
|
||
return h;
|
||
};
|
||
const ratio = (st, days) => {
|
||
const buckets = Object.entries(st.daily ?? {}).sort().slice(-days);
|
||
const total = buckets.reduce((a, [, b]) => a + (b.total ?? 0), 0);
|
||
const upCount = buckets.reduce((a, [, b]) => a + (b.up ?? 0), 0);
|
||
if (total) return (upCount / total) * 100;
|
||
// 无逐日数据时回退到数据源给出的聚合比率(UptimeRobot 镜像目标自带)
|
||
const provided = st.uptimeRatios?.[days];
|
||
return Number.isFinite(provided) ? provided : 0;
|
||
};
|
||
const monitors = Object.entries(snap.monitors).map(([id, st]) => {
|
||
const cfg = cfgById.get(id);
|
||
const isUr = (st.meta?.source ?? cfg?.source) === 'uptimerobot';
|
||
const name = st.meta?.name ?? cfg?.name ?? id;
|
||
const url = cfg ? (cfg.type === 'http' ? cfg.url : `${cfg.host}:${cfg.port}`) : st.meta?.url ?? '';
|
||
const portLike = isUr ? st.meta?.sourceLabel === 'PORT' : cfg?.type !== 'http';
|
||
return {
|
||
id: numericId(id),
|
||
friendly_name: name,
|
||
url,
|
||
type: portLike ? 4 : 1,
|
||
status: st.status === 'up' ? 2 : st.status === 'down' ? 9 : 1,
|
||
interval: cfg?.interval ?? st.meta?.interval ?? DEFAULT_INTERVAL_SEC,
|
||
custom_uptime_ratio: [1, 7, 30, 90].map((d) => ratio(st, d).toFixed(3)).join('-'),
|
||
average_response_time: st.latency ?? 0,
|
||
response_times: (st.history ?? []).slice(-HISTORY_LIMIT).map((h) => ({ datetime: Math.floor(new Date(h.t).getTime() / 1000), value: h.ms })),
|
||
};
|
||
});
|
||
return { stat: 'ok', pagination: { offset: 0, limit: 50, total: monitors.length }, monitors };
|
||
}
|
||
|
||
// runtime 是持有器对象:热重载只更新其属性,服务器与巡检循环始终读到最新引用
|
||
function createServer(runtime) {
|
||
return http.createServer(async (req, res) => {
|
||
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||
const { config, state } = runtime;
|
||
try {
|
||
if (url.pathname === '/api/status') return sendJson(res, snapshot(runtime));
|
||
if (url.pathname === '/api/monitors') return sendJson(res, config);
|
||
if (url.pathname === '/api/refresh') {
|
||
const decide = refreshDecision(config, req.socket.remoteAddress);
|
||
if (!decide.ok) {
|
||
// 只回错误码,不回显目标地址与内部细节
|
||
return sendJson(res, { error: decide.error }, {
|
||
status: decide.status,
|
||
headers: decide.retryAfter ? { 'retry-after': String(decide.retryAfter) } : {},
|
||
});
|
||
}
|
||
// scope=external:只同步 UptimeRobot,不跑本地探测轮次(面板在「只看 UR」模式下用)
|
||
const scope = url.searchParams.get('scope');
|
||
if (scope !== 'external') {
|
||
// 等本轮探测完成再返回快照,避免面板刷新后读到的仍是旧数据;上限兜底防止个别目标卡死请求
|
||
await Promise.race([forceRound(runtime), new Promise((resolve) => setTimeout(resolve, FORCED_MAX_WAIT_MS))]);
|
||
}
|
||
// 顺带同步 UptimeRobot(至少间隔 60s,尊重其接口限流),让「只看 UR」模式下的刷新同样有意义;
|
||
// 再等 6s 上限,避免 UR 接口慢把整体刷新拖过面板超时
|
||
await Promise.race([
|
||
syncUptimeRobot(runtime, { minGapMs: UR_MIN_REFRESH_GAP_MS }),
|
||
new Promise((resolve) => setTimeout(resolve, 6000)),
|
||
]);
|
||
// 浏览器中断请求(切页/关标签)是常态:等待期间连接可能已断开,此时不能再写响应
|
||
if (res.writableEnded || res.destroyed) return;
|
||
return sendJson(res, snapshot(runtime));
|
||
}
|
||
|
||
// UptimeRobot 兼容的只读接口(GET/POST 均可):已有脚本/看板可直接消费本服务数据。
|
||
// 不做鉴权,因为其暴露内容与公开面板一致;如需鉴权请在其前方加代理。
|
||
if (url.pathname === '/api/v2/getMonitors' || url.pathname === '/v2/getMonitors') {
|
||
return sendJson(res, toUptimeRobotResponse(runtime));
|
||
}
|
||
|
||
let rel = decodeURIComponent(url.pathname);
|
||
if (rel === '/') rel = '/index.html';
|
||
const file = path.join(PUBLIC_DIR, path.normalize(rel));
|
||
if (!file.startsWith(PUBLIC_DIR)) {
|
||
res.writeHead(403);
|
||
return res.end('Forbidden');
|
||
}
|
||
if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) {
|
||
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
||
return res.end('Not Found');
|
||
}
|
||
const ext = path.extname(file).toLowerCase();
|
||
const live = rel === '/status.json' || rel === '/monitors.json';
|
||
res.writeHead(200, {
|
||
'content-type': MIME[ext] || 'application/octet-stream',
|
||
'cache-control': live ? 'no-store' : 'public, max-age=300',
|
||
});
|
||
fs.createReadStream(file).pipe(res);
|
||
} catch (err) {
|
||
try {
|
||
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
||
res.end('Internal Error');
|
||
} catch {
|
||
// 连接已被对端关闭,忽略写入失败(避免未捕获异常终止探针进程)
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
async function main() {
|
||
const runtime = {
|
||
config: loadConfig(),
|
||
state: null,
|
||
dataUpdatedAt: Date.now(),
|
||
ur: { enabled: false, ok: null, error: null, lastSyncAt: null, count: 0, syncing: false, lastSyncMs: 0 },
|
||
};
|
||
if (DEMO) {
|
||
writeDemo(runtime.config);
|
||
return;
|
||
}
|
||
runtime.state = hydrate(runtime.config);
|
||
// 启用 UptimeRobot 时先同步一次,保证首次渲染就有异地探测数据
|
||
await syncUptimeRobot(runtime);
|
||
|
||
if (ONCE) {
|
||
await probeDue(runtime, true);
|
||
writeStatus(snapshot(runtime));
|
||
const s = snapshot(runtime).stats;
|
||
console.log(`[once] 完成:共 ${s.total},在线 ${s.up},离线 ${s.down}`);
|
||
return;
|
||
}
|
||
|
||
let reloadTimer = null;
|
||
fs.watch(ACTIVE_CONFIG_PATH, () => {
|
||
clearTimeout(reloadTimer);
|
||
reloadTimer = setTimeout(() => {
|
||
try {
|
||
const next = loadConfig();
|
||
// 复用当前内存态恢复 history/daily;仅替换持有器属性,不再产生游离的旧引用
|
||
runtime.state = hydrate(next, runtime.state);
|
||
runtime.config = next;
|
||
console.log('[config] monitors.json 已热重载');
|
||
scheduleWrite(runtime);
|
||
} catch (err) {
|
||
console.error(`[config] 重载失败:${err.message}`);
|
||
}
|
||
}, 200);
|
||
});
|
||
|
||
setInterval(() => {
|
||
probeDue(runtime)
|
||
.then(() => scheduleWrite(runtime))
|
||
.catch((err) => console.error(`[tick] ${err.stack || err}`));
|
||
// UptimeRobot 按自身 pollIntervalSec 拉取,失败不影响本地巡检
|
||
if (Date.now() - runtime.ur.lastSyncMs >= uptimeRobotConfig(runtime.config).pollIntervalSec * 1000) {
|
||
syncUptimeRobot(runtime)
|
||
.then(() => scheduleWrite(runtime))
|
||
.catch(() => { /* syncUptimeRobot 内部已记录错误 */ });
|
||
}
|
||
}, 1000);
|
||
|
||
createServer(runtime).listen(PORT, () => {
|
||
console.log(`shiroko-monitor v${VERSION}`);
|
||
console.log(`面板: http://localhost:${PORT}/`);
|
||
console.log(`状态 API: http://localhost:${PORT}/api/status`);
|
||
console.log(`兼容接口: http://localhost:${PORT}/api/v2/getMonitors (UptimeRobot getMonitors 只读兼容)`);
|
||
console.log(`监控目标: ${runtime.config.monitors.length} 个自建探测${runtime.ur.enabled ? ` + ${runtime.ur.count} 个 UptimeRobot 监控` : ''}(配置热重载已启用)`);
|
||
});
|
||
}
|
||
|
||
// 直接执行(node index.js)时启动;被测试脚本 import 时不自动启动
|
||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
|