feat: v0.12.0 —— 表格每行绝对等高 + 管理 WebUI(2FA 绑定才能进入)
① 每行绝对等高 - 行高与内容长度彻底解耦:新增 --row-h: 72px 作为唯一基准 - 名称列改为「文本区限两行 + 徽标区固定单行」两段式;文本区用 .clamp2 - 关键点:必须用 height 而非 max-height —— 后者会让短内容缩成一行、 长内容撑到两行,反而产生 66px/71px 两种行高(实测踩过) - 分组标题行用 :not(.group-row) 排除在数据行规则之外 - 事件表与监控表格共用同一个 --row-h,两张表视觉上也严格对齐 - 完整值仍可通过悬停 title 查看(已加断言强制所有截断单元格带 title) - 卡片视图同样等高(13 视口 × 250px 全一致) ② 管理 WebUI + 2FA - 新增 /admin 与 /api/admin/*;未绑定 2FA 时除绑定流程外一律 403 - 零依赖实现 TOTP(RFC 6238),用 RFC 4226/6238 官方测试向量验证(16 个向量) - 内置 QR 码生成器(byte 模式 / ECC L / 版本 1-10,GF(256) RS 纠错 + 掩码评分), 用独立解码器实测:版本 1 与版本 6 均能读出逐字节一致的内容 - 绑定流程:口令 → 生成密钥(扫码/手输/otpauth 链接)→ 验证 6 位码 → 10 枚恢复码; 完成后凭本次验证码直接换取会话 - 安全:会话 id 32 字节随机数且服务端只存哈希;HttpOnly+SameSite=Strict; 30 分钟空闲/2 小时最长;同时间步的码防重放;恢复码一次性; 登录失败按 IP 指数退避锁定;不解析 X-Forwarded-For - 管理功能:运行状态、探测范围、展示口径、维护窗口增删、立即重探、通知自检、 目标只读列表、最近事件 - 所有写操作只落 local.monitors.json(私有不入库),绝不改 public/monitors.json 过程中修掉三个真 bug: 1. 会话 cookie 的 Path=/admin 覆盖不到 /api/admin/*,登录 200 但后续全部 401 2. 恢复码登录被 verifyTotp 的 invalid-format 提前挡掉(恢复码非 6 位数字) 3. 超大请求体用 req.destroy() 硬断连接,客户端只看到 fetch failed 拿不到状态码 测试:新增 scripts/verify-admin.cjs(102 项),全项目共 7 套 274 项全部通过
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
/* 管理后台:复用面板的配色变量,跟随浅色/深色与砂狼主题 */
|
||||
|
||||
body.admin-body { min-height: 100vh; }
|
||||
|
||||
.admin-topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px 20px;
|
||||
padding: 12px 20px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.admin-topbar .brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.admin-topbar-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.admin-ver { font-family: var(--mono); font-size: 12px; color: var(--text-2); }
|
||||
.admin-link { font-size: 13px; color: var(--accent); text-decoration: none; }
|
||||
.admin-link:hover { text-decoration: underline; }
|
||||
|
||||
.admin-main {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 18px 20px 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
padding: 16px 18px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.admin-card-narrow { max-width: 460px; }
|
||||
.admin-card h2 { margin: 0 0 10px; font-size: 14px; font-weight: 650; }
|
||||
.admin-card h3 { margin: 16px 0 6px; font-size: 13px; font-weight: 650; color: var(--text-2); }
|
||||
.admin-muted { margin: 0 0 10px; font-size: 12px; line-height: 1.7; color: var(--text-2); }
|
||||
.admin-muted code { font-family: var(--mono); background: var(--bg); padding: 0 4px; border-radius: 4px; }
|
||||
.admin-hint { margin: 4px 0 0; font-size: 11px; color: var(--text-2); line-height: 1.6; }
|
||||
|
||||
.admin-step { margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--border); }
|
||||
.admin-step:first-of-type { border-top: 0; }
|
||||
.admin-ol { margin: 6px 0 10px; padding-left: 20px; font-size: 12px; line-height: 1.9; color: var(--text-2); }
|
||||
|
||||
.admin-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.admin-row input, .admin-row select { flex: 1 1 200px; min-width: 0; }
|
||||
|
||||
.admin-field { margin: 10px 0; min-width: 0; }
|
||||
.admin-field label { display: block; margin-bottom: 4px; font-size: 12px; font-weight: 600; color: var(--text-2); }
|
||||
.admin-field-row { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.admin-field-row .admin-field { flex: 1 1 220px; }
|
||||
|
||||
input[type="text"], input[type="password"], input[type="datetime-local"], select {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
}
|
||||
input:focus, select:focus { outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 1px; }
|
||||
|
||||
.admin-btn {
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.admin-btn:hover { background: var(--bg); border-color: var(--accent); }
|
||||
.admin-btn:disabled { opacity: 0.55; cursor: default; }
|
||||
.admin-btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.admin-btn-primary:hover { filter: brightness(1.08); background: var(--accent); }
|
||||
.admin-btn-danger { border-color: color-mix(in srgb, var(--down) 45%, var(--border)); color: var(--down-text); }
|
||||
.admin-btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
.admin-btn-ghost { padding: 5px 12px; font-size: 12px; }
|
||||
|
||||
.admin-msg { margin: 8px 0 0; font-size: 12px; min-height: 18px; color: var(--text-2); }
|
||||
.admin-msg.err { color: var(--down-text); }
|
||||
.admin-msg.ok { color: var(--up-text); }
|
||||
|
||||
.qr-box { display: flex; justify-content: center; margin: 10px 0; padding: 10px; background: #fff; border: 1px solid var(--border); border-radius: 10px; }
|
||||
.qr-box canvas { width: 220px; height: 220px; image-rendering: pixelated; }
|
||||
|
||||
.secret-box {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
padding: 8px 10px; margin-bottom: 8px;
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
||||
}
|
||||
.secret-box code { flex: 1 1 200px; min-width: 0; font-family: var(--mono); font-size: 12px; word-break: break-all; line-height: 1.6; }
|
||||
|
||||
.admin-details { margin-top: 10px; font-size: 12px; color: var(--text-2); }
|
||||
.admin-details summary { cursor: pointer; color: var(--accent); }
|
||||
.admin-details > *:not(summary) { margin-top: 8px; }
|
||||
|
||||
.recovery-box {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(min(150px, 100%), 1fr));
|
||||
gap: 6px; margin-bottom: 10px;
|
||||
}
|
||||
.recovery-box code {
|
||||
padding: 6px 8px; background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: 6px; font-family: var(--mono); font-size: 12px; text-align: center;
|
||||
}
|
||||
|
||||
.admin-stats { display: flex; flex-wrap: wrap; gap: 8px 18px; margin-bottom: 8px; }
|
||||
.admin-stat { text-align: center; min-width: 56px; }
|
||||
.admin-stat b { display: block; font-size: 17px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.admin-stat label { font-size: 11px; color: var(--text-2); }
|
||||
.admin-stat.up b { color: var(--up-text); }
|
||||
.admin-stat.down b { color: var(--down-text); }
|
||||
.admin-kv { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 12px; color: var(--text-2); }
|
||||
.admin-kv code { font-family: var(--mono); color: var(--text); }
|
||||
|
||||
.mw-list { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
|
||||
.mw-item {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.mw-item .mw-time { font-family: var(--mono); }
|
||||
.mw-item .mw-note { color: var(--text-2); }
|
||||
.mw-item.active { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--bg)); }
|
||||
.mw-tag { padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 650; }
|
||||
.mw-tag.active { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); }
|
||||
.mw-tag.idle { background: var(--bg); color: var(--text-2); border: 1px solid var(--border); }
|
||||
|
||||
.table-scroll { overflow-x: auto; }
|
||||
.admin-table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 12.5px; }
|
||||
.admin-table th {
|
||||
padding: 8px 10px; text-align: left; font-size: 12px; font-weight: 600;
|
||||
color: var(--text-2); background: var(--bg); border-bottom: 1px solid var(--border); white-space: nowrap;
|
||||
}
|
||||
.admin-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.admin-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.admin-table tbody tr[data-status="down"] { background: color-mix(in srgb, var(--down) 6%, transparent); }
|
||||
.admin-table tbody tr[data-status="up"] { background: color-mix(in srgb, var(--up) 5%, transparent); }
|
||||
.admin-table tbody tr[data-status="pending"] { background: color-mix(in srgb, var(--pending) 6%, transparent); }
|
||||
.admin-table td.mono { font-family: var(--mono); }
|
||||
.admin-table th:nth-child(1) { width: 26%; }
|
||||
.admin-table th:nth-child(2) { width: 16%; }
|
||||
.admin-table th:nth-child(3) { width: 11%; }
|
||||
.admin-table th:nth-child(4) { width: 30%; }
|
||||
.admin-table th:nth-child(5) { width: 17%; }
|
||||
.admin-table.ev-table th:nth-child(1) { width: 15%; }
|
||||
.admin-table.ev-table th:nth-child(2) { width: 10%; }
|
||||
.admin-table.ev-table th:nth-child(3) { width: 25%; }
|
||||
.admin-table.ev-table th:nth-child(4) { width: 50%; }
|
||||
.ev-badge { padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 650; }
|
||||
tr[data-status="down"] .ev-badge { background: var(--down-bg); color: var(--down-text); }
|
||||
tr[data-status="up"] .ev-badge { background: var(--up-bg); color: var(--up-text); }
|
||||
tr[data-status="pending"] .ev-badge { background: var(--pending-bg); color: var(--pending-text); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-topbar { padding: 10px 14px; }
|
||||
.admin-main { padding: 14px 14px 40px; }
|
||||
.admin-field-row { flex-direction: column; gap: 0; }
|
||||
.admin-table.ev-table td.mono, .admin-table.ev-table th:nth-child(4) { display: none; }
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
/**
|
||||
* 管理后台前端:绑定 2FA → 登录 → 管理面板
|
||||
*
|
||||
* 零依赖,QR 码用内置的最小实现生成(只支持 byte 模式、ECC L、版本自动 1–10),
|
||||
* 覆盖 otpauth 链接(通常 60–120 字节)的长度。
|
||||
*/
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
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';
|
||||
|
||||
// 维护窗口目标下拉
|
||||
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');
|
||||
});
|
||||
|
||||
$('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();
|
||||
@@ -1,4 +1,4 @@
|
||||
const VERSION = '0.11.2';
|
||||
const VERSION = '0.12.0';
|
||||
const HB_LIMIT = 48;
|
||||
const UPTIME_DAYS = 90;
|
||||
const BROWSER_CONCURRENCY = 6;
|
||||
@@ -270,8 +270,8 @@ function renderEvents() {
|
||||
return `<tr class="ev" data-status="${escapeHtml(statusOf[e.type] ?? 'up')}">
|
||||
<td class="ev-time" title="${escapeHtml(fmtTime(e.t))}">${escapeHtml(fmtRelative(e.t))}</td>
|
||||
<td class="ev-type"><span class="ev-badge">${escapeHtml(label[e.type] ?? e.type)}</span>${maint}</td>
|
||||
<td class="ev-name" title="${escapeHtml(e.name)}">${escapeHtml(e.name)}</td>
|
||||
<td class="ev-detail" title="${escapeHtml(e.detail ?? '')}">${escapeHtml(e.detail ?? '')}</td>
|
||||
<td class="ev-name" title="${escapeHtml(e.name)}"><span class="clamp2">${escapeHtml(e.name)}</span></td>
|
||||
<td class="ev-detail" title="${escapeHtml(e.detail ?? '')}"><span class="clamp2">${escapeHtml(e.detail ?? '')}</span></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
@@ -543,9 +543,12 @@ function tableRow(m, hide) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.status = s.cls;
|
||||
tr.dataset.id = m.id;
|
||||
const targetCell = hide ? '' : `<td class="t-target" title="${escapeHtml(targetText(m))}">${escapeHtml(targetText(m))}</td>`; tr.innerHTML =
|
||||
const targetCell = hide ? '' : `<td class="t-target" title="${escapeHtml(targetText(m))}"><span class="clamp2">${escapeHtml(targetText(m))}</span></td>`;
|
||||
// 名称与徽标分行:名称限两行、徽标独占一行,两者高度都固定,行高不再随名称/地址长度变化
|
||||
const tags = `${typeLabel(m) ? `<span class="type-badge">${escapeHtml(typeLabel(m))}</span>` : ''}${certBadge(st)}${maintBadge(st)}`;
|
||||
tr.innerHTML =
|
||||
`<td class="t-state"><span class="dot"></span>${s.label}</td>` +
|
||||
`<td class="t-name" title="${escapeHtml(m.name)}">${escapeHtml(m.name)}<span class="type-badge">${escapeHtml(typeLabel(m))}</span>${certBadge(st)}${maintBadge(st)}</td>` +
|
||||
`<td class="t-name" title="${escapeHtml(m.name)}"><span class="clamp2">${escapeHtml(m.name)}</span><span class="t-tags">${tags}</span></td>` +
|
||||
targetCell +
|
||||
`<td class="t-num">${st?.latency != null ? `${st.latency} ms` : '–'}</td>` +
|
||||
`<td class="t-num">${escapeHtml(resultText(st, m))}</td>` +
|
||||
|
||||
+44
-8
@@ -21,6 +21,11 @@
|
||||
--mono: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, "Courier New", monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / 0.04);
|
||||
/* 表格行高基准:固定像素,行高与内容长度彻底解耦(任意长度内容下行高都相同)。
|
||||
72px = 名称两行(36) + 徽标行(18) + 上下内边距(16),监控表与事件表共用同一个值,
|
||||
两张表因此在视觉上也严格对齐。 */
|
||||
--row-h: 72px;
|
||||
--group-row-h: 32px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@@ -368,6 +373,11 @@ body[data-view="table"] .grid { display: block; }
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
/* 行高基准:所有数据行恒为 --row-h,与内容长度彻底解耦。
|
||||
内容再长也不会把某一行撑高(文本列各自限行截断 + 单元格 overflow:hidden)。
|
||||
分组标题行另设 --group-row-h,用 :not() 排除掉,否则同样会被这条规则套上。 */
|
||||
.monitor-table tbody tr:not(.group-row) { height: var(--row-h); }
|
||||
.group-row td { height: var(--group-row-h); }
|
||||
/* 表格列宽:状态 / 服务 / 目标 / 延迟 / 结果 / 最近检测 / 心率 / 90天可用率 = 100%
|
||||
地址与结果列给足比例,避免长 URL、长名称被截断 */
|
||||
.monitor-table th:nth-child(1) { width: 7%; }
|
||||
@@ -390,34 +400,56 @@ body[data-view="table"] .grid { display: block; }
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.monitor-table td { padding: 9px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; overflow: hidden; line-height: 1.4; }
|
||||
.monitor-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; overflow: hidden; line-height: 1.4; }
|
||||
/* 所有行统一基线:可用率的 mini 条是 inline-flex,其基线会把行盒撑高十几像素,
|
||||
导致带条的行比不带的行高出一截(实测 58px vs 43px) */
|
||||
.monitor-table td > * { vertical-align: middle; }
|
||||
.u-mini { display: inline-flex; align-items: center; gap: 6px; vertical-align: middle; }
|
||||
/* 限行截断:文本再多也只占固定的两行,行高因此与内容长度彻底解耦。
|
||||
height 而非 max-height —— 只有 1 行内容时也照样占满 2 行,行高才恒定;
|
||||
max-height 会让短内容缩成 1 行、长内容撑到 2 行,反而出现两种行高。
|
||||
-webkit-line-clamp 是 Chrome/Safari/Edge 的实现,height + overflow 兜底 Firefox。 */
|
||||
.clamp2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
height: 2.8em;
|
||||
}
|
||||
.monitor-table tbody tr[data-status="pending"] { background: color-mix(in srgb, var(--pending) 6%, transparent); }
|
||||
.monitor-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.monitor-table tbody tr:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
|
||||
.monitor-table tbody tr[data-status="down"] { background: color-mix(in srgb, var(--down) 6%, transparent); }
|
||||
.group-row td { padding: 7px 12px; font-size: 12px; font-weight: 650; color: var(--text-2); background: var(--bg); }
|
||||
.group-row td { padding: 7px 12px; font-size: 12px; font-weight: 650; color: var(--text-2); background: var(--bg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.t-state { white-space: nowrap; }
|
||||
.t-state .dot { display: inline-block; margin-right: 6px; vertical-align: middle; }
|
||||
tr[data-status="up"] .t-state { color: var(--up-text); }
|
||||
tr[data-status="down"] .t-state { color: var(--down-text); }
|
||||
tr[data-status="pending"] .t-state { color: var(--pending-text); }
|
||||
.t-name { white-space: normal; overflow-wrap: anywhere; }
|
||||
.t-name .type-badge { margin-left: 8px; }
|
||||
/* 名称列:文本区限两行,徽标区固定单行高度 —— 整格高度恒定 */
|
||||
.t-name { white-space: normal; }
|
||||
.t-name .clamp2 { overflow-wrap: anywhere; }
|
||||
.t-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 16px;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.t-name .type-badge { margin-left: 0; }
|
||||
.t-target {
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-2);
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-all;
|
||||
}
|
||||
.t-num { font-variant-numeric: tabular-nums; white-space: normal; overflow-wrap: anywhere; }
|
||||
.t-target .clamp2 { overflow-wrap: anywhere; word-break: break-all; }
|
||||
.t-num { font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* 证书 / 维护徽标:出现在名称旁,不占独立列 */
|
||||
.tls-badge, .maint-badge {
|
||||
@@ -469,6 +501,7 @@ tr[data-status="pending"] .t-state { color: var(--pending-text); }
|
||||
/* 事件表:显式声明与监控表格相同的观感(不共用 .monitor-table 类,
|
||||
避免被其他按类名统计/定位的逻辑误算),列宽与类型徽标单独定义 */
|
||||
.events-table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 13px; }
|
||||
.events-table tbody tr { height: var(--row-h); }
|
||||
.events-table th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
@@ -481,7 +514,7 @@ tr[data-status="pending"] .t-state { color: var(--pending-text); }
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.events-table td { padding: 11px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; line-height: 1.4; overflow: hidden; }
|
||||
.events-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; line-height: 1.4; overflow: hidden; }
|
||||
.events-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.events-table tbody tr:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
|
||||
.events-table tbody tr[data-status="down"] { background: color-mix(in srgb, var(--down) 6%, transparent); }
|
||||
@@ -505,6 +538,9 @@ tr[data-status="pending"] .ev-badge { background: var(--pending-bg); color: var(
|
||||
.ev-maint { margin-left: 6px; border-radius: 999px; padding: 0 6px; font-size: 10px; font-weight: 650; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
|
||||
.ev-name { font-weight: 600; }
|
||||
.ev-detail { color: var(--text-2); }
|
||||
/* 事件表的长文本同样限两行,保证与监控表格一致的等高行 */
|
||||
.ev-name, .ev-detail { white-space: normal; }
|
||||
.ev-name > .clamp2, .ev-detail > .clamp2 { overflow-wrap: anywhere; }
|
||||
.events-empty { margin: 0; padding: 12px; font-size: 12px; color: var(--text-2); background: var(--surface); border: 1px dashed var(--border); border-radius: 12px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
Reference in New Issue
Block a user