Files
shiroko-monitor/edge/cloudflare/uptimerobot.js
T
tamakyi d2aa0a1163 feat: v0.12.1 —— Worker 支持 UptimeRobot + 管理端远程访问 + 前后端分离
起因:核实「管理端在 Cloudflare 上是否有效」时发现一个更要紧的问题 ——
Worker 完全没有 UptimeRobot 同步能力(runChecks 的探测目标只有 config.monitors)。
而实际配置是 dataFilter=uptimerobot,也就是说直接部署到 Worker 面板会是空的:
它探测 8 个本地条目,而真正在监控的 6 个 UR 目标它根本不认识。

① Worker 支持 UptimeRobot(Step 1)
- edge/cloudflare/uptimerobot.js(新增):Workers 版 UR 只读数据源,
  与 prober/node/lib/uptimerobot.js 同一套语义(状态映射/ratios 解析/故障原因)
- runChecks 的探测集合扩展为「自建目标 + UR 镜像目标」;UR 目标只拉取不探测,
  采用其状态并补齐本地 daily 分桶
- probeScope 与 dataFilter 解耦(与本地探针同语义):config 范围不拉 UR,
  uptimerobot 范围不探测本地
- 同步失败时标记 sources.uptimerobot.ok=false 并保留上次数据,
  绝不把目标判为离线或从面板消失;UR 侧删除的监控自动清理
- 快照新增 probeScope;UR 目标带 meta(面板区分自建/外部)与 uptimeRatios

② 管理端远程访问(Step 2)
- settings.admin.allowRemote:探针默认只监听 127.0.0.1,显式打开才监听全网卡,
  启动日志就此告警。这是 Cloudflare Tunnel 的安全前提(已实测两种行为)
- edge/cloudflare/tunnel.yml(新增):命名隧道配置,只放行
  ^/(admin|api/admin/.*|assets/.*)$,其余 404 —— 不把公开面板一起挂到公网

