chore: v0.6.0 baseline — 主题美化前存档
已实现:HTTP/TCP/Banner 探测、90 天可用率、表格/卡片视图、 数据源过滤、UptimeRobot 双向兼容、服务端驱动的刷新节奏、 刷新接口滥用防护与信息泄露控制。
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* UptimeRobot 只读数据源(API v2)
|
||||
*
|
||||
* 作用:把 UptimeRobot 已有的监控结果接入本面板,作为「异地探测视角」与自建探针并列展示。
|
||||
* 只读:仅调用 getMonitors,不做任何写操作(暂停/编辑/删除),因此用 ur* 开头的只读 Key 即可。
|
||||
*
|
||||
* 术语对齐(UptimeRobot v2):
|
||||
* monitor.type 1=HTTP(s) 2=Keyword 3=Ping 4=Port
|
||||
* monitor.status 0=暂停 1=未检测 2=正常 8/9=异常
|
||||
* logs[].type 1=故障 2=恢复 98=开始监控 99=暂停
|
||||
* logs[].reason { code: "403", detail: "Forbidden" }
|
||||
* custom_uptime_ratio / custom_down_durations 按请求顺序以 '-' 分隔
|
||||
*/
|
||||
|
||||
const API_URL = 'https://api.uptimerobot.com/v2/getMonitors';
|
||||
const HISTORY_LIMIT = 48;
|
||||
|
||||
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((v) => Number(v))
|
||||
.filter((v) => Number.isFinite(v));
|
||||
}
|
||||
|
||||
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 响应映射成本探针的内部结构(可离线单测)
|
||||
* @param {object} payload getMonitors 原始响应
|
||||
* @param {{group?: string, ratios?: number[], now?: number}} [options]
|
||||
*/
|
||||
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 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, HISTORY_LIMIT)
|
||||
.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),
|
||||
// 与 UR 侧一致:编号、协议、检测间隔
|
||||
source: 'uptimerobot',
|
||||
sourceId: m.id,
|
||||
sourceType: m.type,
|
||||
sourceLabel: TYPE_LABEL[m.type] ?? `TYPE${m.type}`,
|
||||
interval: Number(m.interval) || 300,
|
||||
status,
|
||||
latency,
|
||||
// UR 不直接给「当前 HTTP 状态码」,故障时取最近一次故障日志里的 code
|
||||
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),
|
||||
createDatetime: Number(m.create_datetime) || null,
|
||||
});
|
||||
}
|
||||
return monitors;
|
||||
}
|
||||
|
||||
export async function fetchUptimeRobotMonitors({ apiKey, timeoutMs = 15000, ratios = [1, 7, 30, 90] }) {
|
||||
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),
|
||||
// 但响应中默认会带该字段,故 downSeconds 仍可解析
|
||||
});
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user