const state = { csrf: "", page: "overview" }; function $(id) { return document.getElementById(id); } async function api(path, options = {}) { const headers = Object.assign({ "Content-Type": "application/json" }, options.headers || {}); if (state.csrf && (options.method || "GET") !== "GET") headers["X-CSRF-Token"] = state.csrf; const res = await fetch(path, Object.assign({}, options, { headers, credentials: "same-origin" })); const body = await res.json(); if (!res.ok) { const msg = (body.error && body.error.message) || body.error || res.statusText; throw new Error(msg); } return body; } function show(id) { ["login-view", "change-view", "shell"].forEach((key) => { $(key).hidden = key !== id; }); } function esc(value) { return String(value ?? "").replace(/[&<>"]/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[ch])); } function table(headers, rows) { const thead = headers.map((h) => `${esc(h)}`).join(""); const body = rows.length ? rows.map((cols) => `${cols.map((c) => `${c}`).join("")}`).join("") : `暂无数据`; return `${thead}${body}
`; } async function boot() { try { const session = await api("/admin/api/session"); state.csrf = session.csrf; $("who").textContent = session.username; if (session.must_change) { show("change-view"); return; } show("shell"); await render(); } catch { show("login-view"); } } $("login-form").addEventListener("submit", async (event) => { event.preventDefault(); const form = new FormData(event.target); $("login-error").hidden = true; try { const result = await api("/admin/api/login", { method: "POST", body: JSON.stringify({ username: form.get("username"), password: form.get("password") }), }); state.csrf = result.csrf; if (result.must_change) show("change-view"); else { show("shell"); await render(); } } catch (err) { $("login-error").hidden = false; $("login-error").textContent = err.message; } }); $("change-form").addEventListener("submit", async (event) => { event.preventDefault(); const form = new FormData(event.target); try { await api("/admin/api/change-password", { method: "POST", body: JSON.stringify({ current: form.get("current"), new_password: form.get("new_password") }), }); show("shell"); await render(); } catch (err) { $("change-error").hidden = false; $("change-error").textContent = err.message; } }); $("logout-btn").addEventListener("click", async () => { await api("/admin/api/logout", { method: "POST", body: "{}" }); show("login-view"); }); $("theme-btn").addEventListener("click", () => { const root = document.documentElement; const next = root.getAttribute("data-theme") === "night" ? "" : "night"; if (next) root.setAttribute("data-theme", next); else root.removeAttribute("data-theme"); $("theme-btn").textContent = next ? "日间" : "夜间"; }); document.querySelectorAll("nav button").forEach((btn) => { btn.addEventListener("click", () => { document.querySelectorAll("nav button").forEach((item) => item.classList.remove("active")); btn.classList.add("active"); state.page = btn.dataset.page; render(); }); }); async function render() { const page = $("page"); if (state.page === "overview") { const data = await api("/admin/api/overview"); $("phase").textContent = data.session_phase; page.innerHTML = `
交易日
${esc(data.trade_date)}
阶段
${esc(data.session_phase)}
今日发布
${data.publications.length}
异常批次
${data.anomalies.length}

最近调用

${table(["时间", "源", "端点", "结果", "耗时"], data.recent_calls.map((row) => [ esc(row.created_at), esc(row.provider), esc(row.endpoint), row.ok ? '成功' : `${esc(row.error)}`, `${row.latency_ms ?? "-"} ms`, ]))} `; return; } if (state.page === "sources") { const data = await api("/admin/api/sources"); page.innerHTML = `

数据源

` + table( ["源", "角色", "状态", "凭据", "操作"], data.items.map((item) => { const cred = item.credential || {}; const credText = cred.configured ? `已配置 · ${esc(cred.last4 || "****")}` : "未配置"; return [ esc(item.provider), esc(item.role), esc((item.health && (item.health.state || item.health.status)) || "-"), credText, ``, ]; }), ); page.querySelectorAll("[data-probe]").forEach((btn) => { btn.addEventListener("click", async () => { const result = await api(`/admin/api/sources/${btn.dataset.probe}/probe`, { method: "POST", body: "{}" }); alert(JSON.stringify(result)); render(); }); }); return; } if (state.page === "jobs") { const data = await api("/admin/api/jobs"); page.innerHTML = `

调度任务

${table(["任务", "时刻", "操作"], data.jobs.map((job) => [ `${esc(job.id)} · ${esc(job.title)}`, esc(job.at), ``, ]))}

最近运行

${table(["ID", "任务", "状态", "开始", "结束", "错误"], data.runs.map((row) => [ row.id, esc(row.job_id), esc(row.state), esc(row.started_at), esc(row.finished_at), esc(row.error), ]))} `; page.querySelectorAll("[data-run]").forEach((btn) => { btn.addEventListener("click", async () => { const date = prompt("交易日 YYYYMMDD(可留空=今天)", "") || ""; await api(`/admin/api/jobs/${btn.dataset.run}/run`, { method: "POST", body: JSON.stringify({ trade_date: date }) }); render(); }); }); return; } if (state.page === "release") { const date = new Date().toISOString().slice(0, 10).replace(/-/g, ""); const data = await api(`/admin/api/batches?date=${date}`); page.innerHTML = `

盘后发布 ${esc(data.trade_date)}

当前映射

${table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], data.publications.map((row) => [ esc(row.dataset), esc(row.active_batch), esc(row.prev_batch), esc(row.state), esc(row.published_at), row.prev_batch ? `` : "-", ]))}

批次

${table(["batch_id", "数据集", "状态", "行数", "错误"], data.batches.map((row) => [ esc(row.batch_id), esc(row.dataset), esc(row.state), row.rows_out ?? "", esc(row.error), ]))} `; $bindRelease(page); return; } if (state.page === "datasets") { const data = await api("/admin/api/datasets?date="); page.innerHTML = `

数据集 / 质量 ${esc(data.trade_date)}

${table(["数据集", "批次", "状态", "发布时间"], data.publications.map((row) => [ esc(row.dataset), esc(row.active_batch), esc(row.state), esc(row.published_at), ]))}

源间差异

${table(["指标", "左", "右", "偏差", "样本"], data.diff_reports.map((row) => [ esc(row.metric), esc(row.left_value), esc(row.right_value), esc(row.deviation), row.sample_count ?? "", ]))} `; return; } if (state.page === "audit") { const data = await api("/admin/api/audit"); page.innerHTML = `

审计

` + table( ["时间", "操作者", "动作", "对象", "详情"], data.items.map((row) => [esc(row.created_at), esc(row.actor), esc(row.action), esc(row.target), esc(row.detail)]), ); } } function $bindRelease(page) { page.querySelector("#rel-load").addEventListener("click", async () => { const date = page.querySelector("#rel-date").value; const data = await api(`/admin/api/batches?date=${encodeURIComponent(date)}`); state.page = "release"; // re-render with fetched date by writing location hash history.replaceState(null, "", `#release-${date}`); $("page").innerHTML = renderRelease(data); $bindRelease($("page")); }); page.querySelector("#rel-backfill").addEventListener("click", () => dangerous("backfill")); page.querySelectorAll("[data-rollback]").forEach((btn) => { btn.addEventListener("click", () => dangerous("rollback", btn.dataset.rollback)); }); } function renderRelease(data) { return `

盘后发布 ${esc(data.trade_date)}

当前映射

${table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], data.publications.map((row) => [ esc(row.dataset), esc(row.active_batch), esc(row.prev_batch), esc(row.state), esc(row.published_at), row.prev_batch ? `` : "-", ]))}

批次

${table(["batch_id", "数据集", "状态", "行数", "错误"], data.batches.map((row) => [ esc(row.batch_id), esc(row.dataset), esc(row.state), row.rows_out ?? "", esc(row.error), ]))} `; } async function dangerous(kind, dataset) { const date = ($("rel-date") && $("rel-date").value) || ""; const ds = dataset || prompt("数据集(daily / valuation / moneyflow / auction / index_daily / reference)", "daily"); if (!ds) return; const password = prompt("二次确认:输入管理密码"); if (!password) return; const confirmWord = `${ds}:${date}`; const typed = prompt(`请输入确认词:${confirmWord}`); const path = kind === "rollback" ? "/admin/api/rollback" : "/admin/api/backfill"; await api(path, { method: "POST", body: JSON.stringify({ dataset: ds, trade_date: date, password, confirm: typed }), }); render(); } boot();