Files
shiroko-monitor/public/assets/admin.js
T
tamakyi f345626840 fix: 管理入口改为「填链接」而非绑定特定隧道方案,并修掉配置接口的密钥泄露
用户反馈:「你不需要帮我处理 tunnel 这部分,也许我不是通过 tunnel 开放内网呢?
你只需要给我一个填写开放的管理端链接的地方。」

之前把「Cloudflare Tunnel」当成既定方案写进了配置与文档,这是替用户做了假设。
现在改为:项目只提供一个填地址的地方,怎么暴露完全由使用者决定。

- 删除 edge/cloudflare/tunnel.yml 与全部 tunnel 相关文档
- 新增 settings.adminUrl:管理界面里填一个地址,公开面板页脚出现「管理」按钮
  指向它;留空则完全不显示(默认,避免在公网面板暴露管理面位置)
- 只接受 http/https,javascript: 等协议被服务端拒绝(会被渲染成 <a href>)
- 设置落在 local.monitors.json(私有层),只有自己的面板会出现该按钮
- 面板启动时额外拉 /api/monitors 合并 settings:原先只读静态 monitors.json
  (公开层),导致私有层的设置面板看不到;纯静态部署无该接口时沿用公开配置

顺带修掉一个既存漏洞(与本轮改动无关,是引入私有层叠加机制时埋下的):
/api/monitors 无鉴权却回吐合并后的完整配置,包含私有层的 UptimeRobot API Key ——
只要配了 key,任何访客 curl /api/monitors 就能拿到。现在两侧(Node 与 Worker)
都对对外响应剥离 uptimeRobot.apiKey,保留 enabled 等非敏感字段供面板读取;
探针内部同步仍用完整配置,剥离只作用于 HTTP 出口。

测试:verify-worker 36→40 项(新增 [E] 配置接口密钥泄露 4 条)、
verify-security 20→25 项(新增 [D2] 密钥泄露 5 条)。全项目 8 套 314 项通过。
2026-10-14 15:23:22 +08:00

