- 基于第七版稳定基线(be647bb)重构 admin 前端为模块化架构:
tokens.css(设计token唯一来源) + shared.css(公共组件) + core.js(状态/API/事件总线/轮询/路由)
+ main.js(入口) + layouts/{flowline,ledger,strata}.{css,js}(三方向独立布局)
+ layouts/shared.js(三方向共用数据视图与操作绑定)
- 三个页面(A装配线/B值班台账/C地层剖面)通过 ?layout= 查询参数独立可达、可刷新、可前进后退导航,
共享登录态、真实后端数据(overview/sources/jobs/batches/datasets/audit)、错误处理与主题
- 六大板块(总览/数据源/调度任务/盘后发布/数据集/审计)在三个方向均可查看与操作(探测/触发/回滚/补数)
- 修正健康状态语义:CircuitBreaker closed/half_open/open 与适配器 ok/error/empty/unconfigured/unknown
统一归一化为 ok/warn/error/unconfigured/unknown 供三方向一致展示
- 事件驱动动效:A 水平接力光梭、B 行级高亮+实时调用跑马灯、C 纵向贯穿光点+分层标记;
统一使用 transform/opacity/WAAPI,避免 transition:all,支持 prefers-reduced-motion 静态降级
- 修复响应式布局在 1024/1280 断点因 CSS Grid 默认 min-width:auto 被内部宽表格撑爆轨道的问题
- 修复布局切换/前进后退时残留 setTimeout 回调在 unmount 后访问 null root 导致的报错(mounted 标志位+
safeTimeout+统一清理定时器)
- 自测:Playwright 全断点(1440/1280/1024)x 双主题矩阵截图、90 次连续布局切换+前进后退压力测试无报错、
探测/触发/回滚danger操作流程验证、reduced-motion 验证;后端 pytest 全量 133 用例通过,无回归
- 未改动:登录/鉴权契约、后端 API、'问天'冻结区、生产数据/权限
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
292 lines
13 KiB
JavaScript
292 lines
13 KiB
JavaScript
"use strict";
|
||
/* ==========================================================================
|
||
B · 值班台账 Ledger Desk —— 表格本身就是主舞台,每条真实事件一行。
|
||
响应/汇入/处理/发布/审计五列只填该行真实具备的阶段,没有发生的阶段留空,
|
||
不做跨行编造关联。底部调用带只滚真实 recent_calls。
|
||
========================================================================== */
|
||
(function () {
|
||
const C = window.Core, S = window.HubShared;
|
||
const { $, esc, timeShort } = C;
|
||
let root = null;
|
||
let unsubs = [];
|
||
let seenRowKeys = new Set();
|
||
let mounted = false;
|
||
let pendingTimers = [];
|
||
function safeTimeout(fn, ms) {
|
||
const id = setTimeout(() => { if (mounted) fn(); }, ms);
|
||
pendingTimers.push(id);
|
||
return id;
|
||
}
|
||
|
||
const SKELETON = `
|
||
<div id="lg-root" class="page-shell">
|
||
<div id="lg-grid">
|
||
<div id="lg-side">
|
||
<section class="card lg-panel">
|
||
<span class="sec-label">来源健康 SOURCES</span>
|
||
<div id="lg-srcList"></div>
|
||
</section>
|
||
<section class="card lg-panel">
|
||
<span class="sec-label">调度任务 JOBS</span>
|
||
<div id="lg-jobList"></div>
|
||
</section>
|
||
</div>
|
||
|
||
<section class="card" id="lg-ledger">
|
||
<div class="lg-ledger-hd">
|
||
<span class="sec-label">事件台账 EVENT LEDGER</span>
|
||
<span class="hint">每条事件依次经过 响应→汇入→处理→发布→审计</span>
|
||
</div>
|
||
<div style="overflow:auto; max-height: 62vh;">
|
||
<table class="lg-ledger">
|
||
<thead><tr>
|
||
<th>时间</th><th>事件</th><th>来源</th>
|
||
<th class="stage">响应<br/><span style="opacity:.6">RESP</span></th>
|
||
<th class="stage">汇入<br/><span style="opacity:.6">INGEST</span></th>
|
||
<th class="stage">处理<br/><span style="opacity:.6">PROC</span></th>
|
||
<th class="stage">发布<br/><span style="opacity:.6">PUB</span></th>
|
||
<th class="stage">审计<br/><span style="opacity:.6">AUDIT</span></th>
|
||
</tr></thead>
|
||
<tbody id="lg-ledgerBody"></tbody>
|
||
</table>
|
||
</div>
|
||
<div id="lg-tape">
|
||
<span class="sec-label">调用带 CALL TAPE</span>
|
||
<div id="lg-tape-track"><div id="lg-tape-inner"></div></div>
|
||
</div>
|
||
</section>
|
||
|
||
<div id="lg-side-r">
|
||
<section class="card lg-panel" id="lg-release">
|
||
<span class="sec-label">盘后发布 RELEASE</span>
|
||
<div class="toolrow">
|
||
<input id="lg-rel-date" value="" />
|
||
<button type="button" class="btn mini" id="lg-rel-load">查看</button>
|
||
<button type="button" class="btn danger mini" id="lg-rel-backfill">补数</button>
|
||
</div>
|
||
<div id="lg-pubTable"></div>
|
||
</section>
|
||
<section class="card lg-panel">
|
||
<span class="sec-label">审计 AUDIT</span>
|
||
<div id="lg-auditList"></div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
function renderSources() {
|
||
const v = S.sourcesView();
|
||
const host = $("lg-srcList");
|
||
if (!v) { host.innerHTML = `<div class="muted">正在加载…</div>`; return; }
|
||
host.innerHTML = v.live.map((it) => {
|
||
const health = it.health || {};
|
||
const link = it._link || C.linkState(health);
|
||
return `<div class="lg-src-row" data-provider="${esc(it.provider)}">
|
||
<span class="lamps"><i class="lamp link ${C.linkLampClass(link)}"></i><i class="lamp act" data-act></i></span>
|
||
<span class="nm">${esc(C.FLOW_LABEL[it.provider] || it.provider)}</span>
|
||
${C.chipHtml(link, health.state || link)}
|
||
<span class="lat mono">${health.latency_ms == null ? "-" : health.latency_ms + "ms"}</span>
|
||
<button class="btn ghost mini" data-probe="${esc(it.provider)}">探测</button>
|
||
</div>`;
|
||
}).join("");
|
||
S.bindProbeButtons(host, () => renderSources());
|
||
}
|
||
|
||
function renderJobs() {
|
||
const v = S.jobsView();
|
||
const host = $("lg-jobList");
|
||
if (!v) { host.innerHTML = `<div class="muted">正在加载…</div>`; return; }
|
||
host.innerHTML = v.jobs.map((job) => {
|
||
const latest = v.latestByJob.get(job.id);
|
||
return `<div class="lg-jobline">
|
||
${C.chipHtml(latest ? latest.state : "idle")}
|
||
<span class="nm mono">${esc(job.id)}</span>
|
||
<span class="at mono">${esc(job.at)}</span>
|
||
<button class="btn ghost mini" data-run="${esc(job.id)}">触发</button>
|
||
</div>`;
|
||
}).join("");
|
||
S.bindRunButtons(host, () => renderJobs());
|
||
}
|
||
|
||
function renderRelease() {
|
||
const v = S.releaseView();
|
||
const dateInput = $("lg-rel-date");
|
||
if (!dateInput.value) dateInput.value = C.state.releaseDate;
|
||
if (!v) { $("lg-pubTable").innerHTML = `<div class="muted">正在加载…</div>`; return; }
|
||
$("lg-pubTable").innerHTML = C.table(["数据集", "批次", "状态", "时间", ""], v.pubs.map((p) => [
|
||
`<span class="mono k">${esc(p.dataset)}</span>`, `<span class="mono">${esc(p.active_batch)}</span>`,
|
||
C.chipHtml(p.state), `<span class="mono">${esc(p.published_at)}</span>`,
|
||
p.prev_batch ? `<button class="btn danger mini" data-rollback="${esc(p.dataset)}">回滚</button>` : "-",
|
||
]));
|
||
S.bindRollbackButtons($("lg-pubTable"), () => renderRelease());
|
||
}
|
||
|
||
function renderAuditList() {
|
||
const v = S.auditView();
|
||
const host = $("lg-auditList");
|
||
if (!v) { host.innerHTML = `<div class="muted">正在加载…</div>`; return; }
|
||
host.innerHTML = v.items.slice(0, 10).map((a) => `
|
||
<div class="lg-auditline mono">
|
||
<div>${esc(timeShort(a.created_at))} <b>${esc(a.actor)}</b> <span class="${String(a.action).includes("rollback") ? "warn" : ""}">${esc(a.action)}</span></div>
|
||
<div class="dim">${esc(a.target)}${a.detail ? " · " + esc(a.detail) : ""}</div>
|
||
</div>`).join("");
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- 台账行合成
|
||
每行只填该事件类型真实具备的阶段;不同类型互不编造对方的字段。 */
|
||
function cell(text, kind) {
|
||
if (text == null || text === "") return `<span class="lg-cell dash">—</span>`;
|
||
return `<span class="lg-cell ${kind || ""}">${esc(text)}</span>`;
|
||
}
|
||
|
||
function buildLedgerRows() {
|
||
const ov = S.overviewView(), jv = S.jobsView(), rv = S.releaseView(), av = S.auditView();
|
||
const rows = [];
|
||
if (ov) {
|
||
for (const c of ov.recentCalls) {
|
||
rows.push({
|
||
key: `call:${c.id}`, t: c.created_at, name: c.endpoint, sub: "来源响应", source: c.provider,
|
||
resp: cell(timeShort(c.created_at), c.ok ? "ok" : "err"),
|
||
ingest: c.ok ? cell(timeShort(c.created_at), "ok") : cell(),
|
||
proc: cell(), pub: cell(), audit: cell(),
|
||
err: !c.ok,
|
||
});
|
||
}
|
||
}
|
||
if (jv) {
|
||
for (const r of jv.runs.slice(0, 30)) {
|
||
const job = jv.jobs.find((j) => j.id === r.job_id);
|
||
rows.push({
|
||
key: `run:${r.id}`, t: r.started_at, name: (job && job.title) || r.job_id, sub: r.job_id, source: "system",
|
||
resp: cell(), ingest: cell(timeShort(r.started_at), "ok"),
|
||
proc: r.state === "running" ? cell("running", "warn") : r.state === "failed" ? cell(r.error || "failed", "err") : cell(timeShort(r.finished_at), "ok"),
|
||
pub: cell(), audit: cell(),
|
||
err: r.state === "failed",
|
||
});
|
||
}
|
||
}
|
||
if (rv) {
|
||
for (const p of rv.pubs) {
|
||
if (!p.published_at || p.published_at === "-") continue;
|
||
rows.push({
|
||
key: `pub:${p.dataset}:${p.active_batch}`, t: p.published_at, name: `批次发布 · ${p.dataset}`, sub: p.active_batch, source: "system",
|
||
resp: cell(), ingest: cell(), proc: cell(),
|
||
pub: cell(timeShort(p.published_at), p.state === "published" ? "ok" : "warn"),
|
||
audit: cell(),
|
||
err: p.state === "missing" || p.state === "error",
|
||
});
|
||
}
|
||
}
|
||
if (av) {
|
||
for (const a of av.items.slice(0, 30)) {
|
||
const isRollback = String(a.action).includes("rollback");
|
||
const isProbe = a.action === "probe";
|
||
rows.push({
|
||
key: `audit:${a.id}`, t: a.created_at, name: a.action, sub: a.target, source: a.actor,
|
||
resp: isProbe ? cell(timeShort(a.created_at), String(a.detail).includes("失败") ? "err" : "ok") : cell(),
|
||
ingest: cell(), proc: cell(),
|
||
pub: isRollback ? cell(timeShort(a.created_at), "warn") : cell(),
|
||
audit: cell(timeShort(a.created_at), isRollback ? "warn" : "ok"),
|
||
err: isRollback,
|
||
});
|
||
}
|
||
}
|
||
rows.sort((a, b) => String(b.t).localeCompare(String(a.t)));
|
||
return rows.slice(0, 60);
|
||
}
|
||
|
||
function renderLedger() {
|
||
const rows = buildLedgerRows();
|
||
const body = $("lg-ledgerBody");
|
||
body.innerHTML = rows.map((r) => `
|
||
<tr class="lg-row ${seenRowKeys.has(r.key) ? "" : "flashnew"} ${r.err ? "err" : ""}" data-key="${esc(r.key)}">
|
||
<td class="mono">${esc(timeShort(r.t))}</td>
|
||
<td class="ev"><div class="nm">${esc(r.name)}</div><div class="sub">${esc(r.sub || "")}</div></td>
|
||
<td class="src mono">${esc(r.source)}</td>
|
||
<td class="stage">${r.resp}</td>
|
||
<td class="stage">${r.ingest}</td>
|
||
<td class="stage">${r.proc}</td>
|
||
<td class="stage">${r.pub}</td>
|
||
<td class="stage">${r.audit}</td>
|
||
</tr>`).join("");
|
||
rows.forEach((r) => seenRowKeys.add(r.key));
|
||
if (!C.state.reduced) {
|
||
body.querySelectorAll("tr.flashnew").forEach((tr) => {
|
||
setTimeout(() => tr.classList.remove("flashnew"), 1000);
|
||
});
|
||
} else {
|
||
body.querySelectorAll("tr.flashnew").forEach((tr) => tr.classList.remove("flashnew"));
|
||
}
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- 调用带:只滚真实 recent_calls */
|
||
function renderTape() {
|
||
const ov = S.overviewView();
|
||
const inner = $("lg-tape-inner");
|
||
if (!ov || !ov.recentCalls.length) { inner.innerHTML = `<span class="lg-tape-item muted">暂无真实调用</span>`; inner.style.animation = "none"; return; }
|
||
const items = ov.recentCalls.slice(0, 16).map((c) => `<span class="lg-tape-item ${c.ok ? "" : "err"}">${esc(timeShort(c.created_at))} <b>${esc(c.provider)}</b> ${esc(c.endpoint)} ${c.ok ? "✓" : "× " + esc(c.error)}</span>`).join("");
|
||
inner.innerHTML = items + items; // 首尾拼接形成无缝循环,内容仍全部来自真实调用
|
||
if (C.state.reduced) { inner.style.animation = "none"; return; }
|
||
const width = inner.scrollWidth / 2;
|
||
inner.style.animation = "none";
|
||
void inner.offsetWidth;
|
||
inner.style.setProperty("--tape-w", `-${width}px`);
|
||
inner.style.animation = `lg-tape-scroll ${Math.max(12, width / 40)}s linear infinite`;
|
||
}
|
||
if (!document.getElementById("lg-tape-keyframes")) {
|
||
const style = document.createElement("style");
|
||
style.id = "lg-tape-keyframes";
|
||
style.textContent = `@keyframes lg-tape-scroll { from { transform: translateX(0); } to { transform: translateX(var(--tape-w, -800px)); } }`;
|
||
document.head.appendChild(style);
|
||
}
|
||
|
||
function updateTapePauseState() {
|
||
const el = $("lg-tape");
|
||
if (!el) return;
|
||
el.classList.toggle("paused", !C.Poller.runtimeAvailable() || C.state.reduced);
|
||
}
|
||
|
||
function refresh() {
|
||
if (!root) return;
|
||
renderSources(); renderJobs(); renderRelease(); renderAuditList(); renderLedger(); renderTape(); updateTapePauseState();
|
||
}
|
||
|
||
function onEvent(evt) {
|
||
if (!root || !mounted) return;
|
||
if (C.FLOW_PROVIDERS.includes(evt.channel)) {
|
||
const row = root.querySelector(`.lg-src-row[data-provider="${evt.channel}"] [data-act]`);
|
||
if (row) {
|
||
row.classList.remove("on", "err");
|
||
row.classList.add("on"); if (evt.kind === "error") row.classList.add("err");
|
||
setTimeout(() => row.classList.remove("on", "err"), 800);
|
||
}
|
||
}
|
||
renderLedger();
|
||
renderTape();
|
||
}
|
||
|
||
function mount(el) {
|
||
root = el;
|
||
mounted = true;
|
||
seenRowKeys = new Set();
|
||
root.innerHTML = SKELETON;
|
||
refresh();
|
||
S.bindReleaseDateReload(root, "#lg-rel-date", "#lg-rel-load", () => renderRelease());
|
||
S.bindBackfillButton(root, "#lg-rel-backfill", () => renderRelease());
|
||
unsubs.push(C.Bus.on("data", refresh));
|
||
unsubs.push(C.Bus.on("event", onEvent));
|
||
unsubs.push(C.Bus.on("runtime", updateTapePauseState));
|
||
unsubs.push(C.Bus.on("reduced", () => { renderTape(); updateTapePauseState(); }));
|
||
}
|
||
function unmount() {
|
||
mounted = false;
|
||
unsubs.forEach((fn) => fn()); unsubs = [];
|
||
pendingTimers.forEach(clearTimeout); pendingTimers = [];
|
||
root = null;
|
||
}
|
||
|
||
window.HUB_LAYOUTS = window.HUB_LAYOUTS || {};
|
||
window.HUB_LAYOUTS.ledger = { mount, unmount };
|
||
})();
|