硬要求(用户明确):不允许出现需要滚动才能看全的情况。
改动:
- 表格 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)
238 lines
8.5 KiB
JavaScript
238 lines
8.5 KiB
JavaScript
/**
|
||
* shiroko-monitor 边缘探针(Cloudflare Workers)
|
||
*
|
||
* - scheduled:cron 每分钟触发一轮探测(HTTP 用 fetch,TCP 用 cloudflare:sockets)
|
||
* - 状态与历史心率整体存于 KV 的 state 键(小规模单键足够,上限 25MB)
|
||
* - /api/status、/api/monitors、/api/refresh 由本 Worker 提供,其余请求交给静态资源
|
||
*
|
||
* 注意:cloudflare:sockets 的 TCP 出站连接需要 Workers 付费计划;
|
||
* 免费计划下 http 类型探测与静态面板均可正常使用,tcp 类型会返回 down。
|
||
*/
|
||
|
||
import { connect } from 'cloudflare:sockets';
|
||
|
||
const VERSION = '0.9.1';
|
||
const HISTORY_LIMIT = 48;
|
||
const UPTIME_DAYS = 90;
|
||
const USER_AGENT = 'shiroko-monitor/0.3 (lightweight uptime panel)';
|
||
|
||
// 边缘端按 UTC 日期分桶(Worker 无本地时区概念)
|
||
function dayKey(d = new Date()) {
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
// 只保留最近 UPTIME_DAYS 天;YYYY-MM-DD 的字典序即时间序
|
||
function pruneDaily(daily) {
|
||
const keys = Object.keys(daily).sort();
|
||
while (keys.length > UPTIME_DAYS) delete daily[keys.shift()];
|
||
}
|
||
|
||
export default {
|
||
async scheduled(event, env, ctx) {
|
||
ctx.waitUntil(runChecksDedup(env));
|
||
},
|
||
|
||
async fetch(request, env, ctx) {
|
||
const url = new URL(request.url);
|
||
if (url.pathname === '/api/status') return json(await getStatus(env, ctx));
|
||
if (url.pathname === '/api/monitors') return json(await getMonitors(env, request.url));
|
||
if (url.pathname === '/api/refresh') {
|
||
// 公网上的强制探测必须显式开启,避免被任意访客当作「探测放大器」使用
|
||
const gate = refreshGate(env, request);
|
||
if (!gate.ok) return json({ error: gate.error }, { status: gate.status });
|
||
return json(await runChecksDedup(env));
|
||
}
|
||
return env.ASSETS.fetch(request);
|
||
},
|
||
};
|
||
|
||
// 未配置 REFRESH_TOKEN 时一律拒绝(wrangler secret put REFRESH_TOKEN 开启);
|
||
// 比较采用等长逐字节异或,避免按字符提前返回带来的时序差异
|
||
function refreshGate(env, request) {
|
||
const token = env.REFRESH_TOKEN;
|
||
if (!token) return { ok: false, status: 403, error: 'refresh-disabled' };
|
||
const url = new URL(request.url);
|
||
const provided = request.headers.get('x-refresh-token') || url.searchParams.get('token') || '';
|
||
if (provided.length !== token.length) return { ok: false, status: 403, error: 'refresh-forbidden' };
|
||
let diff = 0;
|
||
for (let i = 0; i < token.length; i++) diff |= provided.charCodeAt(i) ^ token.charCodeAt(i);
|
||
return diff === 0 ? { ok: true } : { ok: false, status: 403, error: 'refresh-forbidden' };
|
||
}
|
||
|
||
async function probeMonitor(m) {
|
||
if (m.type === 'tcp') return probeTcp(m);
|
||
// 边缘端无原始 socket 读取能力(sockets API 需付费版且未覆盖测试),banner 类型请使用本地探针
|
||
if (m.type === 'banner') return { status: 'down', latency: 0, code: null, detail: '边缘端暂不支持 banner 探测(请用本地探针)' };
|
||
return probeHttp(m);
|
||
}
|
||
|
||
// 同隔离体内共享同一轮探测,避免并发刷新对目标重复施压(跨隔离体为 best-effort)
|
||
let inFlightRound = null;
|
||
|
||
function runChecksDedup(env) {
|
||
if (!inFlightRound) {
|
||
inFlightRound = runChecks(env).finally(() => {
|
||
inFlightRound = null;
|
||
});
|
||
}
|
||
return inFlightRound;
|
||
}
|
||
|
||
function json(data, init = {}) {
|
||
return new Response(JSON.stringify(data), {
|
||
...init,
|
||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...(init.headers ?? {}) },
|
||
});
|
||
}
|
||
|
||
async function getMonitors(env, baseUrl) {
|
||
const raw = await env.MONITOR_KV.get('monitors');
|
||
if (raw) return JSON.parse(raw);
|
||
// 未手动写入 KV 时,直接从静态资产读取仓库根目录的 monitors.json,实现零配置部署
|
||
const res = await env.ASSETS.fetch(new URL('/monitors.json', baseUrl));
|
||
if (res.ok) return res.json();
|
||
return { settings: {}, monitors: [] };
|
||
}
|
||
|
||
async function getStatus(env, ctx) {
|
||
const state = (await env.MONITOR_KV.get('state', 'json')) ?? { monitors: {}, generatedAt: null };
|
||
if (!state.generatedAt) ctx.waitUntil(runChecks(env));
|
||
return state;
|
||
}
|
||
|
||
async function probeHttp(m) {
|
||
const started = Date.now();
|
||
const timeoutMs = (m.timeout ?? 10) * 1000;
|
||
try {
|
||
// redirect: manual —— 保留 3xx 原始状态码交给 expectStatus 判定
|
||
const res = await fetch(m.url, {
|
||
method: m.method || 'GET',
|
||
redirect: 'manual',
|
||
signal: AbortSignal.timeout(timeoutMs),
|
||
headers: { 'user-agent': USER_AGENT, accept: '*/*' },
|
||
});
|
||
const latency = Date.now() - started;
|
||
const expect = m.expectStatus ?? [200];
|
||
const ok = expect.includes(res.status);
|
||
return {
|
||
status: ok ? 'up' : 'down',
|
||
latency,
|
||
code: res.status,
|
||
detail: ok ? `HTTP ${res.status}` : `HTTP ${res.status}(期望 ${expect.join('/')})`,
|
||
};
|
||
} catch (err) {
|
||
const latency = Date.now() - started;
|
||
return {
|
||
status: 'down',
|
||
latency,
|
||
code: 0,
|
||
detail: err.name === 'TimeoutError' ? `请求超时(>${timeoutMs / 1000}s)` : String(err.cause?.code || err.message),
|
||
};
|
||
}
|
||
}
|
||
|
||
async function probeTcp(m) {
|
||
const started = Date.now();
|
||
const timeoutMs = (m.timeout ?? 10) * 1000;
|
||
try {
|
||
const socket = connect({ hostname: m.host, port: m.port });
|
||
const timer = setTimeout(() => socket.close(), timeoutMs);
|
||
await Promise.race([
|
||
socket.opened,
|
||
new Promise((_, reject) => setTimeout(() => reject(new Error('TCP 连接超时')), timeoutMs)),
|
||
]);
|
||
clearTimeout(timer);
|
||
socket.close();
|
||
return { status: 'up', latency: Date.now() - started, code: null, detail: `TCP ${m.host}:${m.port} 可连接` };
|
||
} catch (err) {
|
||
return { status: 'down', latency: Date.now() - started, code: null, detail: String(err?.message || err) };
|
||
}
|
||
}
|
||
|
||
async function runChecks(env) {
|
||
const config = await getMonitors(env, new URL('http://placeholder/'));
|
||
const state = (await env.MONITOR_KV.get('state', 'json')) ?? { monitors: {} };
|
||
const monitors = state.monitors ?? {};
|
||
const redact = config.settings?.redactDetails === true;
|
||
let up = 0;
|
||
let down = 0;
|
||
let pending = 0;
|
||
let latencySum = 0;
|
||
let latencyCount = 0;
|
||
|
||
await Promise.all(
|
||
(config.monitors ?? []).map(async (m) => {
|
||
const prev = monitors[m.id];
|
||
const r = await probeMonitor(m);
|
||
r.checkedAt = new Date().toISOString();
|
||
const history = [...(prev?.history ?? []), { t: r.checkedAt, ok: r.status === 'up', ms: r.latency ?? 0 }].slice(-HISTORY_LIMIT);
|
||
|
||
const daily = { ...(prev?.daily ?? {}) };
|
||
const key = dayKey();
|
||
const bucket = daily[key] ?? (daily[key] = { total: 0, up: 0 });
|
||
bucket.total++;
|
||
if (r.status === 'up') bucket.up++;
|
||
pruneDaily(daily);
|
||
|
||
monitors[m.id] = {
|
||
status: r.status,
|
||
latency: r.latency,
|
||
code: r.code,
|
||
detail: redact ? (r.status === 'up' ? '正常' : '异常') : r.detail,
|
||
lastCheck: r.checkedAt,
|
||
history,
|
||
daily,
|
||
};
|
||
if (r.status === 'up') {
|
||
up++;
|
||
latencySum += r.latency ?? 0;
|
||
latencyCount++;
|
||
} else {
|
||
down++;
|
||
}
|
||
})
|
||
);
|
||
|
||
let uptimeUp = 0;
|
||
let uptimeTotal = 0;
|
||
for (const m of Object.values(monitors)) {
|
||
for (const bucket of Object.values(m.daily ?? {})) {
|
||
uptimeTotal += bucket.total ?? 0;
|
||
uptimeUp += bucket.up ?? 0;
|
||
}
|
||
}
|
||
|
||
// 与本地探针保持同一份快照口径:dataUpdatedAt / updateIntervalSec / dataFilter
|
||
// 面板据此把倒计时对齐服务端节奏,并按同一过滤口径展示
|
||
const filter = ['both', 'config', 'uptimerobot'].includes(config.settings?.dataFilter) ? config.settings.dataFilter : 'both';
|
||
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);
|
||
}
|
||
}
|
||
|
||
const next = {
|
||
prober: `shiroko-monitor-worker/${VERSION}`,
|
||
demo: false,
|
||
generatedAt: new Date().toISOString(),
|
||
// state.generatedAt 是上一轮探测完成的时间,即「数据最后变更时刻」
|
||
dataUpdatedAt: state.generatedAt ?? new Date().toISOString(),
|
||
updateIntervalSec: Math.max(5, Math.round(intervals.length ? Math.min(...intervals) : 60)),
|
||
dataFilter: filter,
|
||
stats: {
|
||
total: (config.monitors ?? []).length,
|
||
up,
|
||
down,
|
||
pending,
|
||
avgLatency: latencyCount ? Math.round(latencySum / latencyCount) : null,
|
||
avgUptime90: uptimeTotal ? Math.round((uptimeUp / uptimeTotal) * 1000) / 10 : null,
|
||
},
|
||
sources: {},
|
||
monitors,
|
||
};
|
||
await env.MONITOR_KV.put('state', JSON.stringify(next));
|
||
return next;
|
||
}
|