③ 前后端分离(Step 3)
- Worker 侧 /admin 与 /api/admin/* 显式返回 403 + 指向文档的提示,
  而非默默落到静态资源变成 404,确保边缘侧没有任何可写面

测试:新增 scripts/verify-worker.cjs(36 项)—— 把 Worker 以 data: URL 动态载入以
绕开 cloudflare:sockets,注入假 KV/静态资源/UptimeRobot 服务器,
不需要 wrangler 也不需要联网。全项目 8 套 310 项全部通过。

过程中抓到两个真 bug:
1. UR 同步失败的降级路径引用了不含 meta 的旧对象,导致 name/url 为 undefined、
   UR 目标从面板上消失
2. stats.total 按 config.monitors.length 计数,在 probeScope=uptimerobot 时
   与实际参与的目标数不符

另有一条测试夹具教训:内存版 KV 必须实现 get(key, 'json') 的类型参数
(真实 Workers KV 会自动反序列化),否则 state.monitors 恒为 undefined。
2026-10-14 14:51:28 +08:00

128 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* UptimeRobot 只读数据源(API v2)—— Cloudflare Workers 版
*
* 与 prober/node/lib/uptimerobot.js 共用同一份语义(术语对齐见该文件顶部注释),
* 但按 Workers 环境改了两处:
* 1. 没有 node:crypto / node:fs,API Key 从 env(wrangler secret)读取
* 2. 限流改为「按 KV 里记录的上次同步时间判断」,KV 最终一致对分钟级同步无影响
*
* 只读:仅调用 getMonitors,不做任何写操作,因此用 ur* 开头的只读 Key 即可。
*/
const API_URL = 'https://api.uptimerobot.com/v2/getMonitors';
const TYPE_LABEL = { 1: 'HTTP', 2: 'KEYWORD', 3: 'PING', 4: 'PORT' };
// 暂停/未检测都归入 pending:否则会被误报成「离线」
const STATUS_MAP = { 0: 'pending', 1: 'pending', 2: 'up', 8: 'down', 9: 'down' };
function splitNums(value) {
return String(value ?? '').split('-').map(Number).filter(Number.isFinite);
}
function ratiosToObject(value, ratios) {
const nums = splitNums(value);
const out = {};
ratios.forEach((r, i) => { if (Number.isFinite(nums[i])) out[r] = nums[i]; });
return out;
}
function targetOf(m) {
if (m.type === 4 && m.port) return `${m.url ? `${m.url}:` : ''}${m.port}`;
return m.url || '';
}
function latestDownReason(logs) {
const down = (logs ?? []).find((l) => l.type === 1);
const reason = down?.reason;
if (!reason) return null;
const code = String(reason.code ?? '').trim();
const detail = String(reason.detail ?? '').trim();
return { code: code || null, text: [code, detail].filter(Boolean).join(' ') || null };
}
/** 把 UptimeRobot 的 getMonitors 响应映射成内部结构(纯函数,可离线单测) */
export function mapUptimeRobotMonitors(payload, options = {}) {
const group = options.group || 'UptimeRobot';
const ratios = options.ratios ?? [1, 7, 30, 90];
const now = options.now ?? Date.now();
const historyLimit = options.historyLimit ?? 48;
const monitors = [];
for (const m of payload?.monitors ?? []) {
const status = STATUS_MAP[m.status] ?? 'pending';
const samples = Array.isArray(m.response_times) ? m.response_times : [];
const logs = Array.isArray(m.logs) ? m.logs : [];
// UR 侧的真实检测时刻:正常取最新响应样本,故障取最近一次故障日志
const lastCheckSec = status === 'down' ? logs.find((l) => l.type === 1)?.datetime : samples[0]?.datetime;
const lastCheckIso = new Date((Number(lastCheckSec) || Math.floor(now / 1000)) * 1000).toISOString();
const history = samples
.slice(0, historyLimit)
.map((s) => ({ t: new Date(s.datetime * 1000).toISOString(), ok: Number(s.value) > 0, ms: Math.max(0, Number(s.value) || 0) }))
.reverse();
if (status === 'down') history.push({ t: lastCheckIso, ok: false, ms: 0 });
const last = samples[0]?.value;
const avg = m.average_response_time;
const latency = Number.isFinite(Number(last)) && Number(last) > 0
? Math.round(Number(last))
: Number.isFinite(Number(avg)) ? Math.round(Number(avg)) : null;
const reason = latestDownReason(logs);
let detail;
if (status === 'up') detail = 'UptimeRobot:运行正常';
else if (status === 'down') detail = `UptimeRobot:${reason?.text ?? '连接失败'}`;
else detail = m.status === 0 ? 'UptimeRobot:监控已暂停' : 'UptimeRobot:尚未检测';
monitors.push({
id: `ur-${m.id}`,
name: m.friendly_name || `UptimeRobot #${m.id}`,
group,
type: 'ur',
url: targetOf(m),
source: 'uptimerobot',
sourceId: m.id,
sourceType: m.type,
sourceLabel: TYPE_LABEL[m.type] ?? `TYPE${m.type}`,
interval: Number(m.interval) || 300,
status,
latency,
code: status === 'down' && reason?.code ? Number(reason.code) || null : null,
detail,
lastCheckIso,
history,
uptimeRatios: ratiosToObject(m.custom_uptime_ratio, ratios),
downSeconds: ratiosToObject(m.custom_down_durations, ratios),
});
}
return monitors;
}
/**
* 拉取 UptimeRobot 监控列表。
* @param {string} apiKey
* @param {{ratios?: number[], timeoutMs?: number}} [options]
*/
export async function fetchUptimeRobotMonitors({ apiKey, ratios = [1, 7, 30, 90], timeoutMs = 15000 }) {
if (!apiKey) throw new Error('缺少 UptimeRobot API Key');
const body = new URLSearchParams({
api_key: apiKey,
format: 'json',
response_times: '1',
response_times_average: '30',
custom_uptime_ratios: ratios.join('-'),
logs: '1',
log_types: '1-2',
// 注意:custom_down_durations 不能作为请求参数传入(v2 会返回 invalid_parameter)
});
const res = await fetch(API_URL, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
body,
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) throw new Error(`UptimeRobot HTTP ${res.status}`);
const payload = await res.json();
if (payload.stat !== 'ok') throw new Error(`UptimeRobot 返回错误:${payload.error?.message || payload.error?.type || 'unknown'}`);
return mapUptimeRobotMonitors(payload, { ratios });
}