Files
shiroko-monitor/edge/cloudflare/worker.js
T
tamakyi a7408e2bb5 feat(theme): 全局装饰遮罩 + 尺寸统一;全局审阅修复对比度/暗色/平板布局
用户反馈的两点:
1. 遮罩看不出效果 → 人物与单车统一全局半透明 settings.decoOpacity(默认 .78)
   (此前 0.92/0.80 不一致且几乎无感),各自保留边缘渐隐
2. 切视图时人物比例变化 → 删掉卡片视图专用的 300px 高度,两种视图尺寸一致
3. 保留原始素材 → 原图归档 assets-src/,处理流程固化为 scripts/prepare-artwork.py

全局审阅发现并修复:
- 无障碍对比度:两套主题的状态色当文字用时远低于 AA(最低 2.56:1)→ 双色体系
  (填充色 ≥3:1 保持鲜亮 / 文字色 ≥4.5:1 同色系加深),暗色模式同步
- 暗色模式主题失效:顶栏/卡片/表头/分组行是硬编码白色 → 改为随模式切换的变量
- 平板竖屏 768px 表格需内部横滚 242px → 自动切卡片阈值 700 → 1024
- Cloudflare Worker 快照补齐 dataUpdatedAt/updateIntervalSec/dataFilter
- 清理失效 CSS(.logo .halo)

测试脚本修掉两个自身 bug(均为误报):color(srgb/…) 与半透明底的解析、
渐变背景被误判为透明。verify-views 新增对比度断言 8 条,四套合计 89 项全通过。
2026-10-13 21:55:44 +08:00

238 lines
8.5 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.
/**
* 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.0';
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;
}