feat(web): 新增 Web 观测台(形态 A)+ 抽出 render/targets 共用层
- tcaptcha/web/:FastAPI + SSE + 零构建单页。把同步阻塞的 solve() 包成可观测 后台任务(工作线程 + 全局串行,因 onnxruntime session 非线程安全),用 logging.Handler 零侵入把 client 日志变成事件流;按靶子实测 min_interval 强制节流。 - tcaptcha/render.py:标注图(点击顺序编号 + 质心偏差)与点位量尺,CLI 与 web 共用。 - tcaptcha/targets.py:靶子表提为单一来源(含实测频控间隔 1ms=60s / mliev=3s)。 - fastapi/uvicorn 走 optional extra `web`,核心包零 web 依赖(带回归断言)。 - 修两个真 bug:hook 求解器时 args[0] 是实例自身(真入参从 args[1] 起); client 默认每次新建 solver,预热实例未被复用(每发重载 56MB ONNX)。 - 复验:icon/text/slider 三题型各拿到 errorCode=0;selftest 全绿;依赖隔离通过。 用法:uv run --extra web python -m tcaptcha.web.app
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
/* tcaptcha 观测台 —— 零构建单页。只用原生 API,不引框架/打包器。
|
||||
*
|
||||
* 数据来源只有两个:
|
||||
* POST /api/jobs 建任务,拿 job_id
|
||||
* GET /api/jobs/{id}/events SSE 事件流(服务端推 event.to_dict())
|
||||
* 事件类型由后端 tcaptcha/web/events.py 的 EVT_* 定义,这里按 data.type 分派。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const EC_MEANING = {
|
||||
0: { text: '通过', cls: 'ec0' },
|
||||
50: { text: '答案被否', cls: 'ec50' },
|
||||
9: { text: '答案被否', cls: 'ec50' },
|
||||
12: { text: '包级拒答', cls: 'ec12' },
|
||||
};
|
||||
|
||||
function ecInfo(ec) {
|
||||
if (ec === null || ec === undefined || ec === '') return { text: '—', cls: 'ecother' };
|
||||
return EC_MEANING[Number(ec)] || { text: '未知', cls: 'ecother' };
|
||||
}
|
||||
|
||||
function hhmmss(ts) {
|
||||
const d = new Date((ts || Date.now() / 1000) * 1000);
|
||||
return d.toTimeString().slice(0, 8);
|
||||
}
|
||||
|
||||
let state = {
|
||||
targets: [], kinds: [], currentJob: null, es: null,
|
||||
cooldownUntil: 0, cards: {}, lastResult: null,
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ 启动 */
|
||||
async function boot() {
|
||||
await loadTargets();
|
||||
await loadHistory();
|
||||
setInterval(tickCooldown, 1000);
|
||||
$('target').onchange = () => renderNote();
|
||||
$('start').onclick = startJob;
|
||||
$('cancel').onclick = cancelJob;
|
||||
}
|
||||
|
||||
async function loadTargets() {
|
||||
const r = await fetch('/api/targets').then((x) => x.json());
|
||||
state.targets = r.targets;
|
||||
state.kinds = r.kinds;
|
||||
$('target').innerHTML = r.targets
|
||||
.map((t) => `<option value="${t.key}">${t.key} aid=${t.aid}(最小间隔 ${t.min_interval}s)</option>`)
|
||||
.join('');
|
||||
renderNote();
|
||||
applyCooldown(r.cooldown);
|
||||
}
|
||||
|
||||
function renderNote() {
|
||||
const t = state.targets.find((x) => x.key === $('target').value);
|
||||
$('targetNote').textContent = t ? `【${t.key}】${t.note}` : '';
|
||||
}
|
||||
|
||||
function applyCooldown(cd) {
|
||||
const left = cd && cd[$('target').value];
|
||||
if (typeof left === 'number' && left > 0) state.cooldownUntil = Date.now() + left * 1000;
|
||||
}
|
||||
|
||||
function tickCooldown() {
|
||||
const left = Math.ceil((state.cooldownUntil - Date.now()) / 1000);
|
||||
const el = $('cooldown');
|
||||
if (left > 0) {
|
||||
el.textContent = `频控冷却 ${left}s`;
|
||||
el.className = 'cooldown';
|
||||
$('start').disabled = true;
|
||||
} else {
|
||||
el.textContent = '可发';
|
||||
el.className = 'cooldown ready';
|
||||
$('start').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 任务 */
|
||||
async function startJob() {
|
||||
const body = {
|
||||
target: $('target').value,
|
||||
kind: $('kind').value,
|
||||
max_retries: 2,
|
||||
max_refreshes: 20,
|
||||
};
|
||||
resetView();
|
||||
const job = await fetch('/api/jobs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).then((r) => r.json());
|
||||
subscribe(job.id);
|
||||
}
|
||||
|
||||
function subscribe(jobId) {
|
||||
if (state.es) { state.es.close(); state.es = null; }
|
||||
state.currentJob = jobId;
|
||||
$('jobId').textContent = jobId;
|
||||
setState('pending');
|
||||
$('cancel').disabled = false;
|
||||
const es = new EventSource(`/api/jobs/${jobId}/events?since=0`);
|
||||
es.onmessage = (e) => {
|
||||
let evt;
|
||||
try { evt = JSON.parse(e.data); } catch (err) { return; }
|
||||
onEvent(evt);
|
||||
if (evt.type === 'job.finished' || evt.type === 'job.error') {
|
||||
es.close(); // 关键:必须主动关闭,否则 EventSource 会自动重连
|
||||
if (state.es === es) state.es = null;
|
||||
}
|
||||
};
|
||||
es.onerror = () => { /* 断线时 EventSource 自动重连并带 Last-Event-ID 续传 */ };
|
||||
state.es = es;
|
||||
}
|
||||
|
||||
async function cancelJob() {
|
||||
if (!state.currentJob) return;
|
||||
await fetch(`/api/jobs/${state.currentJob}/cancel`, { method: 'POST' });
|
||||
$('cancel').disabled = true;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 事件分派 */
|
||||
function onEvent(evt) {
|
||||
const { type, data } = evt;
|
||||
switch (type) {
|
||||
case 'job.started':
|
||||
setState('running');
|
||||
logRow('meta', `任务启动:靶子=${data.target} aid=${data.aid} 题型=${data.kind} 模式=${data.mode}`);
|
||||
return;
|
||||
case 'log':
|
||||
logRow(data.level, data.message);
|
||||
return;
|
||||
case 'challenge':
|
||||
addChallengeCard(data);
|
||||
logRow('meta', `抽到第 ${data.index} 题:${data.kind} ${data.instruction}`);
|
||||
return;
|
||||
case 'solve':
|
||||
fillSolve(data);
|
||||
return;
|
||||
case 'telemetry':
|
||||
logRow('meta', `collect:来源=${data.source} 长度=${data.collect_len} ` +
|
||||
`段几何=${data.segments ? JSON.stringify(data.segments) : '—'}` +
|
||||
(data.traj_points ? ` 轨迹点=${data.traj_points}` : '') +
|
||||
(data.segments_err ? ` ⚠ ${data.segments_err}` : ''));
|
||||
return;
|
||||
case 'verify':
|
||||
renderVerify(data);
|
||||
return;
|
||||
case 'job.finished':
|
||||
finishJob(data);
|
||||
return;
|
||||
case 'job.error':
|
||||
setState('error');
|
||||
logRow('ERROR', data.message || '任务异常');
|
||||
$('cancel').disabled = true;
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function setState(s) {
|
||||
const map = {
|
||||
pending: ['排队中', ''], running: ['执行中', 'running'], throttled: ['频控等待', 'warn'],
|
||||
done: ['已完成', 'ok'], error: ['失败', 'err'], cancelled: ['已取消', 'warn'],
|
||||
};
|
||||
const [text, cls] = map[s] || [s, ''];
|
||||
const el = $('state');
|
||||
el.textContent = text;
|
||||
el.className = 'badge ' + cls;
|
||||
}
|
||||
|
||||
function logRow(level, message) {
|
||||
const box = $('log');
|
||||
const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 30;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row ' + level;
|
||||
row.innerHTML = `<span class="t">${hhmmss()}</span><span class="m"></span>`;
|
||||
row.querySelector('.m').textContent = message;
|
||||
box.appendChild(row);
|
||||
if (atBottom) box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 图片卡片 */
|
||||
function addChallengeCard(d) {
|
||||
const wrap = $('images');
|
||||
const card = document.createElement('div');
|
||||
card.className = 'shot';
|
||||
const imgs = [];
|
||||
if (d.bg_url) imgs.push(`<img src="${d.bg_url}" alt="底图">`);
|
||||
if (d.prompt_url) imgs.push(`<img src="${d.prompt_url}" alt="提示图">`);
|
||||
card.innerHTML =
|
||||
`<div class="cap"><span>第 ${d.index} 题 · ${d.kind}</span>
|
||||
<span class="muted">${d.instruction || ''}</span></div>
|
||||
<div class="pair">${imgs.join('')}</div>
|
||||
<div class="pts" data-pts>求解中…</div>`;
|
||||
wrap.prepend(card); // 最新的题放最上面
|
||||
state.cards[d.index] = card;
|
||||
}
|
||||
|
||||
function fillSolve(d) {
|
||||
const card = state.cards[d.index];
|
||||
if (!card) return;
|
||||
const offs = (d.off_px || []).map((v) => (v === null ? '—' : v + 'px')).join(' / ');
|
||||
card.querySelector('[data-pts]').textContent =
|
||||
`点位(按点击顺序): ${JSON.stringify(d.points)} 离图标中心: ${offs}`;
|
||||
if (d.annotated_url) {
|
||||
const box = card.querySelector('.pair');
|
||||
const img = document.createElement('img');
|
||||
img.src = d.annotated_url;
|
||||
img.alt = '标注图';
|
||||
box.appendChild(img);
|
||||
}
|
||||
if (d.note) logRow('meta', `求解结论: ${d.note}`);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 结果渲染 */
|
||||
function renderVerify(d) {
|
||||
const info = ecInfo(d.errorCode);
|
||||
state.lastResult = d;
|
||||
$('result').innerHTML = `
|
||||
<div class="card">
|
||||
<div class="kv"><div class="k">errorCode</div>
|
||||
<div class="v ${info.cls}">${d.errorCode ?? '—'} <span style="font-size:12px">${info.text}</span></div></div>
|
||||
<div class="kv"><div class="k">耗时</div><div class="v">${fmtMs(d.elapsed_ms)}</div></div>
|
||||
<div class="kv"><div class="k">randstr</div><div class="v">${d.randstr || '—'}</div></div>
|
||||
<div class="kv"><div class="k">ticket</div><div class="v">${d.ticket || '—'}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function finishJob(d) {
|
||||
const info = ecInfo(d.errorCode);
|
||||
setState(d.success ? 'done' : (info.cls === 'ec12' ? 'error' : 'warn'));
|
||||
$('cancel').disabled = true;
|
||||
const st = d.stats || {};
|
||||
$('result').insertAdjacentHTML('beforeend', `
|
||||
<div class="card">
|
||||
<div class="kv"><div class="k">结果</div><div class="v ${info.cls}">${d.success ? '成功' : (d.errorCode ?? '失败')}</div></div>
|
||||
<div class="kv"><div class="k">抽题次数</div><div class="v">${st.challenges ?? '—'}</div></div>
|
||||
<div class="kv"><div class="k">点位总数</div><div class="v">${st.points ?? '—'}</div></div>
|
||||
<div class="kv"><div class="k">平均偏离中心</div><div class="v">${st.mean_offset_px ?? '—'}${st.mean_offset_px ? 'px' : ''}</div></div>
|
||||
</div>`);
|
||||
if (d.error) logRow('WARNING', d.error);
|
||||
// 本地起冷却(服务端也会节流,这里只是让按钮状态立刻正确)
|
||||
const t = state.targets.find((x) => x.key === $('target').value);
|
||||
if (t) state.cooldownUntil = Date.now() + t.min_interval * 1000;
|
||||
tickCooldown();
|
||||
loadHistory();
|
||||
}
|
||||
|
||||
function fmtMs(ms) {
|
||||
if (ms === null || ms === undefined) return '—';
|
||||
return ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : ms + 'ms';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 历史任务 */
|
||||
async function loadHistory() {
|
||||
const r = await fetch('/api/jobs').then((x) => x.json());
|
||||
const tb = $('history').querySelector('tbody');
|
||||
tb.innerHTML = '';
|
||||
for (const j of r.jobs) {
|
||||
const ec = j.result ? j.result.errorCode : null;
|
||||
const info = ecInfo(ec);
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td class="mono muted">${hhmmss(j.created_at)}</td>
|
||||
<td>${j.target}</td>
|
||||
<td class="mono">${j.params.kind}</td>
|
||||
<td class="mono ${info.cls}">${ec ?? '—'} <span class="muted" style="font-size:11px">${info.text}</span></td>
|
||||
<td class="mono">${fmtMs(j.result ? j.result.elapsed_ms : null)}</td>
|
||||
<td class="mono">${(j.result && j.result.success) ? '✓' : '—'}</td>
|
||||
<td class="mono muted">${(j.result && j.result.ticket) ? j.result.ticket.slice(0, 14) + '…' : '—'}</td>`;
|
||||
tr.onclick = () => replay(j.id);
|
||||
tb.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
/** 点历史行 → 重新订阅该任务的事件流,把整个过程重放一遍。 */
|
||||
function replay(jobId) {
|
||||
resetView();
|
||||
logRow('meta', `重放任务 ${jobId} …`);
|
||||
subscribe(jobId);
|
||||
$('cancel').disabled = true;
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
$('log').innerHTML = '';
|
||||
$('images').innerHTML = '';
|
||||
$('result').innerHTML = '<p class="muted">等待事件…</p>';
|
||||
state.cards = {};
|
||||
}
|
||||
|
||||
boot();
|
||||
@@ -0,0 +1,68 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>tcaptcha 观测台</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="brand">
|
||||
<h1>tcaptcha 观测台</h1>
|
||||
<span class="sub">腾讯天御验证码 · 纯协议求解 · 实时观测</span>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<label>靶子
|
||||
<select id="target"></select>
|
||||
</label>
|
||||
<label>题型
|
||||
<select id="kind">
|
||||
<option value="any">any 遇到啥过啥(推荐)</option>
|
||||
<option value="icon">icon 图标点选 2408</option>
|
||||
<option value="text">text 文字点选 2404</option>
|
||||
<option value="slider">slider 滑块 2401/2402</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="start" class="primary">开始一发</button>
|
||||
<button id="cancel" disabled>取消</button>
|
||||
<span id="cooldown" class="cooldown"></span>
|
||||
</div>
|
||||
<div id="targetNote" class="note"></div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="panel">
|
||||
<h2>执行轨迹 <span id="state" class="badge">空闲</span>
|
||||
<span id="jobId" class="mono muted"></span>
|
||||
</h2>
|
||||
<div id="log" class="log"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>题目 / 点位 / 结果</h2>
|
||||
<div id="result" class="result"><p class="muted">还没有任务。选好靶子与题型,点「开始一发」。</p></div>
|
||||
<div id="images" class="images"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<section class="panel wide">
|
||||
<h2>历史任务</h2>
|
||||
<table id="history">
|
||||
<thead>
|
||||
<tr><th>时间</th><th>靶子</th><th>题型</th><th>errorCode</th><th>耗时</th><th>点位</th><th>ticket</th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<footer class="legend">
|
||||
<span><b class="ec0">0</b> 通过</span>
|
||||
<span><b class="ec50">50 / 9</b> 答案被否(点位或目标错)</span>
|
||||
<span><b class="ec12">12</b> 包级拒答(轨迹指纹 / 频控)</span>
|
||||
<span class="muted">靶子带实测最小间隔,后端会强制节流</span>
|
||||
</footer>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,114 @@
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--panel: #171a21;
|
||||
--panel-2: #1e222b;
|
||||
--line: #2a2f3a;
|
||||
--fg: #e6e9ef;
|
||||
--muted: #8b93a3;
|
||||
--ok: #35c48a;
|
||||
--warn: #e5a13a;
|
||||
--err: #e05a5a;
|
||||
--accent: #4c8dff;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: 14px/1.55 -apple-system, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
padding: 16px 18px 40px;
|
||||
}
|
||||
|
||||
header { margin-bottom: 14px; }
|
||||
.brand { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||
h1 { font-size: 18px; margin: 0; letter-spacing: .3px; }
|
||||
h2 { font-size: 13px; margin: 0 0 10px; color: var(--muted); font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: .6px;
|
||||
display: flex; align-items: center; gap: 10px; }
|
||||
.sub { color: var(--muted); font-size: 12px; }
|
||||
|
||||
.controls { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
|
||||
.controls label { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 13px; }
|
||||
select, button {
|
||||
background: var(--panel-2); color: var(--fg);
|
||||
border: 1px solid var(--line); border-radius: 7px;
|
||||
padding: 7px 11px; font-size: 13px; font-family: inherit;
|
||||
}
|
||||
button { cursor: pointer; }
|
||||
button:hover:not(:disabled) { border-color: var(--accent); }
|
||||
button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
|
||||
button.primary:disabled { background: var(--panel-2); color: var(--fg); border-color: var(--line); }
|
||||
|
||||
.note { margin-top: 8px; font-size: 12.5px; color: var(--muted); }
|
||||
.cooldown { font-family: var(--mono); font-size: 12.5px; color: var(--warn); }
|
||||
.cooldown.ready { color: var(--ok); }
|
||||
|
||||
main { display: grid; grid-template-columns: minmax(380px, 1fr) minmax(420px, 1.2fr); gap: 14px; }
|
||||
@media (max-width: 1080px) { main { grid-template-columns: 1fr; } }
|
||||
|
||||
.panel {
|
||||
background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 11px; padding: 13px 14px; margin-bottom: 14px;
|
||||
}
|
||||
.panel.wide { margin-bottom: 14px; }
|
||||
|
||||
.badge {
|
||||
font-size: 11px; padding: 2px 9px; border-radius: 999px;
|
||||
background: var(--panel-2); color: var(--muted); border: 1px solid var(--line);
|
||||
font-weight: 500; text-transform: none; letter-spacing: 0;
|
||||
}
|
||||
.badge.running { color: var(--accent); border-color: var(--accent); }
|
||||
.badge.ok { color: var(--ok); border-color: var(--ok); }
|
||||
.badge.warn { color: var(--warn); border-color: var(--warn); }
|
||||
.badge.err { color: var(--err); border-color: var(--err); }
|
||||
|
||||
.log {
|
||||
font-family: var(--mono); font-size: 12px;
|
||||
height: 460px; overflow-y: auto;
|
||||
background: #0c0e12; border: 1px solid var(--line); border-radius: 8px;
|
||||
padding: 9px 11px;
|
||||
}
|
||||
.log .row { display: grid; grid-template-columns: 62px 1fr; gap: 9px; padding: 1px 0; }
|
||||
.log .t { color: #5a6376; }
|
||||
.log .m { white-space: pre-wrap; word-break: break-word; }
|
||||
.log .row.INFO .m { color: #cdd4e0; }
|
||||
.log .row.WARNING .m { color: var(--warn); }
|
||||
.log .row.ERROR .m { color: var(--err); }
|
||||
.log .row.meta .m { color: var(--muted); font-style: italic; }
|
||||
|
||||
.result { margin-bottom: 12px; }
|
||||
.result .card {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(155px, 1fr));
|
||||
gap: 9px; margin-top: 9px;
|
||||
}
|
||||
.result .kv {
|
||||
background: var(--panel-2); border: 1px solid var(--line);
|
||||
border-radius: 8px; padding: 8px 10px;
|
||||
}
|
||||
.result .kv .k { color: var(--muted); font-size: 11px; letter-spacing: .4px; }
|
||||
.result .kv .v { font-family: var(--mono); font-size: 14px; margin-top: 3px; word-break: break-all; }
|
||||
.v.ec0 { color: var(--ok); } .v.ec50 { color: var(--warn); }
|
||||
.v.ec12 { color: var(--err); } .v.ecother { color: var(--muted); }
|
||||
|
||||
.images { display: grid; gap: 11px; }
|
||||
.shot { background: var(--panel-2); border: 1px solid var(--line); border-radius: 9px; padding: 9px; }
|
||||
.shot .cap { display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 12px; color: var(--muted); margin-bottom: 7px; }
|
||||
.shot img { width: 100%; border-radius: 6px; display: block; background: #000; }
|
||||
.shot .pair { display: grid; grid-template-columns: 1fr; gap: 7px; }
|
||||
.shot .pts { font-family: var(--mono); font-size: 11.5px; color: #9fb0cc; margin-top: 6px; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||
th, td { text-align: left; padding: 7px 9px; border-bottom: 1px solid var(--line); }
|
||||
th { color: var(--muted); font-weight: 600; font-size: 11px; letter-spacing: .4px; }
|
||||
td.mono, .mono { font-family: var(--mono); }
|
||||
tbody tr:hover { background: var(--panel-2); cursor: pointer; }
|
||||
|
||||
.legend { display: flex; gap: 20px; flex-wrap: wrap; color: var(--muted); font-size: 12px; margin-top: 6px; }
|
||||
.legend b { font-family: var(--mono); padding: 1px 6px; border-radius: 5px; }
|
||||
.ec0 { color: var(--ok); } .ec50 { color: var(--warn); } .ec12 { color: var(--err); }
|
||||
.muted { color: var(--muted); }
|
||||
Reference in New Issue
Block a user