Files
shiroko-monitor/edge/cloudflare/worker.js
T
tamakyi 6f9a2d33ce fix(theme): 补抠封闭白区(光环/车架/轮圈内),人物与单车加渐隐遮罩
问题(用户发现):
1. 光环环内是白块 —— 此前只用「边界洪泛」抠底,被图形围起来的封闭白色
   区域(光环内圈、单车车架三角、轮圈内扇形)与边界不连通,没被处理
2. 人物与单车直接贴在页面上显得突兀

修复:
- 对三张图再补一次「封闭白色区域」清理(按面积阈值):
  人物抠 3675px(含光环 2665)、单车抠 19657px、图标抠 308px;并同步重生成 favicon
  已验证脸部 / 袜子 / 衣服等服装白未被误抠(保留 34px 噪点级)
- 立绘 opacity .92 + 上下渐隐遮罩;单车 opacity .8 + 两端渐隐 + saturate(.92)

验证:四套脚本 81 项全通过;1440 宽 0 横向溢出;局部放大确认光环内圈可透出背景
2026-10-13 21:06:29 +08:00

222 lines
7.7 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.8.3';
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;
}
}
const next = {
prober: `shiroko-monitor-worker/${VERSION}`,
demo: false,
generatedAt: new Date().toISOString(),
stats: {
total: (config.monitors ?? []).length,
up,
down,
pending,
avgLatency: latencyCount ? Math.round(latencySum / latencyCount) : null,
avgUptime90: uptimeTotal ? Math.round((uptimeUp / uptimeTotal) * 1000) / 10 : null,
},
monitors,
};
await env.MONITOR_KV.put('state', JSON.stringify(next));
return next;
}