608 lines
24 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.
/**
* 管理后台前端:绑定 2FA → 登录 → 管理面板
*
* 零依赖,QR 码用内置的最小实现生成(只支持 byte 模式、ECC L、版本自动 1–10),
* 覆盖 otpauth 链接(通常 60–120 字节)的长度。
*/
const $ = (id) => document.getElementById(id);
const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
const API = '/api/admin';
let setupDraft = null; // 绑定过程中的临时状态:{ setupToken, secret, uri }
async function api(path, { method = 'GET', body } = {}) {
const res = await fetch(`${API}${path}`, {
method,
credentials: 'same-origin',
cache: 'no-store',
headers: body ? { 'content-type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(30000),
});
let data = null;
try { data = await res.json(); } catch { /* 空响应体 */ }
return { ok: res.ok, status: res.status, data, retryAfter: res.headers.get('retry-after') };
}
function msg(el, text, kind = '') {
el.textContent = text;
el.className = `admin-msg${kind ? ` ${kind}` : ''}`;
}
const ERROR_TEXT = {
'bad-setup-key': '绑定口令不正确',
'setup-disabled': '未设置 SHIROKO_ADMIN_SETUP_KEY 环境变量,管理端未开放绑定',
'already-bound': '已完成绑定,不能重复绑定',
'no-setup': '绑定流程已失效,请重新开始',
'setup-expired': '绑定流程超过 10 分钟已失效,请重新开始',
'invalid-code': '验证码不正确',
'invalid-format': '格式不正确',
replayed: '该验证码已被使用过,请等下一个时间窗',
locked: '失败次数过多,请稍后再试',
'not-bound': '尚未绑定 2FA',
unauthorized: '会话已失效,请重新登录',
'invalid-window': '时间无效,或结束早于开始',
'invalid-index': '窗口序号无效',
'invalid-scope': '探测范围取值无效',
'invalid-filter': '展示口径取值无效',
'notify-disabled': '未启用任何通知渠道',
};
const errText = (code, hint) => ERROR_TEXT[code] ?? hint ?? '操作失败';
/* ============================================================
* QR 码生成(byte 模式 / ECC L / 版本 1–10)
* 参考 ISO/IEC 18004;这里只保留生成 otpauth 链接所需的最小子集。
* ============================================================ */
// GF(256) 对数/指数表,本原多项式 0x11D
const GF_EXP = new Uint8Array(512);
const GF_LOG = new Uint8Array(256);
(function initGf() {
let x = 1;
for (let i = 0; i < 255; i++) {
GF_EXP[i] = x;
GF_LOG[x] = i;
x <<= 1;
if (x & 0x100) x ^= 0x11d;
}
for (let i = 255; i < 512; i++) GF_EXP[i] = GF_EXP[i - 255];
})();
const gfMul = (a, b) => (a === 0 || b === 0 ? 0 : GF_EXP[GF_LOG[a] + GF_LOG[b]]);
function rsGeneratorPoly(degree) {
let poly = [1];
for (let i = 0; i < degree; i++) {
const next = new Array(poly.length + 1).fill(0);
for (let j = 0; j < poly.length; j++) {
next[j] ^= poly[j];
next[j + 1] ^= gfMul(poly[j], GF_EXP[i]);
}
poly = next;
}
return poly;
}
function rsEncode(data, ecLen) {
const gen = rsGeneratorPoly(ecLen);
const res = new Uint8Array(data.length + ecLen);
res.set(data);
for (let i = 0; i < data.length; i++) {
const factor = res[i];
if (factor === 0) continue;
for (let j = 0; j < gen.length; j++) res[i + j] ^= gfMul(gen[j], factor);
}
return res.slice(data.length);
}
// 版本 1–10 在 ECC L 下的参数表:[总码字数, EC 每块码字数, 组1块数, 组1数据码字, 组2块数, 组2数据码字]
// 索引 0 = 版本 1
const VERSIONS = [
null,
[26, 7, 1, 19, 0, 0],
[44, 10, 1, 34, 0, 0],
[70, 15, 1, 55, 0, 0],
[100, 20, 1, 80, 0, 0],
[134, 26, 1, 108, 0, 0],
[172, 18, 2, 68, 0, 0],
[196, 20, 2, 78, 0, 0],
[242, 24, 2, 97, 0, 0],
[292, 30, 2, 116, 0, 0],
[346, 18, 2, 68, 2, 69],
];
const ALIGN_POS = {
1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30],
6: [6, 34], 7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50],
};
function pickVersion(byteLen) {
for (let v = 1; v <= 10; v++) {
const [, ecLen, b1, d1, b2, d2] = VERSIONS[v];
const capacityBits = b1 * d1 * 8 + b2 * d2 * 8;
const headerBits = 4 + (v < 10 ? 8 : 16);
if (headerBits + byteLen * 8 <= capacityBits) return v;
}
throw new Error('内容过长,无法生成二维码');
}
function buildCodewords(bytes, version) {
const [, ecLen, b1, d1, b2, d2] = VERSIONS[version];
const totalData = b1 * d1 + b2 * d2;
const bits = [];
const put = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1); };
put(0b0100, 4); // byte 模式
put(bytes.length, version < 10 ? 8 : 16);
for (const b of bytes) put(b, 8);
// 终止符最多 4 个 0,再补齐到字节边界
for (let i = 0; i < 4 && bits.length < totalData * 8; i++) bits.push(0);
while (bits.length % 8 !== 0) bits.push(0);
const codewords = new Uint8Array(totalData);
for (let i = 0; i < bits.length; i += 8) {
let byte = 0;
for (let j = 0; j < 8; j++) byte = (byte << 1) | bits[i + j];
codewords[i / 8] = byte;
}
// 填充字节 0xEC / 0x11 交替
const PAD = [0xec, 0x11];
for (let i = bits.length / 8, k = 0; i < totalData; i++, k++) codewords[i] = PAD[k % 2];
// 分块 + 纠错 + 交织
const blocks = [];
let off = 0;
for (let i = 0; i < b1; i++) { blocks.push(codewords.slice(off, off + d1)); off += d1; }
for (let i = 0; i < b2; i++) { blocks.push(codewords.slice(off, off + d2)); off += d2; }
const ecBlocks = blocks.map((b) => rsEncode(b, ecLen));
const out = [];
const maxData = Math.max(d1, d2);
for (let i = 0; i < maxData; i++) for (const b of blocks) if (i < b.length) out.push(b[i]);
for (let i = 0; i < ecLen; i++) for (const b of ecBlocks) out.push(b[i]);
return out;
}
function makeMatrix(version, codewords) {
const size = version * 4 + 17;
const m = Array.from({ length: size }, () => new Array(size).fill(null));
const setFn = (r, c, v) => { if (r >= 0 && r < size && c >= 0 && c < size) m[r][c] = v; };
const finder = (r0, c0) => {
for (let r = -1; r <= 7; r++) {
for (let c = -1; c <= 7; c++) {
const inRing = (r >= 0 && r <= 6 && (c === 0 || c === 6)) || (c >= 0 && c <= 6 && (r === 0 || r === 6));
const inCore = r >= 2 && r <= 4 && c >= 2 && c <= 4;
setFn(r0 + r, c0 + c, inRing || inCore ? 1 : 0);
}
}
};
finder(0, 0);
finder(0, size - 7);
finder(size - 7, 0);
// 校正图形(跳过与 finder 图案重叠的位置)
for (const r of ALIGN_POS[version] ?? []) {
for (const c of ALIGN_POS[version] ?? []) {
if ((r <= 8 && c <= 8) || (r <= 8 && c >= size - 9) || (r >= size - 9 && c <= 8)) continue;
for (let dr = -2; dr <= 2; dr++) {
for (let dc = -2; dc <= 2; dc++) {
setFn(r + dr, c + dc, Math.max(Math.abs(dr), Math.abs(dc)) !== 1 ? 1 : 0);
}
}
}
}
// 定时图形
for (let i = 8; i < size - 8; i++) {
setFn(6, i, i % 2 === 0 ? 1 : 0);
setFn(i, 6, i % 2 === 0 ? 1 : 0);
}
// 预留格式信息位(15 位)与版本信息位(版本 ≥ 7)
for (let i = 0; i <= 8; i++) {
if (m[8][i] === null) setFn(8, i, 0);
if (m[i][8] === null) setFn(i, 8, 0);
}
for (let i = 0; i < 8; i++) {
if (m[8][size - 1 - i] === null) setFn(8, size - 1 - i, 0);
if (m[size - 1 - i][8] === null) setFn(size - 1 - i, 8, 0);
}
if (version >= 7) {
for (let i = 0; i < 18; i++) {
setFn(Math.floor(i / 3), size - 11 + (i % 3), 0);
setFn(size - 11 + (i % 3), Math.floor(i / 3), 0);
}
}
const reserved = m.map((row) => row.map((v) => v !== null));
// 数据按之字形填入(从右下角起,每两列一组,方向交替)
let bitIndex = 0;
const dataBits = [];
for (const cw of codewords) for (let i = 7; i >= 0; i--) dataBits.push((cw >> i) & 1);
let upward = true;
for (let col = size - 1; col > 0; col -= 2) {
if (col === 6) col--; // 跳过垂直定时图形所在列
for (let k = 0; k < size; k++) {
const row = upward ? size - 1 - k : k;
for (const c of [col, col - 1]) {
if (reserved[row][c]) continue;
m[row][c] = dataBits[bitIndex] ? 1 : 0;
bitIndex++;
}
}
upward = !upward;
}
// 8 种掩码,按 ISO 惩罚分选最低
const maskFns = [
(r, c) => (r + c) % 2 === 0,
(r) => r % 2 === 0,
(r, c) => c % 3 === 0,
(r, c) => (r + c) % 3 === 0,
(r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
(r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
(r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0,
(r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
];
const applyMask = (base, fn) => base.map((row, r) => row.map((v, c) => (reserved[r][c] ? v : (v ^ (fn(r, c) ? 1 : 0)))));
const penalty = (g) => {
let score = 0;
// 规则 1:同色连续 ≥5
for (const line of [...g, ...g[0].map((_, c) => g.map((row) => row[c]))]) {
let run = 1;
for (let i = 1; i < line.length; i++) {
if (line[i] === line[i - 1]) run++;
else { if (run >= 5) score += run - 2; run = 1; }
}
if (run >= 5) score += run - 2;
}
// 规则 2:2×2 同色块
for (let r = 0; r < g.length - 1; r++) {
for (let c = 0; c < g.length - 1; c++) {
const v = g[r][c];
if (v === g[r][c + 1] && v === g[r + 1][c] && v === g[r + 1][c + 1]) score += 3;
}
}
// 规则 4:黑白比例偏离 50%
let dark = 0;
for (const row of g) for (const v of row) dark += v;
const pct = (dark * 100) / (g.length * g.length);
score += Math.floor(Math.abs(pct - 50) / 5) * 10;
return score;
};
let best = null;
for (let i = 0; i < 8; i++) {
const cand = applyMask(m, maskFns[i]);
const sc = penalty(cand);
if (!best || sc < best.score) best = { score: sc, grid: cand, mask: i };
}
// 写回格式信息:ECC L 的指示位为 0b01,拼接 3 位掩码编号后按 BCH(15,5) 计算校验并与 0x5412 掩码异或
const fmtData = (0b01 << 3) | best.mask;
let bch = fmtData << 10;
for (let i = 4; i >= 0; i--) {
if (bch & (1 << (i + 10))) bch ^= 0b10100110111 << i;
}
const format = ((fmtData << 10) | (bch & 0x3ff)) ^ 0b101010000010010;
const f = bitsOf(format, 15);
for (let i = 0; i <= 5; i++) best.grid[8][i] = f[i];
best.grid[8][7] = f[6];
best.grid[8][8] = f[7];
best.grid[7][8] = f[8];
for (let i = 9; i <= 14; i++) best.grid[14 - i][8] = f[i];
for (let i = 0; i <= 7; i++) best.grid[size - 1 - i][8] = f[i];
for (let i = 8; i <= 14; i++) best.grid[8][size - 15 + i] = f[i];
best.grid[size - 8][8] = 1; // 固定的黑点
return best.grid;
}
function bitsOf(val, len) {
const out = [];
for (let i = len - 1; i >= 0; i--) out.push((val >> i) & 1);
return out;
}
/** 把文本渲染成 canvas 上的二维码 */
function renderQr(text, canvas) {
const bytes = [...new TextEncoder().encode(text)];
const version = pickVersion(bytes.length);
const grid = makeMatrix(version, buildCodewords(bytes, version));
const n = grid.length;
const scale = Math.floor(canvas.width / (n + 2));
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#000000';
const offset = Math.floor((canvas.width - n * scale) / 2);
for (let r = 0; r < n; r++) {
for (let c = 0; c < n; c++) {
if (grid[r][c]) ctx.fillRect(offset + c * scale, offset + r * scale, scale, scale);
}
}
}
/* ============================================================
* 流程控制
* ============================================================ */
async function boot() {
const { ok, data } = await api('/status');
if (!ok) { msg($('loading'), '无法连接服务端'); return; }
$('loading').hidden = true;
if (!data.bound) {
if (!data.setupKeyConfigured) {
$('bind-panel').hidden = false;
msg($('setup-key-msg'), '服务端未设置 SHIROKO_ADMIN_SETUP_KEY,绑定未开放。请在启动探针的环境里设置后重启。', 'err');
$('setup-start').disabled = true;
return;
}
$('bind-panel').hidden = false;
return;
}
// 已绑定:直接试拉概览,能拉到说明会话还有效
const ov = await api('/overview');
if (ov.ok) return showConsole(ov.data);
$('login-panel').hidden = false;
$('login-code').focus();
}
function showConsole(data) {
$('login-panel').hidden = true;
$('bind-panel').hidden = true;
$('console-panel').hidden = false;
$('logout-btn').hidden = false;
$('admin-ver').textContent = `shiroko-monitor v${data.version}`;
renderConsole(data);
}
function renderConsole(d) {
const s = d.stats ?? {};
$('admin-stats').innerHTML = [
`<div class="admin-stat"><b>${s.total ?? '–'}</b><label>监控数</label></div>`,
`<div class="admin-stat up"><b>${s.up ?? '–'}</b><label>在线</label></div>`,
`<div class="admin-stat down"><b>${s.down ?? '–'}</b><label>离线</label></div>`,
`<div class="admin-stat"><b>${s.avgLatency != null ? `${s.avgLatency} ms` : '–'}</b><label>平均延迟</label></div>`,
`<div class="admin-stat"><b>${s.avgUptime90 != null ? `${s.avgUptime90}%` : '–'}</b><label>90天可用率</label></div>`,
].join('');
const st = d.status ?? {};
const channels = d.notify?.channels ?? [];
$('updated-at').innerHTML =
`数据更新于 ${esc(new Date(st.dataUpdatedAt ?? Date.now()).toLocaleString('zh-CN', { hour12: false }))} · ` +
`自建目标 ${st.localCount ?? 0} 个 / 配置 ${st.configCount ?? 0} 个 / UptimeRobot ${st.urCount ?? 0} 个` +
(st.urEnabled ? (st.urOk === false ? `(<b>同步异常:${esc(st.urError ?? '')}</b>)` : '(已启用)') : '(未启用)') +
(channels.length ? ` · 通知渠道 ${esc(channels.join(' + '))}` : '');
$('probe-scope').value = d.settings?.probeScope ?? 'all';
$('data-filter').value = d.settings?.dataFilter ?? 'both';
$('admin-url').value = d.settings?.adminUrl ?? '';
$('admin-url').dataset.saved = $('admin-url').value;
// 维护窗口目标下拉
const sel = $('mw-id');
const cur = sel.value;
sel.innerHTML = '<option value="">全部目标</option>' + d.monitors.map((m) => `<option value="${esc(m.id)}">${esc(m.name)}</option>`).join('');
if (cur) sel.value = cur;
const now = Date.now();
$('mw-list').innerHTML = d.maintenance.length
? d.maintenance.map((w, i) => {
const active = Date.parse(w.from) <= now && now <= Date.parse(w.to);
const scope = w.id ? (d.monitors.find((m) => m.id === w.id)?.name ?? w.id) : '全部目标';
return `<div class="mw-item${active ? ' active' : ''}">
<span class="mw-tag ${active ? 'active' : 'idle'}">${active ? '进行中' : '待生效'}</span>
<span class="mw-time">${esc(String(w.from).replace('T', ' ').slice(0, 16))} → ${esc(String(w.to).replace('T', ' ').slice(0, 16))}</span>
<span class="mw-note">${esc(scope)}${w.note ? ` · ${esc(w.note)}` : ''}</span>
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" data-del="${i}">删除</button>
</div>`;
}).join('')
: '<p class="admin-muted">暂无维护窗口。</p>';
for (const btn of $('mw-list').querySelectorAll('[data-del]')) {
btn.addEventListener('click', async () => {
const r = await api('/maintenance', { method: 'DELETE', body: { index: Number(btn.dataset.del) } });
if (r.ok) refresh();
else msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err');
});
}
$('monitor-list').innerHTML = d.monitors.map((m) => `<tr>
<td title="${esc(m.name)}">${esc(m.name)}</td>
<td>${esc(m.group)}</td>
<td>${esc(m.type)}</td>
<td class="mono" title="${esc(m.target)}">${esc(m.target)}</td>
<td>${m.interval != null ? `${m.interval}s` : '–'}</td>
</tr>`).join('');
const labels = { down: '故障', up: '恢复', tls: '证书', maintenance: '维护' };
const statusOf = { down: 'down', up: 'up', tls: 'pending', maintenance: 'up' };
$('event-list').innerHTML = d.events.length
? d.events.map((e) => `<tr data-status="${esc(statusOf[e.type] ?? 'up')}">
<td title="${esc(new Date(e.t).toLocaleString('zh-CN', { hour12: false }))}">${esc(new Date(e.t).toLocaleString('zh-CN', { hour12: false }))}</td>
<td><span class="ev-badge">${esc(labels[e.type] ?? e.type)}</span></td>
<td title="${esc(e.name)}">${esc(e.name)}</td>
<td class="mono" title="${esc(e.detail ?? '')}">${esc(e.detail ?? '')}</td>
</tr>`).join('')
: '<tr><td colspan="4">暂无事件</td></tr>';
const tf = d.twofa ?? {};
$('twofa-summary').innerHTML = `已绑定于 ${esc(tf.boundAt ?? '–')} · 剩余恢复码 ${tf.recoveryLeft ?? 0} 枚 · 当前会话 ${tf.sessions ?? 0} 个`
+ (tf.recoveryLow ? ' · <b style="color:var(--pending-text)">恢复码不足 3 枚,请重新绑定以获取新的一批</b>' : '');
}
async function refresh() {
const r = await api('/overview');
if (r.ok) renderConsole(r.data);
else if (r.status === 401) { $('console-panel').hidden = true; $('login-panel').hidden = false; }
}
/* ---------- 绑定 ---------- */
$('setup-start').addEventListener('click', async () => {
const key = $('setup-key').value;
msg($('setup-key-msg'), '');
const r = await api('/2fa/setup', { method: 'POST', body: { key } });
if (!r.ok) { msg($('setup-key-msg'), errText(r.data?.error, r.data?.hint), 'err'); return; }
setupDraft = { setupToken: r.data.setupToken, secret: r.data.secret, uri: r.data.uri };
$('setup-key').value = '';
$('step-key').hidden = true;
$('step-secret').hidden = false;
$('secret-text').textContent = r.data.secret.replace(/(.{4})/g, '$1 ').trim();
$('uri-text').textContent = r.data.uri;
try { renderQr(r.data.uri, $('qr')); } catch (err) { msg($('confirm-msg'), `二维码生成失败,可手动输入密钥:${err.message}`, 'err'); }
$('confirm-code').focus();
});
$('copy-secret').addEventListener('click', () => copy(setupDraft?.secret ?? ''));
$('copy-uri').addEventListener('click', () => copy(setupDraft?.uri ?? ''));
async function copy(text) {
if (!text) return;
try { await navigator.clipboard.writeText(text); } catch { /* 非安全上下文下 clipboard 不可用 */ }
}
$('confirm-btn').addEventListener('click', async () => {
const code = $('confirm-code').value.trim();
msg($('confirm-msg'), '');
const r = await api('/2fa/confirm', { method: 'POST', body: { setupToken: setupDraft?.setupToken, code } });
if (!r.ok) { msg($('confirm-msg'), errText(r.data?.error), 'err'); return; }
$('step-secret').hidden = true;
$('step-recovery').hidden = false;
setupDraft.confirmCode = r.data.confirmCode;
$('recovery-list').innerHTML = r.data.recoveryCodes.map((c) => `<code>${esc(c)}</code>`).join('');
$('copy-recovery').onclick = () => copy(r.data.recoveryCodes.join('\n'));
});
// 绑定完成即视为本次操作已通过 2FA(刚用 App 的验证码证明了持有),
// 直接用绑定时那枚验证码换会话,不再要求用户再输一次
$('recovery-done').addEventListener('click', async () => {
$('recovery-done').disabled = true;
const r = await api('/login', { method: 'POST', body: { code: setupDraft?.confirmCode } });
$('recovery-done').disabled = false;
if (r.ok) {
const ov = await api('/overview');
if (ov.ok) return showConsole(ov.data);
}
// 兜底:让用户手动登录(同一时间窗内的码已被防重放消费,也属正常)
$('bind-panel').hidden = true;
$('login-panel').hidden = false;
$('login-code').focus();
});
/* ---------- 登录 ---------- */
$('login-btn').addEventListener('click', doLogin);
$('login-code').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
async function doLogin() {
const code = $('login-code').value.trim();
if (!code) return;
msg($('login-msg'), '');
$('login-btn').disabled = true;
try {
const r = await api('/login', { method: 'POST', body: { code } });
if (!r.ok) {
const extra = r.data?.fails ? `(已失败 ${r.data.fails} 次)` : (r.retryAfter ? `(约 ${r.retryAfter} 秒后可试)` : '');
msg($('login-msg'), errText(r.data?.error) + extra, 'err');
return;
}
$('login-code').value = '';
const ov = await api('/overview');
if (ov.ok) showConsole(ov.data);
else msg($('login-msg'), '登录成功但读取概览失败,请刷新页面', 'err');
} finally {
$('login-btn').disabled = false;
}
}
$('reset-btn').addEventListener('click', async () => {
const key = $('reset-key').value;
if (!key) return;
if (!confirm('重置 2FA 会清空已绑定的密钥与全部恢复码,需重新绑定。确定继续?')) return;
const r = await api('/2fa/reset', { method: 'POST', body: { key } });
if (r.ok) location.reload();
else msg($('login-msg'), errText(r.data?.error), 'err');
});
$('logout-btn').addEventListener('click', async () => {
await api('/logout', { method: 'POST' });
location.reload();
});
/* ---------- 管理操作 ---------- */
$('probe-scope').addEventListener('change', async (e) => {
const r = await api('/probe-scope', { method: 'POST', body: { probeScope: e.target.value } });
if (r.ok) { msg($('action-msg'), `探测范围已改为 ${e.target.value}`, 'ok'); refresh(); }
else msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err');
});
$('data-filter').addEventListener('change', async (e) => {
const r = await api('/data-filter', { method: 'POST', body: { dataFilter: e.target.value } });
if (r.ok) { msg($('action-msg'), `展示口径已改为 ${e.target.value}`, 'ok'); refresh(); }
else msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err');
});
$('admin-url').addEventListener('change', async (e) => {
const v = e.target.value.trim();
const r = await api('/admin-url', { method: 'POST', body: { adminUrl: v } });
if (r.ok) {
msg($('action-msg'), v ? `面板页脚将显示指向 ${v} 的「管理」入口` : '已清空,面板不再显示管理入口', 'ok');
refresh();
} else {
msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err');
e.target.value = $('admin-url').dataset.saved ?? '';
}
});
$('probe-now').addEventListener('click', async () => {
const btn = $('probe-now');
btn.disabled = true;
msg($('action-msg'), '正在探测…');
try {
const r = await api('/probe-now', { method: 'POST' });
if (r.ok) { msg($('action-msg'), `探测完成:在线 ${r.data.stats.up},离线 ${r.data.stats.down}`, 'ok'); refresh(); }
else msg($('action-msg'), '探测失败', 'err');
} finally { btn.disabled = false; }
});
$('notify-test').addEventListener('click', async () => {
const btn = $('notify-test');
btn.disabled = true;
msg($('action-msg'), '正在发送…');
try {
const r = await api('/notify-test', { method: 'POST' });
if (!r.ok) { msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err'); return; }
const detail = (r.data.results ?? []).map((x) => `${x.channel} ${x.ok ? '成功' : `失败:${x.error}`}`).join(';');
msg($('action-msg'), detail || '无渠道', r.data.ok ? 'ok' : 'err');
} finally { btn.disabled = false; }
});
// 维护窗口默认填「现在起 1 小时」
(function initDefaults() {
const pad = (n) => String(n).padStart(2, '0');
const fmt = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
const now = new Date();
$('mw-from').value = fmt(now);
$('mw-to').value = fmt(new Date(now.getTime() + 60 * 60 * 1000));
})();
$('mw-add').addEventListener('click', async () => {
const from = $('mw-from').value;
const to = $('mw-to').value;
if (!from || !to) { msg($('action-msg'), '请填写开始与结束时间', 'err'); return; }
const body = { from: new Date(from).toISOString(), to: new Date(to).toISOString() };
const note = $('mw-note').value.trim();
const id = $('mw-id').value;
if (note) body.note = note;
if (id) body.id = id;
const r = await api('/maintenance', { method: 'POST', body });
if (r.ok) { msg($('action-msg'), '维护窗口已新增', 'ok'); $('mw-note').value = ''; refresh(); }
else msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err');
});
boot();