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 ``;
}
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();