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:
tamakyi
2026-10-14 12:37:07 +08:00
parent a3a683ff0e
commit 752fad412b
20 changed files with 2631 additions and 339 deletions
+195
View File
@@ -0,0 +1,195 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>管理后台 · Shiroko Monitor</title>
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<link rel="stylesheet" href="/assets/style.css">
<link rel="stylesheet" href="/assets/theme-shiroko.css">
<link rel="stylesheet" href="/assets/admin.css">
</head>
<body class="admin-body">
<header class="admin-topbar">
<div class="brand">
<span class="logo" aria-hidden="true"></span>
<div class="brand-text">
<h1>管理后台</h1>
<p>需绑定并通过 2FA 验证后才能进入</p>
</div>
</div>
<div class="admin-topbar-right">
<span class="admin-ver" id="admin-ver">–</span>
<a class="admin-link" href="/">← 返回面板</a>
<button type="button" class="admin-btn admin-btn-ghost" id="logout-btn" hidden>退出登录</button>
</div>
</header>
<main class="admin-main">
<!-- 状态探测中 -->
<section class="admin-card" id="loading"><p class="admin-muted">正在检查 2FA 状态…</p></section>
<!-- ① 未绑定:先绑定 2FA -->
<section class="admin-card" id="bind-panel" hidden>
<h2>第一步:绑定 2FA</h2>
<p class="admin-muted">管理后台必须绑定两步验证后才能进入。请先在启动探针的环境里设置 <code>SHIROKO_ADMIN_SETUP_KEY</code>,然后在这里输入它以生成密钥。</p>
<div class="admin-step" id="step-key">
<label for="setup-key">绑定口令(SHIROKO_ADMIN_SETUP_KEY)</label>
<div class="admin-row">
<input type="password" id="setup-key" autocomplete="off" placeholder="环境变量里设置的那个值" inputmode="text">
<button type="button" class="admin-btn" id="setup-start">生成密钥</button>
</div>
<p class="admin-msg" id="setup-key-msg"></p>
</div>
<div class="admin-step" id="step-secret" hidden>
<h3>第二步:把密钥加入你的验证器 App</h3>
<ol class="admin-ol">
<li>用 Google Authenticator / Microsoft Authenticator / 1Password / Aegis 扫描二维码,或</li>
<li>手动输入下面的密钥(Base32):</li>
</ol>
<div class="qr-box"><canvas id="qr" width="220" height="220" aria-label="2FA 绑定二维码"></canvas></div>
<div class="secret-box">
<code id="secret-text">–</code>
<button type="button" class="admin-btn admin-btn-sm" id="copy-secret">复制密钥</button>
</div>
<details class="admin-details">
<summary>扫码不方便?复制 otpauth 链接</summary>
<div class="secret-box"><code id="uri-text">–</code><button type="button" class="admin-btn admin-btn-sm" id="copy-uri">复制链接</button></div>
</details>
<h3>第三步:输入 App 显示的 6 位验证码完成绑定</h3>
<div class="admin-row">
<input type="text" id="confirm-code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="6 位数字">
<button type="button" class="admin-btn" id="confirm-btn">完成绑定</button>
</div>
<p class="admin-msg" id="confirm-msg"></p>
</div>
<div class="admin-step" id="step-recovery" hidden>
<h3>第四步:保存恢复码(只显示这一次)</h3>
<p class="admin-muted">每个恢复码只能用一次,用于手机丢失、验证器清库时登录。请离线保存,不要只存在这台机器上。</p>
<div class="recovery-box" id="recovery-list"></div>
<div class="admin-row">
<button type="button" class="admin-btn admin-btn-sm" id="copy-recovery">复制全部恢复码</button>
<button type="button" class="admin-btn admin-btn-primary" id="recovery-done">我已保存</button>
</div>
</div>
</section>
<!-- ② 已绑定未登录:输 2FA 码登录 -->
<section class="admin-card admin-card-narrow" id="login-panel" hidden>
<h2>两步验证</h2>
<p class="admin-muted">输入验证器 App 的 6 位动态码,或使用一枚未用过的恢复码。</p>
<div class="admin-row">
<input type="text" id="login-code" inputmode="numeric" autocomplete="one-time-code" placeholder="验证码 / 恢复码" autofocus>
<button type="button" class="admin-btn admin-btn-primary" id="login-btn">进入</button>
</div>
<p class="admin-msg" id="login-msg"></p>
<details class="admin-details">
<summary>验证码设备丢了?</summary>
<p class="admin-muted">用一枚未使用的恢复码登录;或用 <code>SHIROKO_ADMIN_SETUP_KEY</code> 重置 2FA(重置后需重新绑定)。</p>
<div class="admin-row">
<input type="password" id="reset-key" placeholder="SHIROKO_ADMIN_SETUP_KEY" autocomplete="off">
<button type="button" class="admin-btn admin-btn-danger" id="reset-btn">重置 2FA</button>
</div>
</details>
</section>
<!-- ③ 已登录:管理面板 -->
<section id="console-panel" hidden>
<div class="admin-card" id="stat-card">
<h2>运行状态</h2>
<div class="admin-stats" id="admin-stats"></div>
<p class="admin-muted" id="updated-at">–</p>
</div>
<div class="admin-card">
<h2>口径与范围</h2>
<div class="admin-field">
<label for="probe-scope">探测范围</label>
<select id="probe-scope">
<option value="all">全部(自建 + UptimeRobot)</option>
<option value="config">仅自建配置</option>
<option value="uptimerobot">仅 UptimeRobot</option>
</select>
<p class="admin-hint">决定探针真正发请求的范围;改动立即生效并热重载配置。</p>
</div>
<div class="admin-field">
<label for="data-filter">展示口径</label>
<select id="data-filter">
<option value="both">全部</option>
<option value="config">仅自建配置</option>
<option value="uptimerobot">仅 UptimeRobot</option>
</select>
<p class="admin-hint">只影响面板展示哪些目标;与探测范围相互独立。</p>
</div>
<div class="admin-row">
<button type="button" class="admin-btn" id="probe-now">立即重新探测</button>
<button type="button" class="admin-btn" id="notify-test">发送通知自检</button>
</div>
<p class="admin-msg" id="action-msg"></p>
</div>
<div class="admin-card">
<h2>维护窗口</h2>
<p class="admin-muted">窗口内的目标不计入可用率、也不发故障告警。</p>
<div class="admin-field-row">
<div class="admin-field">
<label for="mw-from">开始</label>
<input type="datetime-local" id="mw-from">
</div>
<div class="admin-field">
<label for="mw-to">结束</label>
<input type="datetime-local" id="mw-to">
</div>
</div>
<div class="admin-field-row">
<div class="admin-field">
<label for="mw-id">仅限目标(留空 = 全部)</label>
<select id="mw-id"><option value="">全部目标</option></select>
</div>
<div class="admin-field">
<label for="mw-note">备注</label>
<input type="text" id="mw-note" maxlength="200" placeholder="例如:升级数据库">
</div>
</div>
<div class="admin-row">
<button type="button" class="admin-btn admin-btn-primary" id="mw-add">新增窗口</button>
</div>
<div class="mw-list" id="mw-list"></div>
</div>
<div class="admin-card">
<h2>监控目标</h2>
<p class="admin-muted">当前配置中的目标(探测范围为 uptimerobot 时,本地目标不会被真正探测)。</p>
<div class="table-scroll">
<table class="admin-table">
<thead><tr><th>名称</th><th>分组</th><th>类型</th><th>目标</th><th>间隔</th></tr></thead>
<tbody id="monitor-list"></tbody>
</table>
</div>
</div>
<div class="admin-card">
<h2>最近事件</h2>
<div class="table-scroll">
<table class="admin-table ev-table">
<thead><tr><th>时间</th><th>类型</th><th>目标</th><th>详情</th></tr></thead>
<tbody id="event-list"></tbody>
</table>
</div>
</div>
<div class="admin-card">
<h2>安全</h2>
<p class="admin-muted" id="twofa-summary">–</p>
<p class="admin-muted">会话最长 2 小时,30 分钟无操作自动失效;进程重启会清空所有会话。</p>
</div>
</section>
</main>
<script type="module" src="/assets/admin.js"></script>
</body>
</html>
+171
View File
@@ -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; }
}
+594
View File
@@ -0,0 +1,594 @@
/**
* 管理后台前端:绑定 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';
// 维护窗口目标下拉
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();
+8 -5
View File
@@ -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
View File
@@ -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) {
+1 -1
View File
@@ -75,7 +75,7 @@
<span id="notify-badge" class="footer-item" hidden>通知 –</span>
<span id="updated-at" class="footer-item">–</span>
<span id="countdown" class="footer-item">–</span>
<span class="footer-item version">shiroko-monitor v0.11.2</span>
<span class="footer-item version">shiroko-monitor v0.12.0</span>
</footer>
<script type="module" src="assets/app.js"></script>
+303 -303
View File
@@ -1,22 +1,22 @@
{
"prober": "shiroko-monitor-node/0.11.1",
"prober": "shiroko-monitor-node/0.12.0",
"demo": false,
"generatedAt": "2026-10-14T02:48:57.258Z",
"dataUpdatedAt": "2026-10-14T02:45:46.268Z",
"generatedAt": "2026-10-14T04:36:38.881Z",
"dataUpdatedAt": "2026-10-14T04:36:26.361Z",
"updateIntervalSec": 300,
"dataFilter": "uptimerobot",
"probeScope": "uptimerobot",
"probeScope": "all",
"stats": {
"total": 6,
"up": 5,
"down": 1,
"pending": 0,
"avgLatency": 735,
"avgLatency": 755,
"avgUptime90": 83.3,
"memory": {
"rssMB": 64.5,
"heapUsedMB": 11.2,
"heapTotalMB": 14.2
"rssMB": 80.2,
"heapUsedMB": 10.8,
"heapTotalMB": 15
}
},
"sources": {
@@ -24,10 +24,10 @@
"enabled": true,
"ok": true,
"error": null,
"lastSyncAt": "2026-10-14T02:45:46.268Z",
"lastSyncAt": "2026-10-14T04:33:06.976Z",
"count": 6,
"syncing": false,
"lastSyncMs": 1791945944983
"lastSyncMs": 1791952385563
}
},
"notify": {
@@ -55,21 +55,11 @@
"monitors": {
"ur-792059439": {
"status": "up",
"latency": 190,
"latency": 99,
"code": null,
"detail": "UptimeRobot:运行正常",
"lastCheck": "2026-10-14T02:41:00.000Z",
"lastCheck": "2026-10-14T04:33:00.000Z",
"history": [
{
"t": "2026-10-13T03:50:00.000Z",
"ok": true,
"ms": 104
},
{
"t": "2026-10-13T04:35:00.000Z",
"ok": true,
"ms": 478
},
{
"t": "2026-10-13T05:15:00.000Z",
"ok": true,
@@ -226,23 +216,38 @@
"ms": 421
},
{
"t": "2026-10-14T02:05:00.000Z",
"t": "2026-10-14T02:00:00.000Z",
"ok": true,
"ms": 272
"ms": 191
},
{
"t": "2026-10-14T02:41:00.000Z",
"t": "2026-10-14T02:40:00.000Z",
"ok": true,
"ms": 190
"ms": 187
},
{
"t": "2026-10-14T03:20:00.000Z",
"ok": true,
"ms": 282
},
{
"t": "2026-10-14T03:57:00.000Z",
"ok": true,
"ms": 321
},
{
"t": "2026-10-14T04:33:00.000Z",
"ok": true,
"ms": 99
}
],
"daily": {
"2026-10-14": {
"total": 7,
"up": 7,
"latSum": 1758,
"latCount": 7,
"latMax": 326
"total": 4,
"up": 4,
"latSum": 690,
"latCount": 4,
"latMax": 197
}
},
"meta": {
@@ -267,38 +272,23 @@
"90": 0
},
"lat": {
"avg": 251,
"max": 326,
"avg": 173,
"max": 197,
"days": [
{
"key": "2026-10-14",
"avg": 251
"avg": 173
}
]
}
},
"ur-795497022": {
"status": "up",
"latency": 811,
"latency": 619,
"code": null,
"detail": "UptimeRobot:运行正常",
"lastCheck": "2026-10-14T02:45:00.000Z",
"lastCheck": "2026-10-14T04:32:00.000Z",
"history": [
{
"t": "2026-10-13T03:35:00.000Z",
"ok": true,
"ms": 726
},
{
"t": "2026-10-13T04:15:00.000Z",
"ok": true,
"ms": 690
},
{
"t": "2026-10-13T04:55:00.000Z",
"ok": true,
"ms": 653
},
{
"t": "2026-10-13T05:35:00.000Z",
"ok": true,
@@ -445,28 +435,38 @@
"ms": 676
},
{
"t": "2026-10-14T01:31:00.000Z",
"t": "2026-10-14T01:00:00.000Z",
"ok": true,
"ms": 802
"ms": 755
},
{
"t": "2026-10-14T02:08:00.000Z",
"t": "2026-10-14T01:40:00.000Z",
"ok": true,
"ms": 634
"ms": 658
},
{
"t": "2026-10-14T02:45:00.000Z",
"t": "2026-10-14T02:20:00.000Z",
"ok": true,
"ms": 811
"ms": 740
},
{
"t": "2026-10-14T03:55:00.000Z",
"ok": true,
"ms": 631
},
{
"t": "2026-10-14T04:32:00.000Z",
"ok": true,
"ms": 619
}
],
"daily": {
"2026-10-14": {
"total": 7,
"up": 7,
"latSum": 5767,
"latCount": 7,
"latMax": 845
"total": 4,
"up": 4,
"latSum": 2484,
"latCount": 4,
"latMax": 627
}
},
"meta": {
@@ -491,33 +491,23 @@
"90": 5432
},
"lat": {
"avg": 824,
"max": 845,
"avg": 621,
"max": 627,
"days": [
{
"key": "2026-10-14",
"avg": 824
"avg": 621
}
]
}
},
"ur-795556828": {
"status": "up",
"latency": 448,
"latency": 335,
"code": null,
"detail": "UptimeRobot:运行正常",
"lastCheck": "2026-10-14T02:41:00.000Z",
"lastCheck": "2026-10-14T04:28:00.000Z",
"history": [
{
"t": "2026-10-13T03:45:00.000Z",
"ok": true,
"ms": 229
},
{
"t": "2026-10-13T04:30:00.000Z",
"ok": true,
"ms": 234
},
{
"t": "2026-10-13T05:10:00.000Z",
"ok": true,
@@ -554,143 +544,158 @@
"ms": 256
},
{
"t": "2026-10-13T09:55:00.000Z",
"t": "2026-10-13T09:50:00.000Z",
"ok": true,
"ms": 455
"ms": 500
},
{
"t": "2026-10-13T10:35:00.000Z",
"t": "2026-10-13T10:30:00.000Z",
"ok": true,
"ms": 372
"ms": 337
},
{
"t": "2026-10-13T11:15:00.000Z",
"t": "2026-10-13T11:10:00.000Z",
"ok": true,
"ms": 240
"ms": 197
},
{
"t": "2026-10-13T11:55:00.000Z",
"t": "2026-10-13T11:50:00.000Z",
"ok": true,
"ms": 261
"ms": 344
},
{
"t": "2026-10-13T12:35:00.000Z",
"t": "2026-10-13T12:30:00.000Z",
"ok": true,
"ms": 335
"ms": 341
},
{
"t": "2026-10-13T13:15:00.000Z",
"t": "2026-10-13T13:10:00.000Z",
"ok": true,
"ms": 413
"ms": 347
},
{
"t": "2026-10-13T13:55:00.000Z",
"t": "2026-10-13T13:50:00.000Z",
"ok": true,
"ms": 283
"ms": 220
},
{
"t": "2026-10-13T14:35:00.000Z",
"ok": true,
"ms": 309
},
{
"t": "2026-10-13T15:20:00.000Z",
"ok": true,
"ms": 451
},
{
"t": "2026-10-13T16:00:00.000Z",
"ok": true,
"ms": 248
},
{
"t": "2026-10-13T16:40:00.000Z",
"ok": true,
"ms": 378
},
{
"t": "2026-10-13T17:20:00.000Z",
"ok": true,
"ms": 293
},
{
"t": "2026-10-13T18:00:00.000Z",
"ok": true,
"ms": 495
},
{
"t": "2026-10-13T18:40:00.000Z",
"ok": true,
"ms": 391
},
{
"t": "2026-10-13T19:20:00.000Z",
"t": "2026-10-13T14:30:00.000Z",
"ok": true,
"ms": 338
},
{
"t": "2026-10-13T20:00:00.000Z",
"t": "2026-10-13T15:10:00.000Z",
"ok": true,
"ms": 363
"ms": 510
},
{
"t": "2026-10-13T20:40:00.000Z",
"t": "2026-10-13T15:50:00.000Z",
"ok": true,
"ms": 499
"ms": 230
},
{
"t": "2026-10-13T21:20:00.000Z",
"t": "2026-10-13T16:30:00.000Z",
"ok": true,
"ms": 372
"ms": 401
},
{
"t": "2026-10-13T22:00:00.000Z",
"t": "2026-10-13T17:10:00.000Z",
"ok": true,
"ms": 194
"ms": 302
},
{
"t": "2026-10-13T22:40:00.000Z",
"t": "2026-10-13T17:50:00.000Z",
"ok": true,
"ms": 225
"ms": 495
},
{
"t": "2026-10-13T23:20:00.000Z",
"t": "2026-10-13T18:30:00.000Z",
"ok": true,
"ms": 237
"ms": 389
},
{
"t": "2026-10-14T00:00:00.000Z",
"t": "2026-10-13T19:10:00.000Z",
"ok": true,
"ms": 328
},
{
"t": "2026-10-13T19:50:00.000Z",
"ok": true,
"ms": 338
},
{
"t": "2026-10-13T20:35:00.000Z",
"ok": true,
"ms": 423
},
{
"t": "2026-10-13T21:15:00.000Z",
"ok": true,
"ms": 394
},
{
"t": "2026-10-13T21:55:00.000Z",
"ok": true,
"ms": 214
},
{
"t": "2026-10-13T22:35:00.000Z",
"ok": true,
"ms": 240
},
{
"t": "2026-10-13T23:15:00.000Z",
"ok": true,
"ms": 215
},
{
"t": "2026-10-14T00:40:00.000Z",
"t": "2026-10-13T23:55:00.000Z",
"ok": true,
"ms": 298
"ms": 240
},
{
"t": "2026-10-14T01:20:00.000Z",
"t": "2026-10-14T00:35:00.000Z",
"ok": true,
"ms": 450
"ms": 329
},
{
"t": "2026-10-14T02:05:00.000Z",
"t": "2026-10-14T01:15:00.000Z",
"ok": true,
"ms": 229
"ms": 387
},
{
"t": "2026-10-14T02:41:00.000Z",
"t": "2026-10-14T01:55:00.000Z",
"ok": true,
"ms": 448
"ms": 337
},
{
"t": "2026-10-14T02:35:00.000Z",
"ok": true,
"ms": 381
},
{
"t": "2026-10-14T03:15:00.000Z",
"ok": true,
"ms": 355
},
{
"t": "2026-10-14T03:52:00.000Z",
"ok": true,
"ms": 538
},
{
"t": "2026-10-14T04:28:00.000Z",
"ok": true,
"ms": 335
}
],
"daily": {
"2026-10-14": {
"total": 7,
"up": 7,
"latSum": 2113,
"latCount": 7,
"latMax": 448
"total": 4,
"up": 4,
"latSum": 1352,
"latCount": 4,
"latMax": 339
}
},
"meta": {
@@ -715,33 +720,23 @@
"90": 0
},
"lat": {
"avg": 302,
"max": 448,
"avg": 338,
"max": 339,
"days": [
{
"key": "2026-10-14",
"avg": 302
"avg": 338
}
]
}
},
"ur-792059414": {
"status": "up",
"latency": 1795,
"latency": 2408,
"code": null,
"detail": "UptimeRobot:运行正常",
"lastCheck": "2026-10-14T02:40:00.000Z",
"lastCheck": "2026-10-14T04:27:00.000Z",
"history": [
{
"t": "2026-10-13T03:55:00.000Z",
"ok": true,
"ms": 2124
},
{
"t": "2026-10-13T04:40:00.000Z",
"ok": true,
"ms": 2623
},
{
"t": "2026-10-13T05:25:00.000Z",
"ok": true,
@@ -868,38 +863,48 @@
"ms": 2418
},
{
"t": "2026-10-14T00:00:00.000Z",
"t": "2026-10-14T00:05:00.000Z",
"ok": true,
"ms": 1947
"ms": 2098
},
{
"t": "2026-10-14T00:40:00.000Z",
"t": "2026-10-14T00:50:00.000Z",
"ok": true,
"ms": 2257
"ms": 1921
},
{
"t": "2026-10-14T01:20:00.000Z",
"t": "2026-10-14T01:35:00.000Z",
"ok": true,
"ms": 2605
"ms": 3110
},
{
"t": "2026-10-14T02:01:00.000Z",
"t": "2026-10-14T02:20:00.000Z",
"ok": true,
"ms": 2591
"ms": 1719
},
{
"t": "2026-10-14T02:40:00.000Z",
"t": "2026-10-14T03:05:00.000Z",
"ok": true,
"ms": 1795
"ms": 1623
},
{
"t": "2026-10-14T03:48:00.000Z",
"ok": true,
"ms": 3512
},
{
"t": "2026-10-14T04:27:00.000Z",
"ok": true,
"ms": 2408
}
],
"daily": {
"2026-10-14": {
"total": 7,
"up": 7,
"latSum": 12076,
"latCount": 7,
"latMax": 1795
"total": 4,
"up": 4,
"latSum": 10418,
"latCount": 4,
"latMax": 2670
}
},
"meta": {
@@ -924,206 +929,206 @@
"90": 7593
},
"lat": {
"avg": 1725,
"max": 1795,
"avg": 2605,
"max": 2670,
"days": [
{
"key": "2026-10-14",
"avg": 1725
"avg": 2605
}
]
}
},
"ur-798788113": {
"status": "up",
"latency": 433,
"latency": 312,
"code": null,
"detail": "UptimeRobot:运行正常",
"lastCheck": "2026-10-14T02:42:00.000Z",
"lastCheck": "2026-10-14T04:29:00.000Z",
"history": [
{
"t": "2026-10-13T04:00:00.000Z",
"ok": true,
"ms": 400
},
{
"t": "2026-10-13T04:40:00.000Z",
"ok": true,
"ms": 357
},
{
"t": "2026-10-13T05:20:00.000Z",
"ok": true,
"ms": 372
},
{
"t": "2026-10-13T06:00:00.000Z",
"ok": true,
"ms": 426
},
{
"t": "2026-10-13T06:40:00.000Z",
"ok": true,
"ms": 314
},
{
"t": "2026-10-13T07:20:00.000Z",
"t": "2026-10-13T05:30:00.000Z",
"ok": true,
"ms": 449
},
{
"t": "2026-10-13T08:00:00.000Z",
"t": "2026-10-13T06:10:00.000Z",
"ok": true,
"ms": 279
"ms": 386
},
{
"t": "2026-10-13T08:40:00.000Z",
"t": "2026-10-13T06:50:00.000Z",
"ok": true,
"ms": 407
"ms": 311
},
{
"t": "2026-10-13T09:20:00.000Z",
"t": "2026-10-13T07:30:00.000Z",
"ok": true,
"ms": 317
"ms": 384
},
{
"t": "2026-10-13T10:00:00.000Z",
"t": "2026-10-13T08:10:00.000Z",
"ok": true,
"ms": 627
"ms": 390
},
{
"t": "2026-10-13T10:40:00.000Z",
"t": "2026-10-13T08:50:00.000Z",
"ok": true,
"ms": 505
"ms": 461
},
{
"t": "2026-10-13T11:20:00.000Z",
"t": "2026-10-13T09:30:00.000Z",
"ok": true,
"ms": 424
"ms": 545
},
{
"t": "2026-10-13T12:00:00.000Z",
"t": "2026-10-13T10:10:00.000Z",
"ok": true,
"ms": 1135
"ms": 456
},
{
"t": "2026-10-13T12:40:00.000Z",
"t": "2026-10-13T10:50:00.000Z",
"ok": true,
"ms": 462
"ms": 394
},
{
"t": "2026-10-13T13:20:00.000Z",
"t": "2026-10-13T11:30:00.000Z",
"ok": true,
"ms": 436
"ms": 398
},
{
"t": "2026-10-13T14:00:00.000Z",
"t": "2026-10-13T12:10:00.000Z",
"ok": true,
"ms": 521
"ms": 588
},
{
"t": "2026-10-13T14:40:00.000Z",
"t": "2026-10-13T12:50:00.000Z",
"ok": true,
"ms": 480
"ms": 430
},
{
"t": "2026-10-13T15:20:00.000Z",
"t": "2026-10-13T13:30:00.000Z",
"ok": true,
"ms": 462
"ms": 502
},
{
"t": "2026-10-13T16:00:00.000Z",
"t": "2026-10-13T14:15:00.000Z",
"ok": true,
"ms": 331
"ms": 355
},
{
"t": "2026-10-13T16:40:00.000Z",
"t": "2026-10-13T14:55:00.000Z",
"ok": true,
"ms": 373
"ms": 358
},
{
"t": "2026-10-13T17:20:00.000Z",
"t": "2026-10-13T15:35:00.000Z",
"ok": true,
"ms": 429
"ms": 385
},
{
"t": "2026-10-13T18:00:00.000Z",
"t": "2026-10-13T16:15:00.000Z",
"ok": true,
"ms": 558
"ms": 342
},
{
"t": "2026-10-13T18:40:00.000Z",
"t": "2026-10-13T16:55:00.000Z",
"ok": true,
"ms": 370
"ms": 428
},
{
"t": "2026-10-13T19:20:00.000Z",
"t": "2026-10-13T17:35:00.000Z",
"ok": true,
"ms": 1519
"ms": 508
},
{
"t": "2026-10-13T20:00:00.000Z",
"t": "2026-10-13T18:15:00.000Z",
"ok": true,
"ms": 483
"ms": 602
},
{
"t": "2026-10-13T20:40:00.000Z",
"ok": true,
"ms": 388
},
{
"t": "2026-10-13T21:20:00.000Z",
"ok": true,
"ms": 367
},
{
"t": "2026-10-13T22:00:00.000Z",
"ok": true,
"ms": 273
},
{
"t": "2026-10-13T22:40:00.000Z",
"ok": true,
"ms": 261
},
{
"t": "2026-10-13T23:20:00.000Z",
"ok": true,
"ms": 263
},
{
"t": "2026-10-14T00:00:00.000Z",
"ok": true,
"ms": 229
},
{
"t": "2026-10-14T00:40:00.000Z",
"t": "2026-10-13T18:55:00.000Z",
"ok": true,
"ms": 363
},
{
"t": "2026-10-14T01:20:00.000Z",
"t": "2026-10-13T19:40:00.000Z",
"ok": true,
"ms": 329
"ms": 1380
},
{
"t": "2026-10-14T02:06:00.000Z",
"t": "2026-10-13T20:20:00.000Z",
"ok": true,
"ms": 234
"ms": 512
},
{
"t": "2026-10-14T02:42:00.000Z",
"t": "2026-10-13T21:00:00.000Z",
"ok": true,
"ms": 433
"ms": 394
},
{
"t": "2026-10-13T21:40:00.000Z",
"ok": true,
"ms": 238
},
{
"t": "2026-10-13T22:20:00.000Z",
"ok": true,
"ms": 323
},
{
"t": "2026-10-13T23:00:00.000Z",
"ok": true,
"ms": 353
},
{
"t": "2026-10-13T23:40:00.000Z",
"ok": true,
"ms": 179
},
{
"t": "2026-10-14T00:20:00.000Z",
"ok": true,
"ms": 263
},
{
"t": "2026-10-14T01:00:00.000Z",
"ok": true,
"ms": 381
},
{
"t": "2026-10-14T01:40:00.000Z",
"ok": true,
"ms": 378
},
{
"t": "2026-10-14T02:20:00.000Z",
"ok": true,
"ms": 278
},
{
"t": "2026-10-14T03:17:00.000Z",
"ok": true,
"ms": 345
},
{
"t": "2026-10-14T03:53:00.000Z",
"ok": true,
"ms": 259
},
{
"t": "2026-10-14T04:29:00.000Z",
"ok": true,
"ms": 312
}
],
"daily": {
"2026-10-14": {
"total": 7,
"up": 7,
"latSum": 2680,
"latCount": 7,
"latMax": 435
"total": 4,
"up": 4,
"latSum": 1245,
"latCount": 4,
"latMax": 323
}
},
"meta": {
@@ -1148,12 +1153,12 @@
"90": 0
},
"lat": {
"avg": 383,
"max": 435,
"avg": 311,
"max": 323,
"days": [
{
"key": "2026-10-14",
"avg": 383
"avg": 311
}
]
}
@@ -1165,11 +1170,6 @@
"detail": "UptimeRobot:403 Forbidden",
"lastCheck": "2026-05-07T07:54:09.000Z",
"history": [
{
"t": "2026-10-13T19:25:00.000Z",
"ok": true,
"ms": 185
},
{
"t": "2026-05-07T07:54:09.000Z",
"ok": false,
@@ -1178,7 +1178,7 @@
],
"daily": {
"2026-10-14": {
"total": 7,
"total": 4,
"up": 0
}
},