Files
tamakyi 752fad412b 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 项全部通过
2026-10-14 12:37:07 +08:00

134 lines
6.3 KiB
JavaScript
Raw Permalink 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 布局回归:任何宽度、任何内容都不应出现横向滚动
*
* 硬要求(用户明确):不允许出现「需要滚动才能看全」的情况。
* 因此这里用**极端内容**(超长名称 / 超长 URL / 6 个分组 / 40 个目标)+ 13 种视口 × 2 种视图
* 做扫描,断言页面与表格容器都不能有横向溢出。
*
* 为什么要用极端内容:常规内容下很多撑破问题不会暴露(实测卡片视图在 320px 下
* 曾因 grid 的 min-width:auto 被长 URL 撑到 801px,溢出 495px)。
*
* 用法:node scripts/verify-layout.cjs
*/
const fs = require('node:fs');
const http = require('node:http');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { chromium } = require('playwright');
const ROOT = path.join(__dirname, '..');
const PROBER = path.join(ROOT, 'prober', 'node', 'index.js');
const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const PORT = 8893;
const WIDTHS = [320, 360, 390, 480, 600, 768, 820, 900, 1024, 1280, 1440, 1920, 2560];
const results = [];
const check = (name, pass, detail) => {
results.push({ name, pass });
console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`);
};
const MIME = { '.html': 'text/html;charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.png': 'image/png' };
// 极端配置:长名称 + 长 URL + 多分组 + 多目标
function hostileConfig() {
const groups = ['超长分组名称测试一二三四五', '公共服务', '基础设施与内网设备', 'UptimeRobot', '外部依赖', '测试用极端分组名'];
const monitors = [];
for (let i = 0; i < 40; i++) {
monitors.push({
id: `hostile-${i}`,
name: i % 3 === 0 ? `这是一个非常非常长的监控目标名称用于测试极端情况下的布局表现与截断行为-${i + 1}` : `目标-${i + 1}`,
group: groups[i % groups.length],
type: 'http',
url: i % 2 === 0 ? `https://very-long-subdomain-name-for-testing-layout.example.com/very/long/path/segment?with=query&and=more&even=more&idx=${i}` : `https://s${i}.example.com`,
expectStatus: [200],
interval: 60,
timeout: 5,
});
}
return monitors;
}
function runOnce(args) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [PROBER, ...args], { stdio: 'ignore' });
child.on('exit', (code) => resolve(code));
});
}
async function main() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-layout-'));
const cfg = path.join(tmp, 'monitors.json');
fs.cpSync(path.join(ROOT, 'public'), tmp, { recursive: true });
const base = JSON.parse(fs.readFileSync(cfg, 'utf8'));
base.settings.dataFilter = 'config';
base.monitors = hostileConfig();
fs.writeFileSync(cfg, `${JSON.stringify(base, null, 2)}\n`);
await runOnce(['--once', '--demo', `--config=${cfg}`, `--local-config=${path.join(tmp, 'none.json')}`, `--out=${path.join(tmp, 'status.json')}`]);
console.log(`极端内容:${base.monitors.length} 个目标 / 6 个分组,视口 ${WIDTHS.length} 种 × 2 视图\n`);
const server = http.createServer((req, res) => {
const rel = decodeURIComponent(req.url.split('?')[0]);
const file = path.join(tmp, rel === '/' ? 'index.html' : rel);
fs.readFile(file, (err, data) => {
if (err) {
res.writeHead(404);
res.end();
return;
}
res.writeHead(200, { 'content-type': MIME[path.extname(file)] ?? 'application/octet-stream' });
res.end(data);
});
});
await new Promise((r) => server.listen(PORT, r));
const browser = await chromium.launch({ executablePath: CHROME, args: ['--no-sandbox'] });
let worst = null;
try {
for (const view of ['table', 'cards']) {
for (const width of WIDTHS) {
const page = await browser.newPage({ viewport: { width, height: 900 } });
await page.goto(`http://localhost:${PORT}/?source=static&view=${view}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.card, table.monitor-table', { timeout: 20000 });
await page.waitForTimeout(400);
const r = await page.evaluate(() => ({
page: document.documentElement.scrollWidth - document.documentElement.clientWidth,
wrap: (() => { const w = document.querySelector('.table-wrap'); return w ? w.scrollWidth - w.clientWidth : 0; })(),
grid: (() => { const g = document.querySelector('.grid'); return g ? g.scrollWidth - g.clientWidth : 0; })(),
// 表格视图额外要求绝对等高:40 个目标、6 个分组、名称与 URL 长短悬殊,
// 行高仍须只有一个取值(硬要求:每行绝对等高)
rowHs: document.body.dataset.view === 'table'
? [...new Set([...document.querySelectorAll('table.monitor-table tbody tr:not(.group-row)')]
.map((tr) => Math.round(tr.getBoundingClientRect().height)))]
: null,
cardHs: document.body.dataset.view === 'cards'
? [...new Set([...document.querySelectorAll('.card')].map((c) => Math.round(c.getBoundingClientRect().height)))]
: null,
}));
const bad = Math.max(r.page, r.wrap, r.grid);
if (!worst || bad > worst.bad) worst = { bad, view, width, r };
const extra = view === 'table'
? (r.rowHs?.length === 1 ? `行高 ${r.rowHs[0]}px` : `行高 ${(r.rowHs ?? []).join('/')}`)
: (r.cardHs?.length === 1 ? `卡片高 ${r.cardHs[0]}px` : `卡片高 ${(r.cardHs ?? []).join('/')}`);
check(`${String(width).padStart(4)}px ${view} 无横向溢出且等高`, bad <= 0 && ((view === 'table' ? r.rowHs : r.cardHs) ?? []).length === 1,
`页面 ${r.page} / 表格 ${r.wrap} / 网格 ${r.grid},${extra}`);
await page.close();
}
}
} finally {
await browser.close();
server.close();
fs.rmSync(tmp, { recursive: true, force: true });
}
if (worst && worst.bad > 0) console.log(`\n最大溢出:${worst.width}px ${worst.view} → ${worst.bad}px`);
const failed = results.filter((x) => !x.pass);
console.log(`\n结果:${results.length - failed.length}/${results.length} 通过`);
if (failed.length) process.exit(1);
}
main().catch((err) => {
console.error('FAILED:', err.stack || err.message);
process.exit(1);
});