"use strict";
/* ==========================================================================
A · 装配线 Flowline —— 来源 → 加工 → 发布 → 审计 四工位水平因果链。
数据卡是线上工件,真实事件沿发丝导线从触发的那一工位接力到下一工位。
========================================================================== */
(function () {
const C = window.Core, S = window.HubShared;
const { $, esc, timeShort } = C;
let root = null;
let unsubs = [];
let stationFlashTimers = {};
let mounted = false;
let pendingTimers = [];
/* 布局随时可能被切走:任何跨阶段延时回调都必须先确认还挂载着,
否则 unmount 之后残留的 setTimeout 会在 root=null 时报错。 */
function safeTimeout(fn, ms) {
const id = setTimeout(() => { if (mounted) fn(); }, ms);
pendingTimers.push(id);
return id;
}
const SKELETON = `
来源 SOURCES
加工 PROCESS
发布 PUBLISH
审计 AUDIT
›››
盘后发布 RELEASE
当前映射 MAPPING
`;
function srcCardHtml(item) {
const health = item.health || {};
const link = item._link || C.linkState(health);
const cred = item.credential || {};
const credText = cred.configured ? `已配置 · ${esc(cred.last4 || "****")}` : "未配置";
return `
${esc(C.FLOW_LABEL[item.provider] || item.provider)}
${esc(C.FLOW_ROLE[item.provider] || item.role)}
${C.chipHtml(link, health.state || link)}
LINK
ACT
${health.latency_ms == null ? "-" : health.latency_ms + "ms"}
${credText}
`;
}
function renderSources() {
const v = S.sourcesView();
const host = $("fl-srcCards");
if (!v) { host.innerHTML = `正在加载…
`; return; }
host.innerHTML = v.live.map(srcCardHtml).join("") +
(v.reserved.length ? `预留源 ${v.reserved.length} 个 · 未接入 · 无真实调用
` : "");
S.bindProbeButtons(host);
}
function renderProcess() {
const jv = S.jobsView();
const body = $("fl-processBody");
const pctEl = root.querySelector('[data-pct]');
if (!jv || !jv.latest) { body.innerHTML = `正在加载…
`; return; }
const latest = jv.latest;
const running = jv.runs.filter((r) => r.state === "running").length;
const pct = latest.state === "running" ? 62 : latest.state === "success" ? 100 : latest.state === "failed" ? 100 : 0;
pctEl.textContent = latest.state === "running" ? `${pct}%` : "空闲";
body.innerHTML = `
${esc(latest.job_id)} · ${esc((jv.jobs.find((j) => j.id === latest.job_id) || {}).title || "")}
队列 ${running} · 最近 20 次失败 ${jv.failedRecent}
${jv.runs.slice(0, 3).map((r) => `
${r.id}${esc(r.state)}${esc(r.error || (r.finished_at !== "-" && r.finished_at ? timeShort(r.finished_at) : "—"))}
`).join("")}
`;
}
function renderPublish() {
const rv = S.releaseView();
const body = $("fl-publishBody");
if (!rv) { body.innerHTML = `正在加载…
`; return; }
const valuation = rv.pubs.find((p) => p.dataset === "valuation");
const latestPub = rv.pubs.slice().sort((a, b) => String(a.published_at).localeCompare(String(b.published_at))).pop();
body.innerHTML = `
${rv.pubs.length}今日发布
最新批次 ${esc(latestPub ? `${latestPub.active_batch} · ${latestPub.published_at}` : "暂无")}
${valuation ? `valuation${C.chipHtml(valuation.state)}
` : ""}`;
}
function renderAudit() {
const av = S.auditView();
const body = $("fl-auditBody");
if (!av || !av.items.length) { body.innerHTML = `正在加载…
`; return; }
const a0 = av.items[0];
body.innerHTML = `
${esc(timeShort(a0.created_at))} ${esc(a0.actor)} · ${esc(a0.action)}
${esc(a0.target)}${a0.detail ? " · " + esc(a0.detail) : ""}
${av.items.slice(1, 3).map((a) => `${esc(timeShort(a.created_at))} ${esc(a.actor)} · ${esc(a.action)}
`).join("")}`;
}
function renderCalls() {
const ov = S.overviewView();
const sv = S.sourcesView();
if (!ov) { $("fl-callsTable").innerHTML = `正在加载…
`; return; }
$("fl-callsTable").innerHTML = C.table(["时间", "源", "端点", "结果", "耗时"], ov.recentCalls.map((row) => [
`${esc(timeShort(row.created_at))}`,
`${esc(row.provider)}`,
`${esc(row.endpoint)}`,
row.ok ? '成功' : `${esc(row.error)}`,
`${row.latency_ms ?? "-"} ms`,
]));
if (sv) {
$("fl-callsFoot").textContent = "今日调用 · " + sv.live.map((s) => `${s.provider} ${s.calls_today ?? 0}`).join(" · ");
}
}
function renderJobsCol() {
const jv = S.jobsView();
const jobRows = $("fl-jobRows");
if (!jv) { jobRows.innerHTML = `正在加载…
`; return; }
jobRows.innerHTML = jv.jobs.map((job) => `
${esc(job.id)}${esc(job.title)}
${esc(job.at)}${C.chipHtml(jv.latestByJob.get(job.id) ? jv.latestByJob.get(job.id).state : "idle")}
`).join("");
S.bindRunButtons(jobRows);
$("fl-runsTable").innerHTML = C.table(["ID", "任务", "状态", "开始", "结束", "错误"], jv.runs.map((r) => [
`${r.id}`, `${esc(r.job_id)}`,
`${esc(r.state)}`,
`${esc(timeShort(r.started_at))}`, `${esc(timeShort(r.finished_at))}`,
`${esc(r.error || "")}`,
]));
}
function renderReleaseCol() {
const rv = S.releaseView();
const dateInput = $("fl-rel-date");
if (!dateInput.value) dateInput.value = C.state.releaseDate;
if (!rv) { $("fl-pubTable").innerHTML = `正在加载…
`; return; }
$("fl-pubTable").innerHTML = C.table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], rv.pubs.map((p) => [
`${esc(p.dataset)}`, `${esc(p.active_batch)}`,
`${esc(p.prev_batch || "-")}`, C.chipHtml(p.state),
`${esc(p.published_at)}`,
p.prev_batch ? `` : "-",
]));
S.bindRollbackButtons($("fl-pubTable"), () => renderReleaseCol());
const failed = rv.batches.filter((b) => b.state === "failed" || b.state === "error");
$("fl-anom").innerHTML = failed.length
? failed.map((b) => `${esc(b.batch_id)} · ${esc(b.dataset)} · ${esc(b.error || "异常")}`).join(" ")
: `批次全部正常`;
}
function refresh() {
if (!root) return;
renderSources(); renderProcess(); renderPublish(); renderAudit();
renderCalls(); renderJobsCol(); renderReleaseCol();
}
/* ---------------------------------------------------------------- 真实事件动效
水平接力:source ACT 闪 → 沿导线光梭滑到 process → process 高亮 → 光梭滑到
publish → publish 高亮 → 光梭滑到 audit → audit 高亮。reduced 时只做状态高亮,
不做位移。 */
function flashSource(provider, kind) {
const card = root.querySelector(`.fl-src-card[data-provider="${provider}"]`);
if (!card) return;
const act = card.querySelector("[data-act]");
act.classList.remove("on", "err");
act.classList.add("on"); if (kind === "error" || kind === "rollback") act.classList.add("err");
const edge = card.querySelector(".edge");
edge.classList.toggle("err", kind === "error");
edge.style.opacity = "1";
clearTimeout(card._flashTimer);
card._flashTimer = setTimeout(() => { act.classList.remove("on", "err"); edge.style.opacity = "0"; }, 820);
return card;
}
function pulseStation(key, ms) {
const el = root.querySelector(`.fl-station[data-station="${key}"]`);
if (!el) return;
el.classList.add("pulse");
clearTimeout(stationFlashTimers[key]);
stationFlashTimers[key] = setTimeout(() => el.classList.remove("pulse"), ms || 640);
}
function travelPacket(fromEl, toEl, color) {
if (!fromEl || !toEl || C.state.reduced) return;
const pk = $("fl-packet");
const band = $("fl-band").getBoundingClientRect();
const a = fromEl.getBoundingClientRect(), b = toEl.getBoundingClientRect();
const x0 = a.left + a.width / 2 - band.left, y0 = a.top + a.height / 2 - band.top;
const x1 = b.left + b.width / 2 - band.left, y1 = b.top + b.height / 2 - band.top;
pk.style.background = color || "var(--packet)";
pk.style.top = y0 - 1.5 + "px"; pk.style.left = x0 - 17 + "px"; pk.style.opacity = "0";
if (pk._anim) pk._anim.cancel();
pk._anim = pk.animate([
{ transform: "translate(0,0)", opacity: 0 },
{ transform: "translate(0,0)", opacity: 1, offset: .08 },
{ transform: `translate(${x1 - x0}px, ${y1 - y0}px)`, opacity: 1, offset: .92 },
{ transform: `translate(${x1 - x0}px, ${y1 - y0}px)`, opacity: 0 },
], { duration: 640, easing: "linear" });
pk._anim.onfinish = () => { pk.style.opacity = "0"; };
}
function onEvent(evt) {
if (!root || !mounted) return;
const process = root.querySelector('.fl-station[data-station="process"]');
const publish = root.querySelector('.fl-station[data-station="publish"]');
const audit = root.querySelector('.fl-station[data-station="audit"]');
if (C.FLOW_PROVIDERS.includes(evt.channel)) {
const card = flashSource(evt.channel, evt.kind);
safeTimeout(() => { travelPacket(card, process, evt.kind === "error" ? "var(--error)" : "var(--packet)"); pulseStation("process", 700); }, 260);
renderCalls();
} else if (evt.channel === "junction") {
pulseStation("process", 700);
safeTimeout(() => { travelPacket(process, publish, "var(--packet)"); pulseStation("publish", 700); renderProcess(); }, 260);
} else if (evt.channel === "tx") {
pulseStation("publish", 700);
safeTimeout(() => { travelPacket(publish, audit, evt.kind === "rollback" ? "var(--error)" : "var(--packet)"); pulseStation("audit", 700); renderPublish(); renderReleaseCol(); }, 260);
} else if (evt.channel === "audit") {
pulseStation("audit", 700);
renderAudit();
}
}
function mount(el) {
root = el;
mounted = true;
root.innerHTML = SKELETON;
refresh();
S.bindReleaseDateReload(root, "#fl-rel-date", "#fl-rel-load", () => renderReleaseCol());
S.bindBackfillButton(root, "#fl-rel-backfill", () => renderReleaseCol());
unsubs.push(C.Bus.on("data", refresh));
unsubs.push(C.Bus.on("event", onEvent));
}
function unmount() {
mounted = false;
unsubs.forEach((fn) => fn()); unsubs = [];
Object.values(stationFlashTimers).forEach(clearTimeout); stationFlashTimers = {};
pendingTimers.forEach(clearTimeout); pendingTimers = [];
root = null;
}
window.HUB_LAYOUTS = window.HUB_LAYOUTS || {};
window.HUB_LAYOUTS.flowline = { mount, unmount };
})();