feat(HEL-382): 搭建 datahub 底座和盘后正式数据链路
新增独立 xiaobai-datahub 服务(SQLite WAL、Tushare 盘后发布、/v1 契约和管理后台),不改现站页面与数据链路。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Cursor
multica-agent
parent
c2ebc0ab91
commit
3498dd7a4b
@@ -0,0 +1,268 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user