新增独立 xiaobai-datahub 服务(SQLite WAL、Tushare 盘后发布、/v1 契约和管理后台),不改现站页面与数据链路。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
269 lines
11 KiB
JavaScript
269 lines
11 KiB
JavaScript
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) => `<th>${esc(h)}</th>`).join("");
|
||
const body = rows.length
|
||
? rows.map((cols) => `<tr>${cols.map((c) => `<td>${c}</td>`).join("")}</tr>`).join("")
|
||
: `<tr><td colspan="${headers.length}">暂无数据</td></tr>`;
|
||
return `<table><thead><tr>${thead}</tr></thead><tbody>${body}</tbody></table>`;
|
||
}
|
||
|
||
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 = `
|
||
<div class="cards">
|
||
<div class="card"><div class="muted">交易日</div><strong>${esc(data.trade_date)}</strong></div>
|
||
<div class="card"><div class="muted">阶段</div><strong>${esc(data.session_phase)}</strong></div>
|
||
<div class="card"><div class="muted">今日发布</div><strong>${data.publications.length}</strong></div>
|
||
<div class="card"><div class="muted">异常批次</div><strong class="${data.anomalies.length ? "fail" : "ok"}">${data.anomalies.length}</strong></div>
|
||
</div>
|
||
<h2>最近调用</h2>
|
||
${table(["时间", "源", "端点", "结果", "耗时"], data.recent_calls.map((row) => [
|
||
esc(row.created_at), esc(row.provider), esc(row.endpoint),
|
||
row.ok ? '<span class="ok">成功</span>' : `<span class="fail">${esc(row.error)}</span>`,
|
||
`${row.latency_ms ?? "-"} ms`,
|
||
]))}
|
||
`;
|
||
return;
|
||
}
|
||
if (state.page === "sources") {
|
||
const data = await api("/admin/api/sources");
|
||
page.innerHTML = `<h2>数据源</h2>` + 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,
|
||
`<button data-probe="${esc(item.provider)}">探测一次</button>`,
|
||
];
|
||
}),
|
||
);
|
||
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 = `
|
||
<h2>调度任务</h2>
|
||
${table(["任务", "时刻", "操作"], data.jobs.map((job) => [
|
||
`${esc(job.id)} · ${esc(job.title)}`, esc(job.at),
|
||
`<button data-run="${esc(job.id)}">手动触发</button>`,
|
||
]))}
|
||
<h3>最近运行</h3>
|
||
${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 = `
|
||
<h2>盘后发布 ${esc(data.trade_date)}</h2>
|
||
<div class="toolbar">
|
||
<label>日期 <input id="rel-date" value="${esc(data.trade_date)}" /></label>
|
||
<button type="button" id="rel-load">查看</button>
|
||
<button type="button" id="rel-backfill">补数</button>
|
||
</div>
|
||
<h3>当前映射</h3>
|
||
${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 ? `<button class="danger" data-rollback="${esc(row.dataset)}">回滚</button>` : "-",
|
||
]))}
|
||
<h3>批次</h3>
|
||
${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 = `
|
||
<h2>数据集 / 质量 ${esc(data.trade_date)}</h2>
|
||
${table(["数据集", "批次", "状态", "发布时间"], data.publications.map((row) => [
|
||
esc(row.dataset), esc(row.active_batch), esc(row.state), esc(row.published_at),
|
||
]))}
|
||
<h3>源间差异</h3>
|
||
${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 = `<h2>审计</h2>` + 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 `
|
||
<h2>盘后发布 ${esc(data.trade_date)}</h2>
|
||
<div class="toolbar">
|
||
<label>日期 <input id="rel-date" value="${esc(data.trade_date)}" /></label>
|
||
<button type="button" id="rel-load">查看</button>
|
||
<button type="button" id="rel-backfill">补数</button>
|
||
</div>
|
||
<h3>当前映射</h3>
|
||
${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 ? `<button class="danger" data-rollback="${esc(row.dataset)}">回滚</button>` : "-",
|
||
]))}
|
||
<h3>批次</h3>
|
||
${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();
|