起因:核实「管理端在 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。
128 lines
5.1 KiB
JavaScript
128 lines
5.1 KiB
JavaScript
/**
|
||
* 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 });
|
||
} |