Compare commits

..
Author SHA1 Message Date
abd4d22a67 HEL-543 返工: 补充 observability 运行时可关闭开关 (DATAHUB_OBSERVABILITY)
总工复核 🔴:安全边界要求新增观测功能必须可关闭、关闭后现有功能完全照旧,
但此前实现没有任何运行时开关。修复:

- settings.py: 新增 Settings.observability_enabled 字段,沿用既有
  DATAHUB_SCHEDULER 的环境变量模式,读取 DATAHUB_OBSERVABILITY
  (0/false/off 关闭,默认开启)。
- hub.py: Hub.__init__ 把 settings.observability_enabled 挂到
  self.db 上,让 pipeline/realtime_serve/steward/admin_api 已经
  在传的 db 参数直接带上开关,零额外改造。
- observability.py: 新增 is_enabled(db),缺失该属性时默认按启用处理
  (向后兼容裸 HubDB 用例/测试)。关闭时 observe() 变成纯
  透传(不计时、不分类、不碰数据库),record_call() 直接 no-op。
- admin_api.py: 4 个新只读端点关闭时返回明确的
  {"enabled": false, ...空结构} 而不是静默返回旧数据。
- 新增 10 个测试:开关默认值/环境变量解析、关闭后 observe() 的透传语义
  (含异常原样重新抛出)、关闭后 record_call() 零写入、关闭后重新开启恢复
  记录、4 个 admin 端点在关闭态的响应结构。

全量测试 183/183 通过(新增 10 个,含此前 173 个零回归)。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-14 01:10:58 +08:00
f014eb11bd HEL-543: add data hub observability side-channel (provider status, source catalog, lineage)
- New provider_call_log/provider_health tables (additive-only schema),
  wired via a fail-open observability.observe()/record_call() helper.
- Tushare pipeline keeps its existing src_calls record unchanged and now
  also feeds the unified provider_health/provider_call_log side channel.
- Eastmoney/Tencent realtime_serve.py call sites and the iFinD steward
  call site are wrapped with observability.observe() at the call site
  only; no adapter internals, routing, fallback order, or return values
  are touched.
- New read-only admin API endpoints: /admin/api/providers/status,
  /admin/api/source-catalog, /admin/api/lineage,
  /admin/api/lineage/affected.
- New static, read-only source_catalog.py and lineage.py registries
  documenting existing providers/interfaces/datasets and known
  main-site consumers (cited against backend/features/screener and
  backend/features/heaven call sites).
- provider_call_log is purged by the existing pipeline.cleanup() job
  alongside src_calls/job_runs.
- 47 new unit/integration tests covering classification, fail-open
  behavior under DB/log failures, unchanged payloads/exceptions on
  success and failure paths, and the new HTTP endpoints. Full suite:
  173 tests, all green.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-14 00:59:03 +08:00
30 changed files with 2110 additions and 1934 deletions
+299
View File
@@ -0,0 +1,299 @@
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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;
const eod = data.eod_status || {};
const rev = data.revision_status || {};
const eodLabels = {
pending_first_attempt: "等待首次尝试",
waiting_upstream: "等待上游",
done: "已成功",
cutoff_failed: "已截止失败",
closed_day: "休市",
};
const revLabels = {
waiting_review: "等待复核",
review_failed: "复核失败",
aligned: "已追平",
cutoff: "已截止",
pending_publish: "待发布",
closed_day: "休市",
};
const eodExtra = [];
if (eod.state === "waiting_upstream") {
eodExtra.push(`已试 ${eod.attempts}`);
if (eod.next_retry_at) eodExtra.push(`下次重试 ${esc(String(eod.next_retry_at).replace("T", " ").slice(11, 16))}`);
if (eod.missing_datasets && eod.missing_datasets.length) eodExtra.push(`${esc(eod.missing_datasets.join(","))}`);
}
if (eod.state === "cutoff_failed" && eod.missing_datasets) {
eodExtra.push(`${esc(eod.missing_datasets.join(","))}`);
}
const revExtra = [];
if (rev.detail) revExtra.push(esc(String(rev.detail)));
if (rev.window) revExtra.push(esc(String(rev.window)));
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>${esc(eodLabels[eod.state] || eod.state || "-")}</strong><div class="muted">${eodExtra.join(" · ")}</div></div>
<div class="card"><div class="muted">估值复核</div><strong>${esc(revLabels[rev.state] || rev.state || "-")}</strong><div class="muted">${revExtra.join(" · ")}</div></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/stocks→A组整批;index_daily→B组;或 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();
-371
View File
@@ -1,371 +0,0 @@
"use strict";
/* ==========================================================================
xiaobai-datahub 管理后台 · 第九版核心引擎(A/B/C 三方向唯一共用来源)
—— 会话/主题/轮询/真实事件识别/危险操作/路由,三个布局文件只消费这里的
state、Bus 事件与 API,禁止各自再写一套请求或业务判断。
========================================================================== */
window.Core = (function () {
function $(id) { return document.getElementById(id); }
function esc(value) {
return String(value ?? "").replace(/[&<>"]/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[ch]));
}
function timeShort(value) {
const s = String(value ?? "");
const m = s.match(/(\d{2}:\d{2}:\d{2})/);
return m ? m[1] : s.replace("T", " ").slice(0, 16);
}
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
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 class="grid"><thead><tr>${thead}</tr></thead><tbody>${body}</tbody></table>`;
}
const state = {
csrf: "",
layout: "flowline",
releaseDate: "",
reduced: matchMedia("(prefers-reduced-motion: reduce)").matches,
visible: document.visibilityState === "visible",
online: navigator.onLine,
data: { overview: null, sources: null, jobs: null, batches: null, datasets: null, audit: null },
};
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;
}
/* ---------------------------------------------------------------- 事件总线
'data' → 任一真实拉取完成,payload = keyoverview/sources/...),布局重绘用
'event' → 已经发生的真实事件(供三方向各自演绎接力/推进/贯穿动效)
payload = { channel, kind, n, meta }
channel: tushare|eastmoney|tencent|ifind|junction|tx|audit
kind: ok|error|unconfigured|rollback|pub
'theme' → 主题切换,payload = 'day'|'night'
'reduced' → 减弱动效状态变化,payload = boolean
*/
const Bus = (() => {
const listeners = {};
return {
on(type, fn) { (listeners[type] = listeners[type] || []).push(fn); return () => this.off(type, fn); },
off(type, fn) { if (listeners[type]) listeners[type] = listeners[type].filter((f) => f !== fn); },
emit(type, payload) { (listeners[type] || []).slice().forEach((fn) => { try { fn(payload); } catch (e) { console.error(e); } }); },
};
})();
/* ---------------------------------------------------------------- 常量与标签 */
const FLOW_PROVIDERS = ["tushare", "eastmoney", "tencent", "ifind"];
const FLOW_LABEL = {
tushare: "Tushare", eastmoney: "东方财富", tencent: "腾讯行情", ifind: "iFinD",
};
const FLOW_ROLE = {
tushare: "官方盘后", eastmoney: "盘中观察", tencent: "盘中观察", ifind: "授权实时",
};
const EOD_LABELS = {
pending_first_attempt: "等待首次尝试", waiting_upstream: "等待上游", done: "已成功",
cutoff_failed: "已截止失败", closed_day: "休市",
};
const REV_LABELS = {
waiting_review: "等待复核", review_failed: "复核失败", aligned: "已追平",
cutoff: "已截止", pending_publish: "待发布", closed_day: "休市",
};
const PHASE_LABELS = { pre: "盘前", intraday: "盘中", lunch: "午间", eod: "盘后", closed: "休市" };
function todayYmd() {
const d = new Date(); const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
}
if (!state.releaseDate) state.releaseDate = todayYmd();
function chipClass(s) {
const map = {
ok: "ok", error: "err", warn: "warn", unknown: "unknown", unconfigured: "unconfigured",
published: "ok", pending: "warn", missing: "err", building: "warn", staged: "warn", failed: "err",
running: "warn", queued: "info", idle: "", success: "ok",
};
return map[s] ?? "";
}
function chipHtml(s, textOverride) {
return `<span class="chip ${chipClass(s)}">${esc(textOverride ?? s)}</span>`;
}
/* 真实健康态归一化:tushare 走断路器术语(closed=健康/half_open=试探/open=已跳闸),
其余三路走 ok/empty/error/unconfigured;没有探测记录时是 unknown。
四态必须原样区分显示,不得把 unknown 和 error 混为一谈,也不得把断路器
"closed"(健康) 误读成红色错误。 */
function linkState(health) {
const s = (health && health.state) || "unknown";
if (s === "ok" || s === "empty" || s === "closed") return "ok";
if (s === "half_open") return "warn";
if (s === "unconfigured" || s === "reserved") return "unconfigured";
if (s === "unknown") return "unknown";
return "error"; // open(断路器已跳闸)、error
}
function linkLampClass(link) {
if (link === "ok") return "on";
if (link === "error") return "err on";
return "off"; // warn / unknown / unconfigured 灯位统一暗灭,状态文字由 chip 承载
}
/* ---------------------------------------------------------------- 真实事件识别
只认已经发生的事实:recent_calls / probe 健康探测 / job runs / batch 状态变化 /
publication 发布时间变化 / audit 新行。没有真实变化就不发事件,不臆造。 */
const Seen = {
callsMax: -1, jobRunsMax: -1, auditMax: -1,
batchState: new Map(), pubPublishedAt: new Map(),
firstOverview: true, firstJobs: true, firstAudit: true, firstBatches: true,
};
async function pollOverview() {
const data = await api("/admin/api/overview");
const wasFirst = Seen.firstOverview;
const calls = data.recent_calls || [];
let maxId = Seen.callsMax;
const fresh = [];
for (const c of calls) { if (c.id > Seen.callsMax) fresh.push(c); if (c.id > maxId) maxId = c.id; }
Seen.callsMax = maxId;
Seen.firstOverview = false;
if (!wasFirst && fresh.length) Bus.emit("event", { channel: "tushare", kind: fresh.some((c) => !c.ok) ? "error" : "ok", n: fresh.length, meta: { fresh } });
state.data.overview = data;
Bus.emit("data", "overview");
}
async function pollSources() {
const data = await api("/admin/api/sources");
for (const item of data.items) {
if (!FLOW_PROVIDERS.includes(item.provider)) continue;
const health = item.health || {};
const link = linkState(health);
item._link = link;
if (item.provider !== "tushare" && link !== "unconfigured" && link !== "unknown") {
Bus.emit("event", { channel: item.provider, kind: link === "ok" ? "ok" : link === "warn" ? "warn" : "error", n: 1, meta: { item } });
}
}
state.data.sources = data;
Bus.emit("data", "sources");
}
async function pollJobs() {
const data = await api("/admin/api/jobs");
const wasFirst = Seen.firstJobs;
let maxId = Seen.jobRunsMax;
const fresh = [];
for (const r of data.runs || []) { if (r.id > Seen.jobRunsMax) fresh.push(r); if (r.id > maxId) maxId = r.id; }
Seen.jobRunsMax = maxId;
Seen.firstJobs = false;
if (!wasFirst && fresh.length) {
const anyFail = fresh.some((r) => r.state === "failed");
Bus.emit("event", { channel: "junction", kind: anyFail ? "error" : "ok", n: fresh.length, meta: { fresh } });
}
state.data.jobs = data;
state.freshJobRuns = fresh;
Bus.emit("data", "jobs");
}
async function pollBatches() {
const date = state.releaseDate || "";
const data = await api(`/admin/api/batches?date=${encodeURIComponent(date)}`);
const wasFirst = Seen.firstBatches;
let changed = false;
for (const b of data.batches || []) {
const prev = Seen.batchState.get(b.batch_id);
if (prev !== b.state) { changed = true; Seen.batchState.set(b.batch_id, b.state); }
}
let published = false;
for (const p of data.publications || []) {
const key = `${p.dataset}:${p.trade_date}`;
const prev = Seen.pubPublishedAt.get(key);
if (prev !== p.published_at) { published = true; Seen.pubPublishedAt.set(key, p.published_at); }
}
Seen.firstBatches = false;
if (!wasFirst && published) Bus.emit("event", { channel: "tx", kind: "pub", n: 1, meta: {} });
else if (!wasFirst && changed) Bus.emit("event", { channel: "junction", kind: "ok", n: 1, meta: {} });
state.data.batches = data;
Bus.emit("data", "batches");
}
async function pollDatasets() {
const data = await api(`/admin/api/datasets?date=${encodeURIComponent("")}`);
state.data.datasets = data;
Bus.emit("data", "datasets");
}
async function pollAudit() {
const data = await api("/admin/api/audit");
const wasFirst = Seen.firstAudit;
let maxId = Seen.auditMax;
const fresh = [];
for (const a of data.items || []) { if (a.id > Seen.auditMax) fresh.push(a); if (a.id > maxId) maxId = a.id; }
Seen.auditMax = maxId;
Seen.firstAudit = false;
if (!wasFirst && fresh.length) {
const rollback = fresh.some((a) => String(a.action).includes("rollback"));
Bus.emit("event", { channel: "audit", kind: rollback ? "rollback" : "ok", n: fresh.length, meta: { fresh } });
}
state.data.audit = data;
Bus.emit("data", "audit");
}
const Poller = (() => {
const specs = [
{ key: "overview", fn: pollOverview, every: 20000 },
{ key: "sources", fn: pollSources, every: 45000 },
{ key: "jobs", fn: pollJobs, every: 25000 },
{ key: "batches", fn: pollBatches, every: 25000 },
{ key: "datasets", fn: pollDatasets, every: 60000 },
{ key: "audit", fn: pollAudit, every: 20000 },
];
const timers = new Map();
function runtimeAvailable() { return state.visible && state.online; }
function tickOne(spec) { spec.fn().catch((err) => console.error(`[hub] poll ${spec.key} failed`, err)); }
function schedule(spec) {
clearTimer(spec.key);
const t = setInterval(() => { if (runtimeAvailable()) tickOne(spec); }, spec.every);
timers.set(spec.key, t);
}
function clearTimer(key) { if (timers.has(key)) { clearInterval(timers.get(key)); timers.delete(key); } }
function startAll() { specs.forEach((spec, i) => { setTimeout(() => { tickOne(spec); schedule(spec); }, i * 160); }); }
function stopAll() { specs.forEach((s) => clearTimer(s.key)); }
function pause() { stopAll(); }
function resume() { specs.forEach((spec) => { tickOne(spec); schedule(spec); }); }
return { startAll, stopAll, pause, resume, runtimeAvailable };
})();
function onRuntimeAvailabilityChange() {
if (Poller.runtimeAvailable()) Poller.resume();
else Poller.pause();
Bus.emit("runtime", Poller.runtimeAvailable());
}
document.addEventListener("visibilitychange", () => { state.visible = document.visibilityState === "visible"; onRuntimeAvailabilityChange(); });
window.addEventListener("online", () => { state.online = true; onRuntimeAvailabilityChange(); });
window.addEventListener("offline", () => { state.online = false; onRuntimeAvailabilityChange(); });
/* ---------------------------------------------------------------- 主题 */
function applyTheme(theme) {
const root = document.documentElement;
if (theme === "night") root.setAttribute("data-theme", "night");
else root.removeAttribute("data-theme");
try { localStorage.setItem("hub_theme", theme); } catch { /* ignore */ }
Bus.emit("theme", theme);
}
function currentTheme() { return document.documentElement.getAttribute("data-theme") === "night" ? "night" : "day"; }
function toggleTheme() { applyTheme(currentTheme() === "night" ? "day" : "night"); }
function bootTheme() {
const saved = (() => { try { return localStorage.getItem("hub_theme"); } catch { return null; } })();
applyTheme(saved === "night" ? "night" : "day");
}
/* ---------------------------------------------------------------- 减少动态效果 */
const REDUCE_MQ = matchMedia("(prefers-reduced-motion: reduce)");
function applyReduced(reduced) {
state.reduced = reduced;
document.documentElement.classList.toggle("reduced", reduced);
Bus.emit("reduced", reduced);
}
REDUCE_MQ.addEventListener("change", (e) => applyReduced(e.matches));
/* ---------------------------------------------------------------- 危险操作确认 */
async function dangerous(kind, dataset, onDone) {
const date = state.releaseDate || "";
const ds = dataset || prompt("数据集(daily/valuation/moneyflow/auction/stocks→A组整批;index_daily→B组;或 reference", "daily");
if (!ds) return;
const password = prompt("二次确认:输入管理密码");
if (!password) return;
const confirmWord = `${ds}:${date}`;
const typed = prompt(`请输入确认词:${confirmWord}`);
if (!typed) return;
const path = kind === "rollback" ? "/admin/api/rollback" : "/admin/api/backfill";
try {
const result = await api(path, { method: "POST", body: JSON.stringify({ dataset: ds, trade_date: date, password, confirm: typed }) });
if (kind === "rollback") Bus.emit("event", { channel: "tx", kind: "rollback", n: 1, meta: {} });
await pollBatches();
await pollAudit();
if (onDone) onDone(result);
} catch (err) {
alert(err.message);
}
}
async function probeSource(provider) {
const result = await api(`/admin/api/sources/${provider}/probe`, { method: "POST", body: "{}" });
if (FLOW_PROVIDERS.includes(provider)) {
const link = linkState(result);
Bus.emit("event", { channel: provider, kind: link === "ok" ? "ok" : link === "warn" ? "warn" : "error", n: 1, meta: { manual: true } });
}
await pollSources();
return result;
}
async function runJob(jobId, tradeDate) {
const result = await api(`/admin/api/jobs/${jobId}/run`, { method: "POST", body: JSON.stringify({ trade_date: tradeDate || "" }) });
await pollJobs();
return result;
}
/* ---------------------------------------------------------------- 路由:?layout=flowline|ledger|strata
单一静态文件,只靠查询参数区分三页;不改后端路由。
支持直接打开 / 刷新 / 浏览器前进后退。 */
const LAYOUT_NAMES = ["flowline", "ledger", "strata"];
const Router = (() => {
let mounted = null;
function parse() {
const qs = new URLSearchParams(location.search);
const l = qs.get("layout");
return LAYOUT_NAMES.includes(l) ? l : "flowline";
}
function urlFor(name) {
const qs = new URLSearchParams(location.search);
qs.set("layout", name);
return `${location.pathname}?${qs.toString()}${location.hash}`;
}
function navigate(name) {
if (!LAYOUT_NAMES.includes(name)) return;
if (parse() === name) return;
history.pushState(null, "", urlFor(name));
mount(name);
}
function mount(name) {
if (mounted && window.HUB_LAYOUTS[mounted] && window.HUB_LAYOUTS[mounted].unmount) {
try { window.HUB_LAYOUTS[mounted].unmount(); } catch (e) { console.error(e); }
}
state.layout = name;
mounted = name;
document.querySelectorAll("#layoutSwitch button[data-layout]").forEach((btn) => {
const on = btn.dataset.layout === name;
if (on) btn.setAttribute("aria-current", "page"); else btn.removeAttribute("aria-current");
});
const root = $("page-root");
const impl = window.HUB_LAYOUTS[name];
if (!impl) { root.innerHTML = `<div class="page-shell">布局未加载:${esc(name)}</div>`; return; }
impl.mount(root);
}
function boot() {
document.querySelectorAll("#layoutSwitch button[data-layout]").forEach((btn) => {
btn.addEventListener("click", () => navigate(btn.dataset.layout));
});
window.addEventListener("popstate", () => mount(parse()));
mount(parse());
}
return { boot, navigate, current: parse, urlFor };
})();
return {
$, esc, timeShort, clamp, table,
state, api, Bus, Poller, Router,
FLOW_PROVIDERS, FLOW_LABEL, FLOW_ROLE, EOD_LABELS, REV_LABELS, PHASE_LABELS,
todayYmd, chipClass, chipHtml, linkState, linkLampClass,
applyTheme, currentTheme, toggleTheme, bootTheme,
applyReduced, REDUCE_MQ,
dangerous, probeSource, runJob,
pollOverview, pollSources, pollJobs, pollBatches, pollDatasets, pollAudit,
};
})();
+20 -31
View File
@@ -3,19 +3,14 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>xiaobai-datahub 管理后台 · 数据中枢</title>
<link rel="stylesheet" href="/admin/tokens.css" />
<link rel="stylesheet" href="/admin/shared.css" />
<link rel="stylesheet" href="/admin/layouts/flowline.css" />
<link rel="stylesheet" href="/admin/layouts/ledger.css" />
<link rel="stylesheet" href="/admin/layouts/strata.css" />
<title>xiaobai-datahub 管理后台</title>
<link rel="stylesheet" href="/admin/styles.css" />
</head>
<body>
<div id="app">
<section id="login-view" class="panel auth-panel">
<span class="badge-sim">内网 · 8766</span>
<h1>数据中枢</h1>
<p class="muted">四路来源持续汇流、调度、发布与审计的运转空间</p>
<p class="muted">内网管理后台,用于查看源状态、调度和盘后发布批次</p>
<form id="login-form">
<label>账号 <input name="username" value="hub_admin" autocomplete="username" /></label>
<label>密码 <input name="password" type="password" autocomplete="current-password" /></label>
@@ -34,31 +29,25 @@
</form>
</section>
<section id="shell" hidden style="display:flex; flex-direction:column; min-height:100vh;">
<header id="topbar">
<div class="title">小白复盘 <em>·</em> 数据中枢</div>
<span class="vdiv"></span>
<span class="tdate mono" id="crumb">8766 · 四源汇流 · 持续运转</span>
<span class="spacer"></span>
<nav id="layoutSwitch" aria-label="页面切换">
<button type="button" data-layout="flowline"><span class="k">A</span>装配线</button>
<button type="button" data-layout="ledger"><span class="k">B</span>值班台账</button>
<button type="button" data-layout="strata"><span class="k">C</span>地层剖面</button>
</nav>
<span class="who muted" id="who"></span>
<button type="button" id="theme-btn" class="btn ghost">夜间</button>
<button type="button" id="logout-btn" class="btn ghost">退出</button>
<section id="shell" hidden>
<header class="top">
<strong>xiaobai-datahub</strong>
<span id="phase" class="pill"></span>
<span id="who" class="muted"></span>
<button type="button" id="theme-btn" class="ghost">夜间</button>
<button type="button" id="logout-btn" class="ghost">退出</button>
</header>
<main id="page-root" style="flex:1;"></main>
<nav>
<button data-page="overview" class="active">总览</button>
<button data-page="sources">数据源</button>
<button data-page="jobs">调度任务</button>
<button data-page="release">盘后发布</button>
<button data-page="datasets">数据集</button>
<button data-page="audit">审计</button>
</nav>
<main id="page"></main>
</section>
</div>
<script src="/admin/core.js"></script>
<script src="/admin/layouts/shared.js"></script>
<script src="/admin/layouts/flowline.js"></script>
<script src="/admin/layouts/ledger.js"></script>
<script src="/admin/layouts/strata.js"></script>
<script src="/admin/main.js"></script>
<script src="/admin/app.js"></script>
</body>
</html>
@@ -1,97 +0,0 @@
/* A · 装配线 Flowline —— 来源→加工→发布→审计 四工位水平因果链
只写这个布局独有的排布;颜色/组件规则一律来自 tokens.css + shared.css。 */
#fl-root { padding: var(--sp4); max-width: 1584px; margin: 0 auto; }
#fl-band { position: relative; padding: 14px 16px 18px; margin-bottom: 14px; overflow: hidden; }
#fl-band .lane-labels { display: flex; }
#fl-band .lane-labels span { flex: 1; }
#fl-wire { position: relative; height: 1px; background: var(--line); margin: 22px 0 18px; }
#fl-wire .chev { position: absolute; top: -8px; font-size: 13px; color: var(--t3); font-family: var(--font-mono); transform: translateX(-50%); }
#fl-lanes { display: grid; grid-template-columns: 2fr 1.15fr 1.05fr 0.85fr; gap: 14px; align-items: start; }
/* 防止内部宽表格用 min-width:auto 撑爆 1fr 轨道,参见 ledger.css 同类注释。 */
#fl-lanes > *, #fl-cols > * { min-width: 0; }
#fl-srcCards { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.fl-src-card { position: relative; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; overflow: hidden; }
.fl-src-card .row { display: flex; align-items: center; gap: 8px; }
.fl-src-card .name { font-size: 14px; font-weight: 600; color: var(--t1); }
.fl-src-card .role { font-size: 11px; color: var(--t3); }
.fl-src-card .grow { flex: 1; }
.fl-src-card .lat { font-size: 12px; color: var(--t2); }
.fl-src-card .cred { font-size: 11px; color: var(--t3); }
.fl-src-card .lamps { display: flex; align-items: center; gap: 4px; }
.fl-src-card .edge {
position: absolute; left: 0; top: 6px; bottom: 6px; width: 2px; background: var(--act);
border-radius: 2px; opacity: 0; transition: opacity var(--dur-ui) var(--ease-out);
}
.fl-src-card .edge.err { background: var(--error); }
.fl-station { padding: 12px 14px; min-height: 150px; position: relative; transition: box-shadow var(--dur-ui) var(--ease-out); }
.fl-station.pulse { box-shadow: 0 0 0 2px var(--action-soft) inset; }
.fl-station .hd { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.fl-station .hd .nm { font-size: 13px; font-weight: 600; color: var(--t1); }
.fl-station .hd .grow { flex: 1; }
.fl-station .task { font-size: 12px; color: var(--t2); margin-bottom: 8px; }
.fl-station .task b { color: var(--t1); font-weight: 600; }
.fl-pbar { position: relative; height: 4px; border-radius: 2px; background: var(--bg2); overflow: hidden; margin-bottom: 6px; }
.fl-pbar > i { position: absolute; left: 0; top: 0; bottom: 0; background: var(--action); border-radius: 2px; transition: width var(--dur-ui) var(--ease-out); }
.fl-station .queue { font-size: 12px; color: var(--t2); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.fl-miniruns { border-top: 1px solid var(--line-soft); padding-top: 6px; }
.fl-miniruns .mr { display: flex; gap: 8px; font-size: 11px; line-height: 1.7; }
.fl-miniruns .mr .id { color: var(--t3); width: 34px; }
.fl-miniruns .mr .why { color: var(--error); }
.fl-gate .bignum { display: flex; align-items: baseline; gap: 8px; margin-bottom: 8px; }
.fl-gate .bignum .n { font-size: var(--fs-hero); font-weight: 600; color: var(--t1); display: inline-block; transition: transform var(--dur-ui) var(--ease-out); }
.fl-gate .bignum .cap { font-size: 12px; color: var(--t3); }
.fl-gate .latest { font-size: 12px; color: var(--t2); margin-bottom: 10px; }
.fl-gate .valrow { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--t2); }
.fl-audit .al { font-size: 11px; line-height: 1.75; color: var(--t2); white-space: normal; word-break: break-all; }
.fl-audit .al .dim { color: var(--t3); }
.fl-audit .adiv { border-top: 1px solid var(--line-soft); margin: 6px 0; }
#fl-packet {
position: absolute; width: 34px; height: 3px; border-radius: 2px; background: var(--packet);
opacity: 0; pointer-events: none; z-index: 5; top: 0; left: 0;
}
#fl-cols { display: grid; grid-template-columns: 1.15fr 1.1fr 1fr; gap: 14px; }
.fl-col { padding: 12px 14px; }
.fl-col .sec-label { display: block; margin-bottom: 8px; }
.fl-col .sub-label { display: block; margin: 12px 0 4px; }
#fl-callsTable td.res-ok { color: var(--t2); }
#fl-callsTable td.res-err { color: var(--error); }
#fl-callsFoot { margin-top: 8px; font-size: 11px; color: var(--t3); border-top: 1px solid var(--line-soft); padding-top: 8px; }
.fl-newrow { transition: background var(--dur-ui) var(--ease-out); }
.fl-newrow.flashin { background: var(--alive-soft); }
.fl-jobrow { display: flex; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--line-soft); }
.fl-jobrow .jid { font-size: 12px; color: var(--t1); }
.fl-jobrow .jti { font-size: 12px; color: var(--t2); }
.fl-jobrow .jat { font-size: 11px; color: var(--t3); }
.fl-jobrow .grow { flex: 1; }
#fl-col3 .toolrow { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
#fl-col3 .toolrow input {
font-family: var(--font-mono); font-size: 12px; color: var(--t1); background: var(--bg2);
border: 1px solid var(--line); border-radius: var(--r-chip); padding: 5px 8px; width: 96px; outline: none;
}
.fl-anom { margin-top: 10px; }
tr.fl-rowflash td { transition: background var(--dur-ui) var(--ease-out); }
tr.fl-rowflash.on td { background: var(--alive-soft); }
/* ---------------- 1280 ---------------- */
@media (max-width: 1280px) {
#fl-lanes { grid-template-columns: 1fr; }
#fl-srcCards { grid-template-columns: 1fr 1fr; }
#fl-cols { grid-template-columns: 1fr 1fr; }
#fl-col3 { grid-column: 1 / -1; }
}
/* ---------------- 1024 ---------------- */
@media (max-width: 1100px) {
#fl-root { padding: var(--sp3); }
#fl-srcCards { grid-template-columns: 1fr; }
#fl-cols { grid-template-columns: 1fr; }
.fl-src-card .role, .fl-src-card .cred { display: none; }
}
-300
View File
@@ -1,300 +0,0 @@
"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 = `
<div id="fl-root" class="page-shell">
<section id="fl-band" class="card">
<div class="lane-labels">
<span class="sec-label">来源 SOURCES</span>
<span class="sec-label">加工 PROCESS</span>
<span class="sec-label">发布 PUBLISH</span>
<span class="sec-label">审计 AUDIT</span>
</div>
<div id="fl-wire"><span class="chev" style="left:26%">&rsaquo;</span><span class="chev" style="left:57%">&rsaquo;</span><span class="chev" style="left:80%">&rsaquo;</span></div>
<div id="fl-lanes">
<div id="fl-srcCards"></div>
<div id="fl-process" class="card fl-station" data-station="process">
<div class="hd"><span class="nm">中枢处理</span><span class="grow"></span><span data-pct class="mono">-</span></div>
<div id="fl-processBody"></div>
</div>
<div id="fl-publish" class="card fl-station fl-gate" data-station="publish">
<div class="hd"><span class="nm">发布闸口</span><span class="grow"></span><span class="sec-label">GATE</span></div>
<div id="fl-publishBody"></div>
</div>
<div id="fl-audit" class="card fl-station fl-audit" data-station="audit">
<div class="hd"><span class="nm">审计末端</span></div>
<div id="fl-auditBody"></div>
</div>
</div>
<div id="fl-packet"></div>
</section>
<div id="fl-cols">
<section id="fl-col1" class="card fl-col">
<span class="sec-label">最近调用 RECENT CALLS</span>
<div id="fl-callsTable"></div>
<div id="fl-callsFoot"></div>
</section>
<section id="fl-col2" class="card fl-col">
<span class="sec-label">调度任务 JOBS</span>
<div id="fl-jobRows"></div>
<span class="sec-label sub-label">最近运行 RUNS</span>
<div id="fl-runsTable"></div>
</section>
<section id="fl-col3" class="card fl-col">
<span class="sec-label">盘后发布 RELEASE</span>
<div class="toolrow">
<input id="fl-rel-date" value="" spellcheck="false" />
<button type="button" class="btn" id="fl-rel-load">查看</button>
<button type="button" class="btn danger" id="fl-rel-backfill">补数</button>
</div>
<span class="sec-label sub-label">当前映射 MAPPING</span>
<div id="fl-pubTable"></div>
<div id="fl-anom" class="fl-anom"></div>
</section>
</div>
</div>
`;
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 `
<div class="fl-src-card card" data-provider="${esc(item.provider)}">
<div class="edge"></div>
<div class="row">
<span class="name">${esc(C.FLOW_LABEL[item.provider] || item.provider)}</span>
<span class="role">${esc(C.FLOW_ROLE[item.provider] || item.role)}</span>
<span class="grow"></span>
${C.chipHtml(link, health.state || link)}
</div>
<div class="row">
<span class="lamps"><i class="lamp link ${C.linkLampClass(link)}" data-link></i><span class="lamp-tag">LINK</span></span>
<span class="lamps"><i class="lamp act" data-act></i><span class="lamp-tag">ACT</span></span>
<span class="grow"></span>
<span class="lat mono">${health.latency_ms == null ? "-" : health.latency_ms + "ms"}</span>
</div>
<div class="row">
<span class="cred mono">${credText}</span><span class="grow"></span>
<button class="btn ghost mini" data-probe="${esc(item.provider)}">探测一次</button>
</div>
</div>`;
}
function renderSources() {
const v = S.sourcesView();
const host = $("fl-srcCards");
if (!v) { host.innerHTML = `<div class="muted">正在加载…</div>`; return; }
host.innerHTML = v.live.map(srcCardHtml).join("") +
(v.reserved.length ? `<div class="fl-src-card card muted" style="align-items:center;justify-content:center;font-size:12px;grid-column: 1 / -1">预留源 ${v.reserved.length} 个 · 未接入 · 无真实调用</div>` : "");
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 = `<div class="muted">正在加载…</div>`; 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 = `
<div class="task mono"><b>${esc(latest.job_id)}</b> · ${esc((jv.jobs.find((j) => j.id === latest.job_id) || {}).title || "")}</div>
<div class="fl-pbar"><i style="width:${pct}%;background:${latest.state === "failed" ? "var(--error)" : "var(--action)"}"></i></div>
<div class="queue">队列 <b class="mono">${running}</b> · 最近 20 次失败 <span class="chip ${jv.failedRecent ? "err" : "ok"}">${jv.failedRecent}</span></div>
<div class="fl-miniruns">
${jv.runs.slice(0, 3).map((r) => `<div class="mr mono"><span class="id">${r.id}</span><span class="${r.state === "failed" ? "fail" : r.state === "running" ? "warn" : "ok"}">${esc(r.state)}</span><span class="${r.error ? "why" : "muted"}">${esc(r.error || (r.finished_at !== "-" && r.finished_at ? timeShort(r.finished_at) : "—"))}</span></div>`).join("")}
</div>`;
}
function renderPublish() {
const rv = S.releaseView();
const body = $("fl-publishBody");
if (!rv) { body.innerHTML = `<div class="muted">正在加载…</div>`; 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 = `
<div class="bignum"><span class="n mono" data-pubnum>${rv.pubs.length}</span><span class="cap">今日发布</span></div>
<div class="latest mono">最新批次 ${esc(latestPub ? `${latestPub.active_batch} · ${latestPub.published_at}` : "暂无")}</div>
${valuation ? `<div class="valrow"><span class="mono">valuation</span>${C.chipHtml(valuation.state)}</div>` : ""}`;
}
function renderAudit() {
const av = S.auditView();
const body = $("fl-auditBody");
if (!av || !av.items.length) { body.innerHTML = `<div class="muted">正在加载…</div>`; return; }
const a0 = av.items[0];
body.innerHTML = `
<div class="al mono">${esc(timeShort(a0.created_at))} ${esc(a0.actor)} · ${esc(a0.action)}</div>
<div class="al mono dim">${esc(a0.target)}${a0.detail ? " · " + esc(a0.detail) : ""}</div>
<div class="adiv"></div>
${av.items.slice(1, 3).map((a) => `<div class="al mono dim">${esc(timeShort(a.created_at))} ${esc(a.actor)} · ${esc(a.action)}</div>`).join("")}`;
}
function renderCalls() {
const ov = S.overviewView();
const sv = S.sourcesView();
if (!ov) { $("fl-callsTable").innerHTML = `<div class="muted">正在加载…</div>`; return; }
$("fl-callsTable").innerHTML = C.table(["时间", "源", "端点", "结果", "耗时"], ov.recentCalls.map((row) => [
`<span class="mono">${esc(timeShort(row.created_at))}</span>`,
`<span class="mono k">${esc(row.provider)}</span>`,
`<span class="mono">${esc(row.endpoint)}</span>`,
row.ok ? '<span class="res-ok">成功</span>' : `<span class="res-err mono">${esc(row.error)}</span>`,
`<span class="mono">${row.latency_ms ?? "-"} ms</span>`,
]));
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 = `<div class="muted">正在加载…</div>`; return; }
jobRows.innerHTML = jv.jobs.map((job) => `
<div class="fl-jobrow">
<span class="jid mono">${esc(job.id)}</span><span class="jti">${esc(job.title)}</span>
<span class="jat mono">${esc(job.at)}</span>${C.chipHtml(jv.latestByJob.get(job.id) ? jv.latestByJob.get(job.id).state : "idle")}
<span class="grow"></span><button class="btn ghost mini" data-run="${esc(job.id)}">手动触发</button>
</div>`).join("");
S.bindRunButtons(jobRows);
$("fl-runsTable").innerHTML = C.table(["ID", "任务", "状态", "开始", "结束", "错误"], jv.runs.map((r) => [
`<span class="mono">${r.id}</span>`, `<span class="mono k">${esc(r.job_id)}</span>`,
`<span class="mono ${r.state === "failed" ? "fail" : r.state === "running" ? "warn" : "ok"}">${esc(r.state)}</span>`,
`<span class="mono">${esc(timeShort(r.started_at))}</span>`, `<span class="mono">${esc(timeShort(r.finished_at))}</span>`,
`<span class="fail">${esc(r.error || "")}</span>`,
]));
}
function renderReleaseCol() {
const rv = S.releaseView();
const dateInput = $("fl-rel-date");
if (!dateInput.value) dateInput.value = C.state.releaseDate;
if (!rv) { $("fl-pubTable").innerHTML = `<div class="muted">正在加载…</div>`; return; }
$("fl-pubTable").innerHTML = C.table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], rv.pubs.map((p) => [
`<span class="mono k">${esc(p.dataset)}</span>`, `<span class="mono">${esc(p.active_batch)}</span>`,
`<span class="mono">${esc(p.prev_batch || "-")}</span>`, C.chipHtml(p.state),
`<span class="mono">${esc(p.published_at)}</span>`,
p.prev_batch ? `<button class="btn danger mini" data-rollback="${esc(p.dataset)}">回滚</button>` : "-",
]));
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) => `<span class="chip err mono">${esc(b.batch_id)} · ${esc(b.dataset)} · ${esc(b.error || "异常")}</span>`).join(" ")
: `<span class="chip ok">批次全部正常</span>`;
}
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 };
})();
-81
View File
@@ -1,81 +0,0 @@
/* B · 值班台账 Ledger Desk —— 表格本身就是主舞台,每条真实事件一行。
高密度、精密仪器感;按钮/状态/异常一眼可读。 */
#lg-root { padding: var(--sp3) var(--sp4); max-width: 1680px; margin: 0 auto; }
#lg-grid { display: grid; grid-template-columns: 250px 1fr 300px; gap: 12px; align-items: start; }
/* CSS Grid 子项默认 min-width:auto,会被内部宽表格的内在尺寸撑爆整条 1fr 轨道;
显式清零,让表格自己用 overflow 滚动,而不是撑破布局。 */
#lg-grid > * { min-width: 0; }
#lg-side, #lg-side-r { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
.lg-panel { padding: 10px 12px; min-width: 0; }
.lg-panel .sec-label { display: block; margin-bottom: 8px; }
.lg-src-row { display: flex; align-items: center; gap: 7px; padding: 5px 0; border-bottom: 1px solid var(--line-soft); font-size: 12px; }
.lg-src-row:last-child { border-bottom: 0; }
.lg-src-row .nm { flex: 1; color: var(--t1); }
.lg-src-row .lat { color: var(--t3); font-size: 11px; }
.lg-src-row .lamps { display: flex; gap: 3px; }
.lg-jobline { display: flex; align-items: center; gap: 6px; padding: 5px 0; border-bottom: 1px solid var(--line-soft); font-size: 12px; }
.lg-jobline:last-child { border-bottom: 0; }
.lg-jobline .nm { flex: 1; color: var(--t1); }
.lg-jobline .at { color: var(--t3); font-size: 11px; }
.lg-jobline button { margin-left: 4px; }
/* -------- 主舞台:事件台账表 -------- */
#lg-ledger { padding: 10px 12px; }
#lg-ledger .lg-ledger-hd { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; }
#lg-ledger .lg-ledger-hd .hint { font-size: 11px; color: var(--t3); }
table.lg-ledger { width: 100%; border-collapse: collapse; font-size: 12px; }
table.lg-ledger th {
position: sticky; top: 0; background: var(--bg1); text-align: left; font-weight: 500; color: var(--t3);
font-size: 11px; padding: 6px 8px; border-bottom: 1px solid var(--line); white-space: nowrap; z-index: 1;
}
table.lg-ledger th.stage { text-align: center; }
table.lg-ledger td { padding: 7px 8px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
table.lg-ledger td.ev { min-width: 150px; }
table.lg-ledger td.ev .nm { color: var(--t1); font-weight: 600; font-size: 12.5px; }
table.lg-ledger td.ev .sub { color: var(--t3); font-size: 10.5px; }
table.lg-ledger td.src { color: var(--t2); font-size: 11.5px; white-space: nowrap; }
table.lg-ledger td.stage { text-align: center; white-space: nowrap; }
.lg-cell {
display: inline-flex; align-items: center; justify-content: center; gap: 4px; min-width: 58px;
padding: 3px 7px; border-radius: var(--r-chip); font-family: var(--font-mono); font-size: 11px;
background: var(--bg2); color: var(--t3); transition: background var(--dur-ui) var(--ease-out), color var(--dur-ui) var(--ease-out);
}
.lg-cell.ok { background: var(--alive-soft); color: var(--alive); }
.lg-cell.err { background: var(--error-soft); color: var(--error); }
.lg-cell.warn { background: var(--act-soft); color: var(--act); }
.lg-cell.dash { opacity: .4; }
tr.lg-row { transition: background var(--dur-ui) var(--ease-out); }
tr.lg-row.flashnew td { background: var(--action-soft); }
tr.lg-row.flashnew.err td { background: var(--error-soft); }
/* -------- 调用带(持续,只滚真实数据) -------- */
#lg-tape { margin-top: 10px; padding: 8px 0 0; border-top: 1px solid var(--line-soft); }
#lg-tape .sec-label { display: block; margin-bottom: 6px; }
#lg-tape-track { overflow: hidden; white-space: nowrap; position: relative; height: 24px; }
#lg-tape-inner { display: inline-flex; gap: 22px; will-change: transform; }
.lg-tape-item { font-family: var(--font-mono); font-size: 11.5px; color: var(--t2); white-space: nowrap; }
.lg-tape-item b { color: var(--t1); }
.lg-tape-item.err { color: var(--error); }
#lg-tape.paused #lg-tape-inner { animation-play-state: paused; }
/* -------- 右侧发布/审计 -------- */
#lg-release table.grid td, #lg-release table.grid th { padding: 5px 6px; font-size: 11px; }
#lg-release .toolrow { display: flex; gap: 6px; margin-bottom: 8px; }
#lg-release .toolrow input { width: 84px; font-family: var(--font-mono); font-size: 11px; background: var(--bg2); border: 1px solid var(--line); border-radius: var(--r-chip); padding: 4px 6px; color: var(--t1); }
.lg-auditline { font-size: 11px; color: var(--t2); padding: 5px 0; border-bottom: 1px solid var(--line-soft); line-height: 1.5; }
.lg-auditline:last-child { border-bottom: 0; }
.lg-auditline .dim { color: var(--t3); }
@media (max-width: 1280px) {
#lg-grid { grid-template-columns: 1fr; }
#lg-side, #lg-side-r { flex-direction: row; flex-wrap: wrap; }
.lg-panel { flex: 1 1 260px; }
}
@media (max-width: 1100px) {
#lg-root { padding: var(--sp2) var(--sp3); }
table.lg-ledger { font-size: 11px; }
.lg-cell { min-width: 46px; font-size: 10px; padding: 2px 5px; }
}
-291
View File
@@ -1,291 +0,0 @@
"use strict";
/* ==========================================================================
B · 值班台账 Ledger Desk —— 表格本身就是主舞台,每条真实事件一行。
响应/汇入/处理/发布/审计五列只填该行真实具备的阶段,没有发生的阶段留空,
不做跨行编造关联。底部调用带只滚真实 recent_calls。
========================================================================== */
(function () {
const C = window.Core, S = window.HubShared;
const { $, esc, timeShort } = C;
let root = null;
let unsubs = [];
let seenRowKeys = new Set();
let mounted = false;
let pendingTimers = [];
function safeTimeout(fn, ms) {
const id = setTimeout(() => { if (mounted) fn(); }, ms);
pendingTimers.push(id);
return id;
}
const SKELETON = `
<div id="lg-root" class="page-shell">
<div id="lg-grid">
<div id="lg-side">
<section class="card lg-panel">
<span class="sec-label">来源健康 SOURCES</span>
<div id="lg-srcList"></div>
</section>
<section class="card lg-panel">
<span class="sec-label">调度任务 JOBS</span>
<div id="lg-jobList"></div>
</section>
</div>
<section class="card" id="lg-ledger">
<div class="lg-ledger-hd">
<span class="sec-label">事件台账 EVENT LEDGER</span>
<span class="hint">每条事件依次经过 响应→汇入→处理→发布→审计</span>
</div>
<div style="overflow:auto; max-height: 62vh;">
<table class="lg-ledger">
<thead><tr>
<th>时间</th><th>事件</th><th>来源</th>
<th class="stage">响应<br/><span style="opacity:.6">RESP</span></th>
<th class="stage">汇入<br/><span style="opacity:.6">INGEST</span></th>
<th class="stage">处理<br/><span style="opacity:.6">PROC</span></th>
<th class="stage">发布<br/><span style="opacity:.6">PUB</span></th>
<th class="stage">审计<br/><span style="opacity:.6">AUDIT</span></th>
</tr></thead>
<tbody id="lg-ledgerBody"></tbody>
</table>
</div>
<div id="lg-tape">
<span class="sec-label">调用带 CALL TAPE</span>
<div id="lg-tape-track"><div id="lg-tape-inner"></div></div>
</div>
</section>
<div id="lg-side-r">
<section class="card lg-panel" id="lg-release">
<span class="sec-label">盘后发布 RELEASE</span>
<div class="toolrow">
<input id="lg-rel-date" value="" />
<button type="button" class="btn mini" id="lg-rel-load">查看</button>
<button type="button" class="btn danger mini" id="lg-rel-backfill">补数</button>
</div>
<div id="lg-pubTable"></div>
</section>
<section class="card lg-panel">
<span class="sec-label">审计 AUDIT</span>
<div id="lg-auditList"></div>
</section>
</div>
</div>
</div>
`;
function renderSources() {
const v = S.sourcesView();
const host = $("lg-srcList");
if (!v) { host.innerHTML = `<div class="muted">正在加载…</div>`; return; }
host.innerHTML = v.live.map((it) => {
const health = it.health || {};
const link = it._link || C.linkState(health);
return `<div class="lg-src-row" data-provider="${esc(it.provider)}">
<span class="lamps"><i class="lamp link ${C.linkLampClass(link)}"></i><i class="lamp act" data-act></i></span>
<span class="nm">${esc(C.FLOW_LABEL[it.provider] || it.provider)}</span>
${C.chipHtml(link, health.state || link)}
<span class="lat mono">${health.latency_ms == null ? "-" : health.latency_ms + "ms"}</span>
<button class="btn ghost mini" data-probe="${esc(it.provider)}">探测</button>
</div>`;
}).join("");
S.bindProbeButtons(host, () => renderSources());
}
function renderJobs() {
const v = S.jobsView();
const host = $("lg-jobList");
if (!v) { host.innerHTML = `<div class="muted">正在加载…</div>`; return; }
host.innerHTML = v.jobs.map((job) => {
const latest = v.latestByJob.get(job.id);
return `<div class="lg-jobline">
${C.chipHtml(latest ? latest.state : "idle")}
<span class="nm mono">${esc(job.id)}</span>
<span class="at mono">${esc(job.at)}</span>
<button class="btn ghost mini" data-run="${esc(job.id)}">触发</button>
</div>`;
}).join("");
S.bindRunButtons(host, () => renderJobs());
}
function renderRelease() {
const v = S.releaseView();
const dateInput = $("lg-rel-date");
if (!dateInput.value) dateInput.value = C.state.releaseDate;
if (!v) { $("lg-pubTable").innerHTML = `<div class="muted">正在加载…</div>`; return; }
$("lg-pubTable").innerHTML = C.table(["数据集", "批次", "状态", "时间", ""], v.pubs.map((p) => [
`<span class="mono k">${esc(p.dataset)}</span>`, `<span class="mono">${esc(p.active_batch)}</span>`,
C.chipHtml(p.state), `<span class="mono">${esc(p.published_at)}</span>`,
p.prev_batch ? `<button class="btn danger mini" data-rollback="${esc(p.dataset)}">回滚</button>` : "-",
]));
S.bindRollbackButtons($("lg-pubTable"), () => renderRelease());
}
function renderAuditList() {
const v = S.auditView();
const host = $("lg-auditList");
if (!v) { host.innerHTML = `<div class="muted">正在加载…</div>`; return; }
host.innerHTML = v.items.slice(0, 10).map((a) => `
<div class="lg-auditline mono">
<div>${esc(timeShort(a.created_at))} <b>${esc(a.actor)}</b> <span class="${String(a.action).includes("rollback") ? "warn" : ""}">${esc(a.action)}</span></div>
<div class="dim">${esc(a.target)}${a.detail ? " · " + esc(a.detail) : ""}</div>
</div>`).join("");
}
/* ---------------------------------------------------------------- 台账行合成
每行只填该事件类型真实具备的阶段;不同类型互不编造对方的字段。 */
function cell(text, kind) {
if (text == null || text === "") return `<span class="lg-cell dash">—</span>`;
return `<span class="lg-cell ${kind || ""}">${esc(text)}</span>`;
}
function buildLedgerRows() {
const ov = S.overviewView(), jv = S.jobsView(), rv = S.releaseView(), av = S.auditView();
const rows = [];
if (ov) {
for (const c of ov.recentCalls) {
rows.push({
key: `call:${c.id}`, t: c.created_at, name: c.endpoint, sub: "来源响应", source: c.provider,
resp: cell(timeShort(c.created_at), c.ok ? "ok" : "err"),
ingest: c.ok ? cell(timeShort(c.created_at), "ok") : cell(),
proc: cell(), pub: cell(), audit: cell(),
err: !c.ok,
});
}
}
if (jv) {
for (const r of jv.runs.slice(0, 30)) {
const job = jv.jobs.find((j) => j.id === r.job_id);
rows.push({
key: `run:${r.id}`, t: r.started_at, name: (job && job.title) || r.job_id, sub: r.job_id, source: "system",
resp: cell(), ingest: cell(timeShort(r.started_at), "ok"),
proc: r.state === "running" ? cell("running", "warn") : r.state === "failed" ? cell(r.error || "failed", "err") : cell(timeShort(r.finished_at), "ok"),
pub: cell(), audit: cell(),
err: r.state === "failed",
});
}
}
if (rv) {
for (const p of rv.pubs) {
if (!p.published_at || p.published_at === "-") continue;
rows.push({
key: `pub:${p.dataset}:${p.active_batch}`, t: p.published_at, name: `批次发布 · ${p.dataset}`, sub: p.active_batch, source: "system",
resp: cell(), ingest: cell(), proc: cell(),
pub: cell(timeShort(p.published_at), p.state === "published" ? "ok" : "warn"),
audit: cell(),
err: p.state === "missing" || p.state === "error",
});
}
}
if (av) {
for (const a of av.items.slice(0, 30)) {
const isRollback = String(a.action).includes("rollback");
const isProbe = a.action === "probe";
rows.push({
key: `audit:${a.id}`, t: a.created_at, name: a.action, sub: a.target, source: a.actor,
resp: isProbe ? cell(timeShort(a.created_at), String(a.detail).includes("失败") ? "err" : "ok") : cell(),
ingest: cell(), proc: cell(),
pub: isRollback ? cell(timeShort(a.created_at), "warn") : cell(),
audit: cell(timeShort(a.created_at), isRollback ? "warn" : "ok"),
err: isRollback,
});
}
}
rows.sort((a, b) => String(b.t).localeCompare(String(a.t)));
return rows.slice(0, 60);
}
function renderLedger() {
const rows = buildLedgerRows();
const body = $("lg-ledgerBody");
body.innerHTML = rows.map((r) => `
<tr class="lg-row ${seenRowKeys.has(r.key) ? "" : "flashnew"} ${r.err ? "err" : ""}" data-key="${esc(r.key)}">
<td class="mono">${esc(timeShort(r.t))}</td>
<td class="ev"><div class="nm">${esc(r.name)}</div><div class="sub">${esc(r.sub || "")}</div></td>
<td class="src mono">${esc(r.source)}</td>
<td class="stage">${r.resp}</td>
<td class="stage">${r.ingest}</td>
<td class="stage">${r.proc}</td>
<td class="stage">${r.pub}</td>
<td class="stage">${r.audit}</td>
</tr>`).join("");
rows.forEach((r) => seenRowKeys.add(r.key));
if (!C.state.reduced) {
body.querySelectorAll("tr.flashnew").forEach((tr) => {
setTimeout(() => tr.classList.remove("flashnew"), 1000);
});
} else {
body.querySelectorAll("tr.flashnew").forEach((tr) => tr.classList.remove("flashnew"));
}
}
/* ---------------------------------------------------------------- 调用带:只滚真实 recent_calls */
function renderTape() {
const ov = S.overviewView();
const inner = $("lg-tape-inner");
if (!ov || !ov.recentCalls.length) { inner.innerHTML = `<span class="lg-tape-item muted">暂无真实调用</span>`; inner.style.animation = "none"; return; }
const items = ov.recentCalls.slice(0, 16).map((c) => `<span class="lg-tape-item ${c.ok ? "" : "err"}">${esc(timeShort(c.created_at))} <b>${esc(c.provider)}</b> ${esc(c.endpoint)} ${c.ok ? "✓" : "× " + esc(c.error)}</span>`).join("");
inner.innerHTML = items + items; // 首尾拼接形成无缝循环,内容仍全部来自真实调用
if (C.state.reduced) { inner.style.animation = "none"; return; }
const width = inner.scrollWidth / 2;
inner.style.animation = "none";
void inner.offsetWidth;
inner.style.setProperty("--tape-w", `-${width}px`);
inner.style.animation = `lg-tape-scroll ${Math.max(12, width / 40)}s linear infinite`;
}
if (!document.getElementById("lg-tape-keyframes")) {
const style = document.createElement("style");
style.id = "lg-tape-keyframes";
style.textContent = `@keyframes lg-tape-scroll { from { transform: translateX(0); } to { transform: translateX(var(--tape-w, -800px)); } }`;
document.head.appendChild(style);
}
function updateTapePauseState() {
const el = $("lg-tape");
if (!el) return;
el.classList.toggle("paused", !C.Poller.runtimeAvailable() || C.state.reduced);
}
function refresh() {
if (!root) return;
renderSources(); renderJobs(); renderRelease(); renderAuditList(); renderLedger(); renderTape(); updateTapePauseState();
}
function onEvent(evt) {
if (!root || !mounted) return;
if (C.FLOW_PROVIDERS.includes(evt.channel)) {
const row = root.querySelector(`.lg-src-row[data-provider="${evt.channel}"] [data-act]`);
if (row) {
row.classList.remove("on", "err");
row.classList.add("on"); if (evt.kind === "error") row.classList.add("err");
setTimeout(() => row.classList.remove("on", "err"), 800);
}
}
renderLedger();
renderTape();
}
function mount(el) {
root = el;
mounted = true;
seenRowKeys = new Set();
root.innerHTML = SKELETON;
refresh();
S.bindReleaseDateReload(root, "#lg-rel-date", "#lg-rel-load", () => renderRelease());
S.bindBackfillButton(root, "#lg-rel-backfill", () => renderRelease());
unsubs.push(C.Bus.on("data", refresh));
unsubs.push(C.Bus.on("event", onEvent));
unsubs.push(C.Bus.on("runtime", updateTapePauseState));
unsubs.push(C.Bus.on("reduced", () => { renderTape(); updateTapePauseState(); }));
}
function unmount() {
mounted = false;
unsubs.forEach((fn) => fn()); unsubs = [];
pendingTimers.forEach(clearTimeout); pendingTimers = [];
root = null;
}
window.HUB_LAYOUTS = window.HUB_LAYOUTS || {};
window.HUB_LAYOUTS.ledger = { mount, unmount };
})();
-136
View File
@@ -1,136 +0,0 @@
"use strict";
/* ==========================================================================
三方向共用的数据整形 + 操作绑定。A/B/C 各自的 mount() 只管 DOM 结构和动效编排,
"字段从哪来、按钮点了调什么接口" 统一走这里,禁止在布局文件里重写业务判断。
========================================================================== */
window.HubShared = (function () {
const C = window.Core;
const { esc, timeShort } = C;
function overviewView() {
const d = C.state.data.overview;
if (!d) return null;
const eod = d.eod_status || {}, rev = d.revision_status || {};
return {
tradeDate: d.trade_date,
phase: C.PHASE_LABELS[d.session_phase] || d.session_phase,
pubCount: d.publications.length,
eodState: eod.state, eodLabel: C.EOD_LABELS[eod.state] || eod.state || "-", eod,
revState: rev.state, revLabel: C.REV_LABELS[rev.state] || rev.state || "-", rev,
anomalies: d.anomalies.length,
recentCalls: d.recent_calls || [],
sourceCount: d.source_count,
};
}
function sourcesView() {
const d = C.state.data.sources;
if (!d) return null;
const live = d.items.filter((it) => C.FLOW_PROVIDERS.includes(it.provider));
const reserved = d.items.filter((it) => !C.FLOW_PROVIDERS.includes(it.provider));
return { live, reserved, all: d.items };
}
function jobsView() {
const d = C.state.data.jobs;
if (!d) return null;
const runs = d.runs || [];
const latestByJob = new Map();
for (const r of runs) if (!latestByJob.has(r.job_id)) latestByJob.set(r.job_id, r);
return {
jobs: d.jobs || [], runs, latest: runs[0], latestByJob,
failedRecent: runs.slice(0, 20).filter((r) => r.state === "failed").length,
fresh: C.state.freshJobRuns || [],
};
}
function releaseView() {
const d = C.state.data.batches;
if (!d) return null;
const pubs = d.publications || [];
const batches = d.batches || [];
return {
tradeDate: d.trade_date, pubs, batches,
rollbacks: pubs.filter((p) => p.prev_batch).length,
failedBatches: batches.filter((b) => b.state === "failed").length,
};
}
function datasetsView() {
const d = C.state.data.datasets;
if (!d) return null;
return { tradeDate: d.trade_date, pubs: d.publications || [], diffs: d.diff_reports || [] };
}
function auditView() {
const d = C.state.data.audit;
if (!d) return null;
return { items: d.items || [] };
}
/* ---------------------------------------------------------------- 操作绑定 */
function bindProbeButtons(root, onDone) {
root.querySelectorAll("[data-probe]").forEach((btn) => {
btn.addEventListener("click", async () => {
const provider = btn.dataset.probe;
btn.disabled = true;
const original = btn.textContent;
btn.textContent = "探测中…";
try {
const result = await C.probeSource(provider);
btn.textContent = "已探测";
setTimeout(() => { btn.textContent = original; }, 1200);
if (onDone) onDone(result, provider);
} catch (err) {
alert(err.message);
btn.textContent = original;
} finally {
setTimeout(() => { btn.disabled = false; }, 400);
}
});
});
}
function bindRunButtons(root, onDone) {
root.querySelectorAll("[data-run]").forEach((btn) => {
btn.addEventListener("click", async () => {
const date = prompt("交易日 YYYYMMDD(可留空=今天)", "") || "";
btn.disabled = true;
try {
const result = await C.runJob(btn.dataset.run, date);
if (onDone) onDone(result);
} catch (err) {
alert(err.message);
} finally {
btn.disabled = false;
}
});
});
}
function bindRollbackButtons(root, onDone) {
root.querySelectorAll("[data-rollback]").forEach((btn) => {
btn.addEventListener("click", () => C.dangerous("rollback", btn.dataset.rollback, onDone));
});
}
function bindBackfillButton(root, selector, onDone) {
const el = root.querySelector(selector);
if (el) el.addEventListener("click", () => C.dangerous("backfill", null, onDone));
}
function bindReleaseDateReload(root, inputSel, btnSel, onDone) {
const btn = root.querySelector(btnSel);
if (!btn) return;
btn.addEventListener("click", async () => {
C.state.releaseDate = root.querySelector(inputSel).value.trim();
await C.pollBatches();
if (onDone) onDone();
});
}
return {
overviewView, sourcesView, jobsView, releaseView, datasetsView, auditView,
bindProbeButtons, bindRunButtons, bindRollbackButtons, bindBackfillButton, bindReleaseDateReload,
};
})();
-60
View File
@@ -1,60 +0,0 @@
/* C · 地层剖面 Strata —— 来源层→加工层→发布层→审计沉积层 四条全宽横带。
真实事件自上而下贯穿;整齐、纵向追踪;选中/关联状态解决跨层追踪费眼问题。 */
#st-root { padding: var(--sp3) var(--sp4); max-width: 1680px; margin: 0 auto; position: relative; }
#st-spine { position: absolute; left: 46px; top: 56px; bottom: 12px; width: 2px; background: var(--line); z-index: 0; }
#st-dot { position: absolute; left: 41px; width: 12px; height: 12px; border-radius: 50%; background: var(--packet); opacity: 0; z-index: 3; pointer-events: none; box-shadow: 0 0 0 4px var(--bg0); }
.st-band { position: relative; display: flex; gap: 16px; padding: 14px 16px 16px 62px; margin-bottom: 10px; z-index: 1; border-left: 3px solid transparent; transition: border-color var(--dur-ui) var(--ease-out), background var(--dur-ui) var(--ease-out); }
.st-band > div { min-width: 0; }
.st-band.pulse { border-left-color: var(--action); background: var(--action-soft); }
.st-band.pulse.err { border-left-color: var(--error); background: var(--error-soft); }
.st-band .st-idx { position: absolute; left: 6px; top: 14px; width: 36px; text-align: center; }
.st-band .st-idx .n { font-family: var(--font-mono); font-size: 18px; font-weight: 700; color: var(--t3); display: block; }
.st-band .st-idx .lb { font-size: 9.5px; color: var(--t3); letter-spacing: .12em; display: block; margin-top: 2px; }
#st-sources { display: grid; grid-template-columns: repeat(4, 1fr) auto; gap: 12px; flex: 1; min-width: 0; }
/* 防止内部宽表格用 min-width:auto 撑爆 1fr 轨道,参见 ledger.css 同类注释。 */
#st-sources > *, #st-process > *, #st-publish > * { min-width: 0; }
.st-src { padding: 8px 10px; cursor: pointer; border: 1px solid var(--line); border-radius: var(--r-card); transition: border-color var(--dur-ui) var(--ease-out); }
.st-src.sel { border-color: var(--action); box-shadow: 0 0 0 1px var(--action) inset; }
.st-src .row { display: flex; align-items: center; gap: 6px; }
.st-src .nm { font-size: 13px; font-weight: 600; color: var(--t1); flex: 1; }
.st-src .sub { font-size: 10.5px; color: var(--t3); margin-top: 2px; }
.st-src .lat { font-size: 11px; color: var(--t2); }
.st-reserved { align-self: center; font-size: 11px; color: var(--t3); white-space: nowrap; padding: 0 8px; }
#st-process { flex: 1; display: grid; grid-template-columns: 1.3fr 1fr; gap: 14px; }
.st-jobs .st-job { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--line-soft); font-size: 12px; cursor: pointer; border-radius: 4px; }
.st-jobs .st-job:last-child { border-bottom: 0; }
.st-jobs .st-job.sel { background: var(--action-soft); }
.st-jobs .st-job .nm { flex: 1; color: var(--t1); }
.st-jobs .st-job .at { color: var(--t3); font-size: 11px; }
.st-runs table.grid td, .st-runs table.grid th { padding: 4px 6px; font-size: 11px; }
.st-runs tr.hi td { background: var(--action-soft); }
#st-publish { flex: 1; display: grid; grid-template-columns: 220px 1fr; gap: 16px; }
.st-gate .bignum { display: flex; align-items: baseline; gap: 8px; }
.st-gate .bignum .n { font-size: var(--fs-hero); font-weight: 700; color: var(--t1); }
.st-gate .bignum .cap { font-size: 11px; color: var(--t3); }
.st-gate .win { font-size: 11px; color: var(--t2); margin-top: 8px; line-height: 1.7; }
.st-datasets table.grid td, .st-datasets table.grid th { padding: 4px 6px; font-size: 11px; }
.st-datasets tr.hi td { background: var(--action-soft); }
#st-audit { flex: 1; display: flex; flex-direction: column; gap: 6px; }
.st-audit-line { font-size: 11.5px; color: var(--t2); font-family: var(--font-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.st-audit-line b { color: var(--t1); }
.st-audit-line.warn { color: var(--act); }
@media (max-width: 1280px) {
#st-sources { grid-template-columns: repeat(2, 1fr); }
.st-reserved { grid-column: 1 / -1; }
#st-process, #st-publish { grid-template-columns: 1fr; }
}
@media (max-width: 1100px) {
#st-root { padding: var(--sp2) var(--sp3); }
#st-spine { left: 30px; }
#st-dot { left: 25px; }
.st-band { padding-left: 46px; gap: 10px; }
.st-band .st-idx { width: 26px; }
.st-band .st-idx .n { font-size: 14px; }
}
-248
View File
@@ -1,248 +0,0 @@
"use strict";
/* ==========================================================================
C · 地层剖面 Strata —— 来源层 → 加工层 → 发布层 → 审计沉积层 四条全宽横带。
真实事件自上而下贯穿(脊线上的光点从上一层滑到下一层);点击任一层的项目
进入"选中"态,用同一颜色在其它层高亮可关联的真实行,解决跨层追踪费眼问题。
========================================================================== */
(function () {
const C = window.Core, S = window.HubShared;
const { $, esc, timeShort } = C;
let root = null;
let mounted = false;
let pendingTimers = [];
function safeTimeout(fn, ms) {
const id = setTimeout(() => { if (mounted) fn(); }, ms);
pendingTimers.push(id);
return id;
}
let unsubs = [];
let selection = null; // { type: 'provider'|'job'|'dataset', value }
const SKELETON = `
<div id="st-root" class="page-shell">
<div id="st-spine"></div>
<div id="st-dot"></div>
<section class="st-band card" data-band="sources">
<div class="st-idx"><span class="n">01</span><span class="lb">SOURCES<br/>来源层</span></div>
<div id="st-sources"></div>
</section>
<section class="st-band card" data-band="process">
<div class="st-idx"><span class="n">02</span><span class="lb">PROCESS<br/>加工层</span></div>
<div id="st-process">
<div class="st-jobs">
<span class="sec-label">调度任务 JOBS</span>
<div id="st-jobList"></div>
</div>
<div class="st-runs">
<span class="sec-label">最近运行 RUNS</span>
<div id="st-runsTable"></div>
</div>
</div>
</section>
<section class="st-band card" data-band="publish">
<div class="st-idx"><span class="n">03</span><span class="lb">PUBLISH<br/>发布层</span></div>
<div id="st-publish">
<div class="st-gate">
<span class="sec-label">盘后发布 RELEASE</span>
<div class="bignum"><span class="n mono" id="st-pubnum">-</span><span class="cap">今日发布</span></div>
<div class="win mono" id="st-pubwin"></div>
<div class="toolrow" style="margin-top:8px;display:flex;gap:6px;">
<input id="st-rel-date" style="width:88px;font-family:var(--font-mono);font-size:11px;background:var(--bg2);border:1px solid var(--line);border-radius:5px;padding:4px 6px;color:var(--t1)" />
<button type="button" class="btn mini" id="st-rel-load">查看</button>
<button type="button" class="btn danger mini" id="st-rel-backfill">补数</button>
</div>
</div>
<div class="st-datasets">
<span class="sec-label">数据集 DATASETS</span>
<div id="st-datasetsTable"></div>
</div>
</div>
</section>
<section class="st-band card" data-band="audit">
<div class="st-idx"><span class="n">04</span><span class="lb">AUDIT<br/>审计层</span></div>
<div id="st-audit"></div>
</section>
</div>
`;
function applySelectionClasses() {
root.querySelectorAll("[data-sel-provider]").forEach((el) => el.classList.toggle("sel", selection && selection.type === "provider" && el.dataset.selProvider === selection.value));
root.querySelectorAll("[data-sel-job]").forEach((el) => el.classList.toggle("sel", selection && selection.type === "job" && el.dataset.selJob === selection.value));
root.querySelectorAll("[data-rel-job]").forEach((el) => el.classList.toggle("hi", selection && selection.type === "job" && el.dataset.relJob === selection.value));
root.querySelectorAll("[data-rel-dataset]").forEach((el) => el.classList.toggle("hi", selection && selection.type === "dataset" && el.dataset.relDataset === selection.value));
}
function setSelection(type, value) {
selection = (selection && selection.type === type && selection.value === value) ? null : { type, value };
applySelectionClasses();
}
function renderSources() {
const v = S.sourcesView();
const host = $("st-sources");
if (!v) { host.innerHTML = `<div class="muted">正在加载…</div>`; return; }
host.innerHTML = v.live.map((it) => {
const health = it.health || {};
const link = it._link || C.linkState(health);
return `<div class="st-src card" data-sel-provider="${esc(it.provider)}" data-provider="${esc(it.provider)}">
<div class="row">
<span class="lamp link ${C.linkLampClass(link)}"></span>
<span class="lamp act" data-act></span>
<span class="nm">${esc(C.FLOW_LABEL[it.provider] || it.provider)}</span>
${C.chipHtml(link, health.state || link)}
</div>
<div class="sub">${esc(C.FLOW_ROLE[it.provider] || it.role)} · 配置 ${(it.credential || {}).configured ? "已配置" : "未配置"} · ${it.calls_today ?? 0} 次</div>
<div class="row" style="margin-top:6px;justify-content:space-between">
<span class="lat mono">${health.latency_ms == null ? "-" : health.latency_ms + "ms"}</span>
<button class="btn ghost mini" data-probe="${esc(it.provider)}">探测一次</button>
</div>
</div>`;
}).join("") + (v.reserved.length ? `<div class="st-reserved">预留 ${v.reserved.length} 个<br/>未接入</div>` : "");
S.bindProbeButtons(host, () => renderSources());
host.querySelectorAll(".st-src").forEach((el) => el.addEventListener("click", (e) => {
if (e.target.closest("button")) return;
setSelection("provider", el.dataset.provider);
}));
applySelectionClasses();
}
function renderProcess() {
const v = S.jobsView();
const jobHost = $("st-jobList");
if (!v) { jobHost.innerHTML = `<div class="muted">正在加载…</div>`; return; }
jobHost.innerHTML = v.jobs.map((job) => {
const latest = v.latestByJob.get(job.id);
return `<div class="st-job" data-sel-job="${esc(job.id)}" data-job="${esc(job.id)}">
${C.chipHtml(latest ? latest.state : "idle")}
<span class="nm mono">${esc(job.id)}</span><span class="at mono">${esc(job.at)}</span>
<button class="btn ghost mini" data-run="${esc(job.id)}">触发</button>
</div>`;
}).join("");
S.bindRunButtons(jobHost, () => renderProcess());
jobHost.querySelectorAll(".st-job").forEach((el) => el.addEventListener("click", (e) => {
if (e.target.closest("button")) return;
setSelection("job", el.dataset.job);
}));
$("st-runsTable").innerHTML = C.table(["ID", "任务", "状态", "开始", "结束", "错误"], v.runs.map((r) => [
`<span class="mono" data-rel-job="${esc(r.job_id)}">${r.id}</span>`,
`<span class="mono k">${esc(r.job_id)}</span>`,
`<span class="mono ${r.state === "failed" ? "fail" : r.state === "running" ? "warn" : "ok"}">${esc(r.state)}</span>`,
`<span class="mono">${esc(timeShort(r.started_at))}</span>`, `<span class="mono">${esc(timeShort(r.finished_at))}</span>`,
`<span class="fail">${esc(r.error || "")}</span>`,
]));
root.querySelectorAll("#st-runsTable tr").forEach((tr, i) => {
if (i === 0) return;
const run = v.runs[i - 1]; if (run) tr.dataset.relJob = run.job_id;
});
applySelectionClasses();
}
function renderPublish() {
const v = S.releaseView();
const dateInput = $("st-rel-date");
if (!dateInput.value) dateInput.value = C.state.releaseDate;
if (!v) { $("st-pubnum").textContent = "-"; $("st-datasetsTable").innerHTML = `<div class="muted">正在加载…</div>`; return; }
$("st-pubnum").textContent = v.pubs.length;
const eod = (S.overviewView() || {}).eod || {};
$("st-pubwin").textContent = eod.state === "waiting_upstream" ? `等待上游 · 已试 ${eod.attempts ?? "-"}` : "";
$("st-datasetsTable").innerHTML = C.table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], v.pubs.map((p) => [
`<span class="mono k" data-rel-dataset="${esc(p.dataset)}">${esc(p.dataset)}</span>`,
`<span class="mono">${esc(p.active_batch)}</span>`, `<span class="mono">${esc(p.prev_batch || "-")}</span>`,
C.chipHtml(p.state), `<span class="mono">${esc(p.published_at)}</span>`,
p.prev_batch ? `<button class="btn danger mini" data-rollback="${esc(p.dataset)}">回滚</button>` : "-",
]));
root.querySelectorAll("#st-datasetsTable tr").forEach((tr, i) => {
if (i === 0) return;
const p = v.pubs[i - 1]; if (p) tr.dataset.relDataset = p.dataset;
});
S.bindRollbackButtons($("st-datasetsTable"), () => renderPublish());
applySelectionClasses();
}
function renderAudit() {
const v = S.auditView();
const host = $("st-audit");
if (!v) { host.innerHTML = `<div class="muted">正在加载…</div>`; return; }
host.innerHTML = v.items.slice(0, 8).map((a) => `
<div class="st-audit-line ${String(a.action).includes("rollback") ? "warn" : ""}">
${esc(timeShort(a.created_at))} <b>${esc(a.actor)}</b> ${esc(a.action)} · ${esc(a.target)}${a.detail ? " · " + esc(a.detail) : ""}
</div>`).join("");
}
function refresh() { if (!root) return; renderSources(); renderProcess(); renderPublish(); renderAudit(); }
/* ---------------------------------------------------------------- 纵向贯穿动效 */
const BAND_ORDER = ["sources", "process", "publish", "audit"];
function pulseBand(key, err) {
if (!root) return;
const el = root.querySelector(`.st-band[data-band="${key}"]`);
if (!el) return;
el.classList.add("pulse"); el.classList.toggle("err", !!err);
clearTimeout(el._t);
el._t = setTimeout(() => el.classList.remove("pulse", "err"), 700);
}
function travelSpine(fromKey, toKey) {
if (C.state.reduced || !root) return;
const dot = $("st-dot");
const a = root.querySelector(`.st-band[data-band="${fromKey}"]`);
const b = root.querySelector(`.st-band[data-band="${toKey}"]`);
if (!a || !b) return;
const rootRect = $("st-root").getBoundingClientRect();
const ar = a.getBoundingClientRect(), br = b.getBoundingClientRect();
const y0 = ar.top - rootRect.top + 20, y1 = br.top - rootRect.top + 20;
dot.style.top = y0 + "px"; dot.style.opacity = "0";
if (dot._anim) dot._anim.cancel();
dot._anim = dot.animate([
{ transform: "translateY(0)", opacity: 0 },
{ transform: "translateY(0)", opacity: 1, offset: .1 },
{ transform: `translateY(${y1 - y0}px)`, opacity: 1, offset: .9 },
{ transform: `translateY(${y1 - y0}px)`, opacity: 0 },
], { duration: 620, easing: "linear" });
}
function onEvent(evt) {
if (!root || !mounted) return;
if (C.FLOW_PROVIDERS.includes(evt.channel)) {
const act = root.querySelector(`.st-src[data-provider="${evt.channel}"] [data-act]`);
if (act) {
act.classList.remove("on", "err"); act.classList.add("on"); if (evt.kind === "error") act.classList.add("err");
setTimeout(() => act.classList.remove("on", "err"), 800);
}
pulseBand("sources", evt.kind === "error");
safeTimeout(() => { travelSpine("sources", "process"); pulseBand("process"); renderSources(); }, 200);
} else if (evt.channel === "junction") {
pulseBand("process", evt.kind === "error");
safeTimeout(() => { travelSpine("process", "publish"); pulseBand("publish"); renderProcess(); }, 200);
} else if (evt.channel === "tx") {
pulseBand("publish", evt.kind === "rollback");
safeTimeout(() => { travelSpine("publish", "audit"); pulseBand("audit", evt.kind === "rollback"); renderPublish(); }, 200);
} else if (evt.channel === "audit") {
pulseBand("audit", evt.kind === "rollback");
renderAudit();
}
}
function mount(el) {
root = el;
mounted = true;
selection = null;
root.innerHTML = SKELETON;
refresh();
S.bindReleaseDateReload(root, "#st-rel-date", "#st-rel-load", () => renderPublish());
S.bindBackfillButton(root, "#st-rel-backfill", () => renderPublish());
unsubs.push(C.Bus.on("data", refresh));
unsubs.push(C.Bus.on("event", onEvent));
}
function unmount() {
mounted = false;
unsubs.forEach((fn) => fn()); unsubs = [];
pendingTimers.forEach(clearTimeout); pendingTimers = [];
root = null;
}
window.HUB_LAYOUTS = window.HUB_LAYOUTS || {};
window.HUB_LAYOUTS.strata = { mount, unmount };
})();
-91
View File
@@ -1,91 +0,0 @@
"use strict";
/* ==========================================================================
xiaobai-datahub 管理后台 · 启动壳
会话/登录/改密/登出/主题按钮/退出确认 —— 与具体布局无关,三页共用同一份。
========================================================================== */
(function () {
const C = window.Core;
const { $ } = C;
function show(id) {
["login-view", "change-view", "shell"].forEach((key) => { $(key).hidden = key !== id; });
}
async function boot() {
try {
const session = await C.api("/admin/api/session");
C.state.csrf = session.csrf;
$("who").textContent = session.username;
if (session.must_change) { show("change-view"); return; }
show("shell");
enterShell();
} 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 C.api("/admin/api/login", {
method: "POST",
body: JSON.stringify({ username: form.get("username"), password: form.get("password") }),
});
C.state.csrf = result.csrf;
if (result.must_change) show("change-view");
else { show("shell"); enterShell(); }
} 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 C.api("/admin/api/change-password", {
method: "POST",
body: JSON.stringify({ current: form.get("current"), new_password: form.get("new_password") }),
});
show("shell");
enterShell();
} catch (err) {
$("change-error").hidden = false;
$("change-error").textContent = err.message;
}
});
$("logout-btn").addEventListener("click", async () => {
await C.api("/admin/api/logout", { method: "POST", body: "{}" });
C.Poller.stopAll();
Object.values(window.HUB_LAYOUTS || {}).forEach((impl) => { try { impl.unmount && impl.unmount(); } catch { /* ignore */ } });
show("login-view");
});
$("theme-btn").addEventListener("click", () => C.toggleTheme());
C.Bus.on("theme", (theme) => { $("theme-btn").textContent = theme === "night" ? "日间" : "夜间"; });
function updateCrumb() {
const el = $("crumb");
if (!el) return;
if (!C.state.online) { el.textContent = "网络已断开 · 已暂停实时"; return; }
if (!C.state.visible) { el.textContent = "已切至后台 · 已暂停动效"; return; }
const d = C.state.data.overview;
el.textContent = d ? `8766 · ${d.trade_date} · 持续运转` : "8766 · 四源汇流 · 持续运转";
}
C.Bus.on("runtime", updateCrumb);
C.Bus.on("data", updateCrumb);
function enterShell() {
C.bootTheme();
C.applyReduced(C.REDUCE_MQ.matches);
C.Poller.startAll();
updateCrumb();
C.Router.boot();
}
boot();
})();
-136
View File
@@ -1,136 +0,0 @@
/* xiaobai-datahub 数据中枢后台 —— A/B/C 三方向共用组件层
只依赖 tokens.css 的变量;三个布局文件(flowline.css / ledger.css / strata.css
只写各自独有的排布,不得重复定义这里已有的颜色/组件规则。 */
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background: var(--bg0); color: var(--t1); font-family: var(--font-cn);
font-size: var(--fs-body); -webkit-font-smoothing: antialiased;
}
.num, .mono { font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; }
.mono { font-family: var(--font-mono); }
.muted { color: var(--t3); }
.error, .fail { color: var(--error); }
.ok { color: var(--alive); }
.warn { color: var(--act); }
button { font-family: var(--font-cn); cursor: pointer; }
/* ---------- 区段标签:mono 小字大写,是整套系统的刻度尺 ---------- */
.sec-label {
font-family: var(--font-mono); font-size: var(--fs-label); letter-spacing: .14em;
text-transform: uppercase; color: var(--t3);
}
/* ---------- 卡片:只靠底色阶梯和 1px 发丝线分层 ---------- */
.card { background: var(--bg1); border: 1px solid var(--line); border-radius: var(--r-card); }
/* ---------- 状态 chip ---------- */
.chip {
display: inline-flex; align-items: center; gap: 5px; font-size: var(--fs-small);
line-height: 1; padding: 4px 8px; border-radius: var(--r-chip); border: 1px solid var(--line);
color: var(--t2); white-space: nowrap;
}
.chip::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none; }
.chip.ok { color: var(--alive); background: var(--alive-soft); border-color: transparent; }
.chip.err { color: var(--error); background: var(--error-soft); border-color: transparent; }
.chip.warn { color: var(--act); background: var(--act-soft); border-color: transparent; }
.chip.unknown { color: var(--unknown); }
.chip.unconfigured { color: var(--unconfigured); border-style: dashed; background: transparent; }
.chip.info { color: var(--action); background: var(--action-soft); border-color: transparent; }
/* ---------- 双灯:LINK=链路健康(低亮常亮),ACT=真实活动(事件时短闪) ---------- */
.lamp { width: 7px; height: 7px; border-radius: 50%; display: inline-block; flex: none; transition: background var(--dur-fast) var(--ease-out); }
.lamp.link.on { background: var(--alive); box-shadow: 0 0 6px rgba(60,203,154,.55); }
.lamp.link.off { background: var(--bg3); }
.lamp.link.err { background: var(--error); box-shadow: 0 0 6px rgba(229,83,75,.55); }
.lamp.act { background: var(--bg3); }
.lamp.act.on { background: var(--act); box-shadow: 0 0 7px rgba(240,167,60,.8); }
.lamp.act.on.err { background: var(--error); box-shadow: 0 0 7px rgba(229,83,75,.8); }
.lamp-tag { font-family: var(--font-mono); font-size: 9px; color: var(--t3); letter-spacing: .08em; }
/* ---------- 按钮:蓝色只给"可操作" ---------- */
.btn {
font-family: var(--font-cn); font-size: var(--fs-small); color: var(--action);
background: var(--action-soft); border: 1px solid transparent; border-radius: var(--r-chip);
padding: 5px 12px; cursor: pointer; transition: background var(--dur-fast) var(--ease-out), transform var(--dur-fast) var(--ease-out);
}
.btn:hover { background: var(--action); color: #fff; }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn.ghost { background: transparent; border-color: var(--line); color: var(--t2); }
.btn.ghost:hover { border-color: var(--action); color: var(--action); background: transparent; }
.btn.ghost.active { border-color: var(--action); color: var(--action); background: var(--action-soft); }
.btn.danger { color: var(--error); background: var(--error-soft); }
.btn.danger:hover { background: var(--error); color: #fff; }
.btn.mini { padding: 3px 8px; font-size: 11px; }
/* ---------- 表格 ---------- */
table.grid { width: 100%; border-collapse: collapse; font-size: var(--fs-small); }
table.grid th {
text-align: left; font-weight: 500; color: var(--t3); font-size: 11px;
padding: 6px 10px; border-bottom: 1px solid var(--line); white-space: nowrap;
}
table.grid td { padding: 6px 10px; color: var(--t2); border-bottom: 1px solid var(--line-soft); white-space: nowrap; }
table.grid td.k { color: var(--t1); }
table.grid tr:last-child td { border-bottom: none; }
/* ---------- 登录 / 改密(三页共用,跟布局无关) ---------- */
.auth-panel {
max-width: 420px; margin: 12vh auto; padding: 30px 32px; border-radius: 14px;
background: var(--glass); border: 1px solid var(--line); backdrop-filter: blur(14px);
box-shadow: var(--shadow-pop);
}
.auth-panel h1 { margin: 0 0 6px; font-size: 21px; }
.auth-panel label { display: block; margin: 12px 0; font-size: 13px; color: var(--t2); }
.auth-panel input {
width: 100%; margin-top: 6px; padding: 9px 11px; border: 1px solid var(--line); border-radius: 8px;
background: var(--bg2); color: var(--t1); font-family: var(--font-cn);
}
.auth-panel input:focus { outline: 2px solid var(--action); outline-offset: 1px; }
.auth-panel .badge-sim { display: inline-block; margin-bottom: 10px; font-size: 11px; padding: 3px 10px; border-radius: 999px; letter-spacing: .1em; color: var(--act); border: 1px solid var(--act); opacity: .85; }
.auth-panel button[type="submit"] {
background: var(--action); color: #fff; border: 0; border-radius: 8px; padding: 8px 16px; font-size: 13px;
}
/* ---------- 顶栏:品牌 + 交易日 + 阶段 + A/B/C 切换 + 全局控制 ---------- */
#topbar {
height: 48px; display: flex; align-items: center; gap: 12px; padding: 0 16px;
border-bottom: 1px solid var(--line); background: var(--bg1); position: sticky; top: 0; z-index: 20;
}
#topbar .title { font-size: 15px; font-weight: 600; color: var(--t1); white-space: nowrap; }
#topbar .title em { font-style: normal; color: var(--action); }
#topbar .vdiv { width: 1px; height: 16px; background: var(--line); flex: none; }
#topbar .tdate { font-size: 13px; color: var(--t2); white-space: nowrap; }
#topbar .spacer { flex: 1; }
#topbar .who { font-size: 12px; color: var(--t2); white-space: nowrap; }
/* A/B/C 切换:一直可见,写清中文名称,当前页状态明确 */
#layoutSwitch {
display: flex; align-items: center; gap: 2px; padding: 3px; border-radius: 8px;
background: var(--bg2); border: 1px solid var(--line); flex: none;
}
#layoutSwitch button {
font-family: var(--font-cn); font-size: 12.5px; color: var(--t2); background: transparent;
border: 0; border-radius: 6px; padding: 6px 11px; white-space: nowrap;
transition: background var(--dur-ui) var(--ease-out), color var(--dur-ui) var(--ease-out);
}
#layoutSwitch button .k { font-family: var(--font-mono); font-weight: 700; margin-right: 5px; opacity: .7; }
#layoutSwitch button[aria-current="page"] {
background: var(--action); color: #fff; box-shadow: var(--shadow-pop);
}
#layoutSwitch button[aria-current="page"] .k { opacity: 1; }
#layoutSwitch button:not([aria-current="page"]):hover { background: var(--bg3); color: var(--t1); }
/* ---------- 主内容容器 ---------- */
#page-root { min-height: calc(100vh - 48px); background: var(--bg0); }
.page-shell { padding: var(--sp4); max-width: 1584px; margin: 0 auto; }
/* 三方向表格容器统一命名以 Table 结尾:窄列/1024 档宁可局部横向滚动,
也不可撑破外层 grid/flex 轨道导致整页错位。 */
[id$="Table"] { overflow-x: auto; }
/* ---------- reduced-motion 全局兜底:不留半成品动效 ---------- */
@media (prefers-reduced-motion: reduce) {
* { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; }
}
html.reduced * { animation-duration: .001ms !important; transition-duration: .001ms !important; }
+51
View File
@@ -0,0 +1,51 @@
:root {
color-scheme: light;
--bg: #f4f5f7;
--surface: #ffffff;
--text: #1f2329;
--muted: #646a73;
--line: #dee0e3;
--action: #3370ff;
--danger: #e04536;
--ok: #16a34a;
--warn: #b45309;
--radius: 8px;
--pad: 16px;
font-family: "Segoe UI", "PingFang SC", "Noto Sans SC", sans-serif;
}
:root[data-theme="night"] {
color-scheme: dark;
--bg: #111318;
--surface: #1b1e24;
--text: #e8eaed;
--muted: #9aa0a6;
--line: #2a2f38;
--action: #5b8cff;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); }
.panel, header.top, nav, main { background: var(--surface); }
.auth-panel { max-width: 420px; margin: 12vh auto; padding: 28px; border-radius: var(--radius); border: 1px solid var(--line); }
label { display: block; margin: 12px 0; }
input, select { width: 100%; padding: 8px 10px; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); color: var(--text); }
button { background: var(--action); color: #fff; border: 0; border-radius: 4px; padding: 8px 14px; cursor: pointer; }
button.ghost { background: transparent; color: var(--text); border: 1px solid var(--line); }
button.danger { background: var(--danger); }
.muted { color: var(--muted); }
.error { color: var(--danger); }
.top { display: flex; gap: 12px; align-items: center; padding: 10px var(--pad); border-bottom: 1px solid var(--line); }
nav { display: flex; gap: 4px; padding: 8px var(--pad); border-bottom: 1px solid var(--line); }
nav button { background: transparent; color: var(--muted); }
nav button.active { color: var(--action); background: transparent; font-weight: 600; }
main { padding: var(--pad); min-height: calc(100vh - 96px); }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 16px; }
.card { border: 1px solid var(--line); border-radius: var(--radius); padding: 12px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; padding: 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
.pill { font-size: 12px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); }
.ok { color: var(--ok); }
.warn { color: var(--warn); }
.fail { color: var(--danger); }
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; align-items: end; }
.toolbar label { margin: 0; }
dialog { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); color: var(--text); padding: 20px; }
-79
View File
@@ -1,79 +0,0 @@
/* xiaobai-datahub 数据中枢后台 —— 第九版视觉基础 Token(A/B/C 三方向共用,唯一来源)
来源:HEL-545 视觉规范 + prototype-A-flowline.html 原稿。
原则:精密、克制、成熟。禁止霓虹泛光、粒子、发光描边、蓝图网格、星球/轨道造型、大蓝球/星空。
任何视觉改动只改这个文件里的变量,禁止在各布局 CSS 里另起一套颜色/间距。 */
:root {
color-scheme: light;
/* ---- 日间(默认) ---- */
--bg0: #F4F6F8;
--bg1: #FFFFFF;
--bg2: #EEF1F4;
--bg3: #E4E9EF;
--line: rgba(15, 23, 42, .12);
--line-soft: rgba(15, 23, 42, .06);
--t1: #16202C;
--t2: #4C5C6F;
--t3: #8593A5;
--action: #2F6FE4;
--action-soft: rgba(47, 111, 228, .10);
--alive: #0E9F6E;
--alive-soft: rgba(14, 159, 110, .10);
--act: #C77F1A;
--act-soft: rgba(199, 127, 26, .12);
--error: #D0342C;
--error-soft: rgba(208, 52, 44, .10);
--unknown: #7B8898;
--unconfigured: #9AA7B5;
--packet: #2F6FE4;
--shadow-pop: 0 8px 24px rgba(15, 23, 42, .12);
--glass: rgba(255, 255, 255, .86);
--font-cn: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", system-ui, sans-serif;
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", Menlo, Consolas, monospace;
--fs-hero: 22px;
--fs-title: 15px;
--fs-body: 13px;
--fs-small: 12px;
--fs-label: 10.5px;
--sp1: 4px; --sp2: 8px; --sp3: 12px; --sp4: 16px; --sp5: 24px; --sp6: 32px;
--r-card: 8px; --r-chip: 5px; --r-pill: 999px;
--ease-out: cubic-bezier(.23, 1, .32, 1);
--dur-fast: 130ms;
--dur-ui: 220ms;
--dur-travel: 900ms;
}
:root[data-theme="night"] {
color-scheme: dark;
--bg0: #0A0E13;
--bg1: #10151C;
--bg2: #161D26;
--bg3: #1C2530;
--line: rgba(148, 163, 184, .16);
--line-soft: rgba(148, 163, 184, .08);
--t1: #E8EDF4;
--t2: #97A4B6;
--t3: #5E6C7F;
--action: #4C8DFF;
--action-soft: rgba(76, 141, 255, .14);
--alive: #3CCB9A;
--alive-soft: rgba(60, 203, 154, .13);
--act: #F0A73C;
--act-soft: rgba(240, 167, 60, .13);
--error: #E5534B;
--error-soft: rgba(229, 83, 75, .13);
--unknown: #8B98A9;
--unconfigured: #5A6878;
--packet: #8FB8FF;
--shadow-pop: 0 8px 24px rgba(0, 0, 0, .45);
--glass: rgba(13, 18, 28, .82);
}
+50
View File
@@ -6,9 +6,12 @@ from typing import Any
from datahub.adapters import RESERVED
from datahub.auth import AuthService
from datahub.db import HubDB
from datahub import lineage as lineage_module
from datahub import observability
from datahub.pipeline import OFFICIAL_DATASETS, STOCKS_DATASET, Pipeline
from datahub.scheduler import Scheduler
from datahub.serving import ApiError
from datahub import source_catalog
from datahub.timeutil import isoformat, now_shanghai, session_phase, yyyymmdd
@@ -105,6 +108,53 @@ class AdminAPI:
raise ApiError("INVALID_ARGUMENT", f"unknown provider: {provider}")
return adapter.probe()
# ------------------------------------------------------------------
# HEL-543: read-only side-channel status/catalog/lineage. These never
# change routing, credentials, or adapters; they only read the
# provider_call_log/provider_health tables (observability.py) plus the
# static registries in source_catalog.py / lineage.py.
# ------------------------------------------------------------------
def providers_status(self, provider: str = "", limit: int = 50) -> dict[str, Any]:
if not observability.is_enabled(self.db):
return {"enabled": False, "health": [], "recent_calls": []}
limit = max(1, min(int(limit or 50), 200))
health_sql = "SELECT * FROM provider_health"
params: tuple[Any, ...] = ()
if provider:
health_sql += " WHERE provider = ?"
params = (provider,)
health_sql += " ORDER BY provider, interface"
health = self.db.fetchall(health_sql, params)
calls_sql = "SELECT * FROM provider_call_log"
if provider:
calls_sql += " WHERE provider = ?"
calls_sql += " ORDER BY id DESC LIMIT ?"
recent = self.db.fetchall(calls_sql, (*params, limit))
return {"enabled": True, "health": health, "recent_calls": recent}
def source_catalog(self) -> dict[str, Any]:
if not observability.is_enabled(self.db):
return {"enabled": False, "items": []}
return {"enabled": True, "items": source_catalog.snapshot(self.db, self.auth)}
def lineage(self, trade_date: str = "") -> dict[str, Any]:
day = yyyymmdd(trade_date) if trade_date else yyyymmdd(now_shanghai())
if not observability.is_enabled(self.db):
return {"enabled": False, "trade_date": day, "items": []}
return {"enabled": True, "trade_date": day, "items": lineage_module.snapshot(self.db, day)}
def lineage_affected(self, provider: str = "", interface: str = "") -> dict[str, Any]:
if not provider:
raise ApiError("INVALID_ARGUMENT", "provider is required")
if not observability.is_enabled(self.db):
return {"enabled": False, "provider": provider, "interface": interface, "items": []}
return {
"enabled": True,
"provider": provider,
"interface": interface,
"items": lineage_module.affected(self.db, provider, interface),
}
def jobs(self) -> dict[str, Any]:
runs = self.db.fetchall("SELECT * FROM job_runs ORDER BY id DESC LIMIT 100")
stocks_times = "/".join(self.pipeline.settings.stocks_refresh_times) or "20:00"
+2 -1
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from typing import Any
from datahub.datasets_ext import EXTENDED_DATASET_TABLES, EXTENDED_SCHEMA
from datahub.observability import OBS_SCHEMA
from datahub.timeutil import isoformat
_BASE_SCHEMA = """
@@ -296,7 +297,7 @@ CREATE INDEX IF NOT EXISTS idx_eod_bars_date ON eod_bars(trade_date, batch_id);
CREATE INDEX IF NOT EXISTS idx_calendar_open ON trade_calendar(is_open, cal_date);
"""
SCHEMA = _BASE_SCHEMA + EXTENDED_SCHEMA
SCHEMA = _BASE_SCHEMA + EXTENDED_SCHEMA + OBS_SCHEMA
DATASET_TABLES = {
"daily": ("eod_bars", "staging_bars"),
+20
View File
@@ -122,6 +122,26 @@ class HubRequestHandler(BaseHTTPRequestHandler):
if path == "/admin/api/sources" and method == "GET":
self._json(self.hub.admin.sources(), HTTPStatus.OK)
return
if path == "/admin/api/providers/status" and method == "GET":
query = parse_query(urlparse(self.path).query)
provider = (query.get("provider") or [""])[0]
limit = (query.get("limit") or ["50"])[0]
self._json(self.hub.admin.providers_status(provider, int(limit or 50)), HTTPStatus.OK)
return
if path == "/admin/api/source-catalog" and method == "GET":
self._json(self.hub.admin.source_catalog(), HTTPStatus.OK)
return
if path == "/admin/api/lineage" and method == "GET":
query = parse_query(urlparse(self.path).query)
date = (query.get("date") or [""])[0]
self._json(self.hub.admin.lineage(date), HTTPStatus.OK)
return
if path == "/admin/api/lineage/affected" and method == "GET":
query = parse_query(urlparse(self.path).query)
provider = (query.get("provider") or [""])[0]
interface = (query.get("interface") or [""])[0]
self._json(self.hub.admin.lineage_affected(provider, interface), HTTPStatus.OK)
return
if path.startswith("/admin/api/sources/") and path.endswith("/probe") and method == "POST":
provider = path.split("/")[4]
self._json(self.hub.admin.probe(provider), HTTPStatus.OK)
+6
View File
@@ -24,6 +24,12 @@ class Hub:
raise SystemExit("DATAHUB_ENCRYPTION_KEY 未配置")
self.settings = settings
self.db = HubDB(settings.db_path)
# HEL-543: carry the observability kill switch on the db handle so
# every call site that already threads `db` through (pipeline,
# realtime_serve, steward, admin_api) picks it up for free with no
# extra plumbing. Missing this attribute (e.g. a bare HubDB built
# directly in tests) defaults to enabled — see observability.is_enabled.
self.db.observability_enabled = settings.observability_enabled
self.vault = SecretVault(settings.encryption_key)
self.auth = AuthService(self.db, self.vault, settings.api_token, settings.admin_password)
token = settings.tushare_token or self.auth.load_credential("tushare_token")
+258
View File
@@ -0,0 +1,258 @@
"""Read-only lineage/impact inventory (HEL-543).
Answers, without changing any routing decision: for a given main-site data
item, which datahub dataset backs it, which provider/interface currently
serves it (primary and backup), and — when a provider/interface is
unhealthy — which datasets and, best-effort, which main-site consumers are
affected.
Every row cites where it was verified so a reviewer does not have to trust
a paraphrase:
- ``v1_endpoint``/``primary_source``/``backup_source`` are taken verbatim
from ``datahub/serving.py`` (the ``source=`` string literal passed to
``_published_rows``/``_official_meta``) or from the provider/interface
pairs wired into ``datahub/realtime_serve.py`` for HEL-543.
- ``known_consumers`` lists only call sites this round actually found via
code search in the ``xiaobai-review`` website tree (cited as
``file:line`` in the comment above each dataset). Anything not backed by
a citation is left out rather than guessed; a fuller page-by-page map is
tracked separately (HEL-549) and can extend this table later without
touching its shape.
This module never talks to a provider and never mutates anything; it only
reads ``provider_health``/``provider_call_log`` (HEL-543) and the existing
``publications``/``batches`` tables to attach live status to each row.
"""
from __future__ import annotations
from typing import Any
# Verified against backend/features/screener/data_sync.py (calendar,
# stock_basic, daily, daily_basic, index_daily-as-benchmark, stk_auction,
# moneyflow, ths_hot, dc_hot all called via `self.client.query(...)`) and
# backend/features/heaven/market_context.py (stock_basic, index_daily via
# `self._tushare_client().query(...)` / `client.query(...)`).
DATASETS: list[dict[str, Any]] = [
{
"dataset": "calendar",
"tier": "official",
"v1_endpoint": "/v1/calendar",
"primary_source": "tushare:trade_cal",
"backup_source": None,
"known_consumers": ["智能选股(data_sync.py 交易日历解析)", "问天(交易日推算)"],
},
{
"dataset": "stocks",
"tier": "official",
"v1_endpoint": "/v1/stocks",
"primary_source": "tushare:stock_basic",
"backup_source": None,
"known_consumers": ["智能选股(股票主档)", "问天(market_context.py 股票代码/名称解析)"],
},
{
"dataset": "daily",
"tier": "official",
"v1_endpoint": "/v1/bars/daily",
"primary_source": "tushare:daily",
"backup_source": None,
"known_consumers": ["智能选股(data_sync.py 日K因子)", "交易复盘/个股详情日K图表"],
},
{
"dataset": "valuation",
"tier": "official",
"v1_endpoint": "/v1/valuation",
"primary_source": "tushare:daily_basic",
"backup_source": None,
"known_consumers": ["智能选股(data_sync.py 估值因子)"],
},
{
"dataset": "moneyflow",
"tier": "official",
"v1_endpoint": "/v1/moneyflow",
"primary_source": "tushare:moneyflow",
"backup_source": None,
"known_consumers": ["智能选股(data_sync.py 资金流因子)", "个股详情资金流"],
},
{
"dataset": "auction",
"tier": "official",
"v1_endpoint": "/v1/auction",
"primary_source": "tushare:stk_auction",
"backup_source": None,
"known_consumers": ["智能选股(data_sync.py 竞价快照)", "竞价板块"],
},
{
"dataset": "index_daily",
"tier": "official",
"v1_endpoint": "/v1/indexes/bars",
"primary_source": "tushare:index_daily",
"backup_source": None,
"known_consumers": ["问天(market_context.py 指数近20日走势)", "智能选股(基准回看)"],
},
{
"dataset": "limit_events",
"tier": "official",
"v1_endpoint": "/v1/limit-events",
"primary_source": "tushare:limit_list_d",
"backup_source": None,
"known_consumers": ["涨停梯队(历史/盘后视图)"],
},
{
"dataset": "popularity",
"tier": "official",
"v1_endpoint": "/v1/popularity",
"primary_source": "tushare:ths_hot+dc_hot",
"backup_source": None,
"known_consumers": ["人气榜", "智能选股(data_sync.py 人气因子)"],
},
{
"dataset": "dragon_tiger",
"tier": "official",
"v1_endpoint": "/v1/dragon-tiger",
"primary_source": "tushare:hm_detail",
"backup_source": None,
"known_consumers": ["龙虎榜"],
},
{
"dataset": "sector_daily",
"tier": "official",
"v1_endpoint": "/v1/sectors",
"primary_source": "tushare:ths_daily+dc_index+sw_daily",
"backup_source": None,
"known_consumers": ["主题轮动", "板块梯队"],
},
{
"dataset": "quotes_latest",
"tier": "provisional",
"v1_endpoint": "/v1/quotes/latest",
"primary_source": "eastmoney:ulist/clist",
"backup_source": "tencent:qt",
"known_consumers": ["竞价/股票池盘中价格", "情绪周期盘中快照"],
},
{
"dataset": "index_quotes",
"tier": "provisional",
"v1_endpoint": "/v1/indexes/quotes",
"primary_source": "eastmoney:ulist",
"backup_source": "tencent:qt",
"known_consumers": ["首页大盘指数条"],
},
{
"dataset": "sectors_quote",
"tier": "provisional",
"v1_endpoint": "/v1/sectors/quote",
"primary_source": "eastmoney:sw",
"backup_source": None,
"known_consumers": ["主题轮动盘中板块报价"],
},
{
"dataset": "limit_pool",
"tier": "provisional",
"v1_endpoint": "/v1/limit-pool",
"primary_source": "eastmoney:zt_pool",
"backup_source": None,
"known_consumers": ["涨停梯队盘中视图"],
},
{
"dataset": "intraday_points",
"tier": "provisional",
"v1_endpoint": "/v1/intraday/points",
"primary_source": "eastmoney:trends2",
"backup_source": None,
"known_consumers": ["个股详情分时图"],
},
{
"dataset": "ifind_wencai",
"tier": "licensed",
"v1_endpoint": "/v1/query (api_name=ifind_wencai)",
"primary_source": "ifind:smart_stock_picking",
"backup_source": None,
"known_consumers": ["问师(自然语言选股,需 iFinD 凭证)"],
},
]
_KNOWN_PROVIDERS_BY_SOURCE_PREFIX = ("tushare", "eastmoney", "tencent", "ifind")
def _providers_for(primary_source: str, backup_source: str | None) -> list[str]:
providers: list[str] = []
for source in (primary_source, backup_source or ""):
for provider in _KNOWN_PROVIDERS_BY_SOURCE_PREFIX:
if source.startswith(provider) and provider not in providers:
providers.append(provider)
return providers
def snapshot(db: Any, trade_date: str = "") -> list[dict[str, Any]]:
"""Attach live status to the static lineage table. Read-only; never
raises (a per-row status lookup failure just leaves that row's status
empty rather than failing the whole snapshot)."""
result: list[dict[str, Any]] = []
for entry in DATASETS:
row = dict(entry)
providers = _providers_for(entry["primary_source"], entry.get("backup_source"))
row["providers"] = providers
live: list[dict[str, Any]] = []
try:
if db is not None and providers:
placeholders = ",".join("?" for _ in providers)
live = db.fetchall(
f"SELECT provider, interface, state, last_error, last_fallback_reason, "
f"consec_failures, updated_at FROM provider_health WHERE provider IN ({placeholders})",
tuple(providers),
)
except Exception:
live = []
row["live_provider_health"] = live
if entry["tier"] == "official":
pub = None
try:
if db is not None and trade_date:
pub = db.fetchone(
"SELECT dataset, trade_date, state, published_at FROM publications "
"WHERE dataset = ? AND trade_date = ?",
(entry["dataset"], trade_date),
)
except Exception:
pub = None
row["publication"] = pub
result.append(row)
return result
def affected(db: Any, provider: str = "", interface: str = "") -> list[dict[str, Any]]:
"""Read-only: which datasets/pages are impacted by a given provider (and,
optionally, a specific interface) right now. Does not change routing."""
provider = str(provider or "").strip()
interface = str(interface or "").strip()
result: list[dict[str, Any]] = []
for entry in DATASETS:
providers = _providers_for(entry["primary_source"], entry.get("backup_source"))
if provider and provider not in providers:
continue
row = dict(entry)
row["providers"] = providers
health: list[dict[str, Any]] = []
try:
if db is not None:
if interface:
health = db.fetchall(
"SELECT provider, interface, state, last_error, last_fallback_reason, "
"consec_failures, updated_at FROM provider_health "
"WHERE provider = ? AND interface = ?",
(provider, interface),
)
elif providers:
placeholders = ",".join("?" for _ in providers)
health = db.fetchall(
f"SELECT provider, interface, state, last_error, last_fallback_reason, "
f"consec_failures, updated_at FROM provider_health WHERE provider IN ({placeholders})",
tuple(providers),
)
except Exception:
health = []
row["live_provider_health"] = health
result.append(row)
return result
+319
View File
@@ -0,0 +1,319 @@
"""Side-channel provider-call observability (HEL-543).
This module is additive-only and must never change what any existing call
returns or raises. It exists purely to answer, after the fact and without
touching routing: which provider/interface was called, whether it
succeeded, how stale/complete the payload looked, and why a fallback fired.
Hard rules enforced here:
- Every public entry point (`observe`, `record_call`) is wrapped so that a
database failure, a classifier bug, or any other internal error is
swallowed and logged at DEBUG level. It never raises into the caller and
never delays/blocks the caller's real data path beyond a best-effort
timing measurement.
- `observe()` always returns exactly what `fn()` returned, and re-raises
exactly what `fn()` raised (same exception object, unmodified). It does
not retry, does not change ordering, and does not add new failure modes.
- No mock data is ever produced or returned by this module.
"""
from __future__ import annotations
import re
import time
from typing import Any, Callable, TypeVar
from datahub.timeutil import isoformat, now_shanghai
_T = TypeVar("_T")
# Additive-only schema: two new tables, no changes to any existing table.
# Merged into datahub.db.SCHEMA the same way EXTENDED_SCHEMA is.
OBS_SCHEMA = """
CREATE TABLE IF NOT EXISTS provider_call_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL,
interface TEXT NOT NULL,
fetched_at TEXT NOT NULL,
latency_ms INTEGER,
status TEXT NOT NULL,
error TEXT,
fallback_reason TEXT,
data_age_seconds INTEGER,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS provider_health (
provider TEXT NOT NULL,
interface TEXT NOT NULL,
state TEXT NOT NULL,
last_ok_at TEXT,
last_error TEXT,
last_fallback_reason TEXT,
consec_failures INTEGER NOT NULL DEFAULT 0,
last_latency_ms INTEGER,
last_data_age_seconds INTEGER,
updated_at TEXT NOT NULL,
PRIMARY KEY (provider, interface)
);
CREATE INDEX IF NOT EXISTS idx_provider_call_log_created ON provider_call_log(created_at);
CREATE INDEX IF NOT EXISTS idx_provider_call_log_provider ON provider_call_log(provider, interface, created_at);
"""
DEFAULT_STALE_SECONDS = 300
_BLOCKED_MARKERS = (
"<!doctype", "<html", "expecting value", "verify you are human",
"unusual traffic", "captcha", "安全验证", "访问异常", "请完成验证",
"拦截", "禁止访问", "forbidden",
)
# Matches provider messages like "Eastmoney returned 0/3 indices" or
# "Tencent returned 2/3 indices" (see adapters/eastmoney.py, adapters/tencent.py).
_COUNT_MISMATCH_RE = re.compile(r"returned (\d+)\s*/\s*(\d+)")
def _logger():
from datahub.logutil import get_logger
return get_logger()
def is_enabled(db: Any) -> bool:
"""Runtime kill switch (``Settings.observability_enabled`` /
``DATAHUB_OBSERVABILITY``, wired onto the db handle in ``Hub.__init__``).
Defaults to enabled when the attribute is absent — e.g. a bare ``HubDB``
built directly in a test, or any call site that predates HEL-543 — so
this can never accidentally disable an existing deployment. Never
raises.
"""
try:
return bool(getattr(db, "observability_enabled", True))
except Exception: # pragma: no cover - defensive, must never raise
return True
def classify_error(message: str) -> tuple[str, str]:
"""Best-effort, side-reading classification of an exception message.
Never raises. Unknown shapes fall back to a generic ``error`` status so a
classifier miss can never be mistaken for a healthy call.
"""
try:
lower = (message or "").lower()
if any(marker in lower for marker in _BLOCKED_MARKERS):
return "blocked", "response_looks_like_intercept_page"
if "timeout" in lower or "timed out" in lower:
return "timeout", "request_timeout"
mismatch = _COUNT_MISMATCH_RE.search(lower)
if mismatch and int(mismatch.group(1)) == 0:
return "empty", "empty_or_incomplete_response"
if mismatch:
return "degraded", "partial_or_mismatched_response"
if "empty" in lower or "no intraday chart data" in lower or "missing" in lower:
return "empty", "empty_or_incomplete_response"
if "too small" in lower or "incomplete" in lower or "mismatch" in lower:
return "degraded", "partial_or_mismatched_response"
return "error", ""
except Exception: # pragma: no cover - defensive, must never raise
return "error", ""
def classify_rows(
rows: Any,
*,
required_fields: tuple[str, ...] | None = None,
freshness_field: str | None = "quote_time_epoch",
max_age_seconds: int = DEFAULT_STALE_SECONDS,
) -> tuple[str, str, int | None]:
"""Read-only classification of an already-successful payload.
Only ever called on a value a caller is about to use as-is; this never
mutates ``rows`` and a classifier bug always degrades to ``("ok", "",
None)`` rather than mislabeling a real success as a failure.
"""
try:
if isinstance(rows, dict):
items = [rows] if rows else []
elif isinstance(rows, (list, tuple)):
items = [item for item in rows if isinstance(item, dict)]
else:
items = []
if not items:
return "empty", "no_rows_returned", None
if required_fields:
missing: set[str] = set()
for item in items:
for field in required_fields:
if item.get(field) in (None, ""):
missing.add(field)
if missing:
return "missing_fields", "missing:" + ",".join(sorted(missing)), None
data_age: int | None = None
if freshness_field:
now_epoch = time.time()
ages: list[int] = []
for item in items:
raw = item.get(freshness_field)
try:
epoch = int(raw or 0)
except (TypeError, ValueError):
epoch = 0
if epoch > 0:
ages.append(max(0, int(now_epoch - epoch)))
if ages:
data_age = max(ages)
if data_age > max_age_seconds:
return "stale", "data_age_exceeds_threshold", data_age
return "ok", "", data_age
except Exception: # pragma: no cover - defensive, must never raise
return "ok", "", None
def record_call(
db: Any,
provider: str,
interface: str,
*,
status: str,
latency_ms: int | None = None,
error: str = "",
fallback_reason: str = "",
data_age_seconds: int | None = None,
) -> None:
"""Fail-open recorder. Never raises; a write failure here must never be
able to take down a real, otherwise-successful data path."""
if db is None or not is_enabled(db):
return
try:
now = isoformat(now_shanghai())
ok = status == "ok"
error_text = (error or "")[:500]
reason_text = (fallback_reason or "")[:200]
with db.write() as connection:
connection.execute(
"INSERT INTO provider_call_log("
"provider, interface, fetched_at, latency_ms, status, error, "
"fallback_reason, data_age_seconds, created_at) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(provider, interface, now, latency_ms, status, error_text, reason_text, data_age_seconds, now),
)
connection.execute(
"""
INSERT INTO provider_health(
provider, interface, state, last_ok_at, last_error, last_fallback_reason,
consec_failures, last_latency_ms, last_data_age_seconds, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(provider, interface) DO UPDATE SET
state = excluded.state,
last_ok_at = CASE WHEN excluded.state = 'ok' THEN excluded.last_ok_at ELSE provider_health.last_ok_at END,
last_error = CASE WHEN excluded.state = 'ok' THEN '' ELSE excluded.last_error END,
last_fallback_reason = excluded.last_fallback_reason,
consec_failures = CASE WHEN excluded.state = 'ok' THEN 0 ELSE provider_health.consec_failures + 1 END,
last_latency_ms = excluded.last_latency_ms,
last_data_age_seconds = excluded.last_data_age_seconds,
updated_at = excluded.updated_at
""",
(
provider,
interface,
status,
now if ok else None,
"" if ok else (error_text or reason_text or "unknown_error"),
reason_text,
0 if ok else 1,
latency_ms,
data_age_seconds,
now,
),
)
except Exception: # pragma: no cover - defensive, must never raise
try:
_logger().debug(
"observability record_call failed (fail-open)",
extra={"hub": {"provider": provider, "interface": interface}},
exc_info=True,
)
except Exception:
pass
def _safe_record_failure(db: Any, provider: str, interface: str, latency_ms: int, exc: BaseException) -> None:
if db is None:
return
try:
message = str(exc)
status, reason = classify_error(message)
record_call(
db, provider, interface,
status=status, latency_ms=latency_ms, error=message, fallback_reason=reason,
)
except Exception: # pragma: no cover - defensive, must never raise
pass
def _safe_record_success(
db: Any,
provider: str,
interface: str,
latency_ms: int,
result: Any,
classify: Callable[[Any], tuple[str, str, int | None] | None] | None,
) -> None:
if db is None:
return
status, reason, data_age = "ok", "", None
if classify is not None:
try:
classified = classify(result)
if classified:
status, reason, data_age = classified
except Exception: # pragma: no cover - defensive, must never raise
# A classifier bug must never mislabel (or hide) a real success;
# degrade to a plain "ok" call rather than skipping the log.
status, reason, data_age = "ok", "", None
try:
record_call(
db, provider, interface,
status=status, latency_ms=latency_ms, fallback_reason=reason, data_age_seconds=data_age,
)
except Exception: # pragma: no cover - defensive, must never raise
pass
def observe(
db: Any,
provider: str,
interface: str,
fn: Callable[[], _T],
*,
classify: Callable[[Any], tuple[str, str, int | None] | None] | None = None,
) -> _T:
"""Call ``fn()`` and record a side-channel status row.
Returns exactly what ``fn()`` returns and re-raises exactly what
``fn()`` raises. ``db`` may be ``None`` (e.g. in call sites that are not
wired to a database yet); in that case this is a transparent passthrough
with no recording at all. Same when the ``DATAHUB_OBSERVABILITY`` kill
switch is off (see ``is_enabled``): this becomes ``return fn()`` with no
timing, no classification, and no db access whatsoever.
"""
if db is not None and not is_enabled(db):
return fn()
started = time.perf_counter()
try:
result = fn()
except Exception:
latency_ms = round((time.perf_counter() - started) * 1000)
import sys
exc = sys.exc_info()[1]
if exc is not None:
_safe_record_failure(db, provider, interface, latency_ms, exc)
raise
latency_ms = round((time.perf_counter() - started) * 1000)
_safe_record_success(db, provider, interface, latency_ms, result, classify)
return result
+14
View File
@@ -20,6 +20,7 @@ from datahub.governance.ratelimit import TokenBucket
from datahub.governance.retry import RetryError, retry_call
from datahub.logutil import get_logger
from datahub.normalize import finite_number, normalize_daily
from datahub import observability
from datahub.revision import (
compare_fields,
diff_published_vs_upstream,
@@ -1634,6 +1635,7 @@ class Pipeline:
deleted += cur.rowcount
connection.execute("DELETE FROM job_runs WHERE started_at < ?", (cutoff_jobs,))
connection.execute("DELETE FROM src_calls WHERE created_at < ?", (cutoff_jobs,))
connection.execute("DELETE FROM provider_call_log WHERE created_at < ?", (cutoff_jobs,))
return {"staging_deleted": deleted}
def audit(self, actor: str, action: str, target: str = "", detail: str = "") -> None:
@@ -1768,6 +1770,18 @@ class Pipeline:
"INSERT INTO src_calls(provider, endpoint, ok, latency_ms, error, created_at) VALUES (?,?,?,?,?,?)",
("tushare", endpoint, 1 if ok else 0, latency_ms, error, isoformat(self.clock())),
)
# HEL-543 side channel: unified cross-provider call log/health. Kept
# strictly additive and fail-open; the src_calls insert above (the
# existing, already-compatible Tushare record) is unaffected either
# way.
if ok:
status, reason = "ok", ""
else:
status, reason = observability.classify_error(error)
observability.record_call(
self.db, "tushare", endpoint,
status=status, latency_ms=latency_ms, error=error, fallback_reason=reason,
)
def _persist_health(self, state: str, error: str = "") -> None:
snap = self.breaker.snapshot()
+58 -11
View File
@@ -18,6 +18,7 @@ from datahub.adapters.tencent import TencentAdapter
from datahub.codes import resolve_code
from datahub.db import HubDB
from datahub.governance.lkg import LastKnownGood
from datahub import observability
from datahub.timeutil import isoformat, now_shanghai, yyyymmdd
QUOTE_TTL = 60
@@ -25,6 +26,23 @@ INDEX_TTL = 60
INTRADAY_TTL = 20
QUOTE_BATCH = 60
# HEL-543 side-channel classifiers. These only *read* an already-successful
# payload to decide what to log; they never change the payload itself and a
# classifier exception always degrades to "ok" (see observability.classify_rows).
def _classify_quote_rows(rows: Any) -> tuple[str, str, int | None]:
return observability.classify_rows(rows, freshness_field="quote_time_epoch")
def _classify_rows_no_freshness(rows: Any) -> tuple[str, str, int | None]:
return observability.classify_rows(rows, freshness_field=None)
def _classify_intraday_payload(data: Any) -> tuple[str, str, int | None]:
points = data.get("points") if isinstance(data, dict) else None
return observability.classify_rows(points or [], freshness_field=None)
class RealtimeApiError(RuntimeError):
def __init__(self, code: str, message: str) -> None:
@@ -44,12 +62,17 @@ def fetch_index_quotes(db: HubDB) -> dict[str, Any]:
cached = _read_cache(db, cache_key)
if cached is not None:
return cached
eastmoney = EastmoneyAdapter()
try:
rows = eastmoney.fetch_indices()
rows = observability.observe(
db, "eastmoney", "indices", lambda: EastmoneyAdapter().fetch_indices(),
classify=_classify_quote_rows,
)
source = "eastmoney:ulist"
except Exception:
rows = TencentAdapter().fetch_indices()
rows = observability.observe(
db, "tencent", "indices", lambda: TencentAdapter().fetch_indices(),
classify=_classify_quote_rows,
)
source = "tencent:qt"
if len(rows) < 3:
raise RealtimeApiError("SOURCE_UNAVAILABLE", "index quotes incomplete")
@@ -77,7 +100,10 @@ def fetch_market_quotes(db: HubDB) -> dict[str, Any]:
rows: list[dict[str, Any]] = []
source = ""
try:
rows = EastmoneyAdapter().fetch_market_quotes()
rows = observability.observe(
db, "eastmoney", "market_quotes", lambda: EastmoneyAdapter().fetch_market_quotes(),
classify=_classify_quote_rows,
)
source = "eastmoney:clist"
except Exception as exc:
errors.append(f"eastmoney:{exc}")
@@ -85,7 +111,10 @@ def fetch_market_quotes(db: HubDB) -> dict[str, Any]:
listed = _listed_ts_codes(db)
if not listed:
raise AdapterError("no local stock master for tencent market snapshot")
rows = _tencent_named_quotes(listed)
rows = observability.observe(
db, "tencent", "market_quotes_fallback", lambda: _tencent_named_quotes(listed),
classify=_classify_quote_rows,
)
if len(rows) < 200:
raise AdapterError(f"Tencent market snapshot too small: {len(rows)}")
source = "tencent:qt"
@@ -130,7 +159,10 @@ def fetch_quotes(db: HubDB, codes: list[str]) -> dict[str, Any]:
missing = [code for code in resolved if code not in by_code]
try:
rows = _eastmoney_named_quotes(missing)
rows = observability.observe(
db, "eastmoney", "named_quotes", lambda: _eastmoney_named_quotes(missing),
classify=_classify_quote_rows,
)
by_code.update(_quote_map(rows, missing))
if rows:
sources.append("eastmoney:ulist")
@@ -140,7 +172,10 @@ def fetch_quotes(db: HubDB, codes: list[str]) -> dict[str, Any]:
missing = [code for code in resolved if code not in by_code]
if missing:
try:
rows = _tencent_named_quotes(missing)
rows = observability.observe(
db, "tencent", "named_quotes", lambda: _tencent_named_quotes(missing),
classify=_classify_quote_rows,
)
by_code.update(_quote_map(rows, missing))
if rows:
sources.append("tencent:qt")
@@ -203,7 +238,10 @@ def fetch_sector_quote(db: HubDB, code: str, expected_date: str = "") -> dict[st
return cached
errors: list[str] = []
try:
row = EastmoneyAdapter().fetch_shenwan_quote(ts_code)
row = observability.observe(
db, "eastmoney", "sector_quote", lambda: EastmoneyAdapter().fetch_shenwan_quote(ts_code),
classify=_classify_quote_rows,
)
if not _sector_row_matches(row, canonical_name):
raise AdapterError(
f"industry name mismatch: expected {canonical_name}, got {row.get('name') or '--'}"
@@ -270,7 +308,10 @@ def fetch_limit_pool(db: HubDB, trade_date: str = "") -> dict[str, Any]:
if cached is not None:
return cached
try:
rows = EastmoneyAdapter().fetch_limit_pool(day)
rows = observability.observe(
db, "eastmoney", "limit_pool", lambda: EastmoneyAdapter().fetch_limit_pool(day),
classify=_classify_rows_no_freshness,
)
source = "eastmoney:zt_pool"
except Exception as exc:
recovered = _load_quotes_lkg(db, cache_key)
@@ -530,7 +571,10 @@ def warm_realtime(db: HubDB) -> dict[str, Any]:
if master_code and master_name:
canonical_names.setdefault(master_code, master_name)
codes = list(canonical_names)
fetched_sector_rows = _eastmoney_sector_quotes(codes)
fetched_sector_rows = observability.observe(
db, "eastmoney", "sector_quotes_batch", lambda: _eastmoney_sector_quotes(codes),
classify=_classify_quote_rows,
)
for row in fetched_sector_rows:
if _row_quote_date(row, today) != today:
continue
@@ -588,7 +632,10 @@ def fetch_intraday(db: HubDB, code: str, date: str = "") -> dict[str, Any]:
return cached
adapter = EastmoneyAdapter()
try:
payload_data = adapter.fetch_intraday(ts_code, date)
payload_data = observability.observe(
db, "eastmoney", "intraday", lambda: adapter.fetch_intraday(ts_code, date),
classify=_classify_intraday_payload,
)
source = "eastmoney:trends2"
except Exception as exc:
recovered = _load_intraday_lkg(db, ts_code, date)
+6
View File
@@ -33,6 +33,11 @@ class Settings:
quality: dict[str, Any] = field(default_factory=dict)
log_level: str = "INFO"
scheduler_enabled: bool = True
# HEL-543 kill switch: off disables the provider_call_log/provider_health
# side channel entirely (observe()/record_call() become no-ops and the
# new read-only admin endpoints report {"enabled": false}). Default on;
# existing routing/fetch/publish behavior is identical either way.
observability_enabled: bool = True
@property
def tushare_rate_per_minute(self) -> int:
@@ -126,4 +131,5 @@ def load_settings(
quality=_load_quality(quality_path),
log_level=environ.get("DATAHUB_LOG_LEVEL") or "INFO",
scheduler_enabled=str(environ.get("DATAHUB_SCHEDULER") or "1") not in {"0", "false", "False"},
observability_enabled=str(environ.get("DATAHUB_OBSERVABILITY") or "1") not in {"0", "false", "False", "off", "OFF"},
)
+165
View File
@@ -0,0 +1,165 @@
"""Minimal, read-only source directory (HEL-543).
Registers what already exists: providers, their concrete interfaces, what
capability/dataset each interface serves, and whether the provider plays a
primary or backup role. This module only *describes* the current adapters
and datasets already wired in `datahub/hub.py`, `datahub/serving.py`, and
`datahub/realtime_serve.py`; it does not add a way to configure or add a new
source without code, and it never changes routing, retries, or fallback
order.
Every ``interfaces`` entry below is a docs-as-code mirror of a real call
site, cross-referenced in comments so a reviewer can verify each row is
accurate rather than aspirational:
- tushare interfaces mirror ``datahub/serving.py``'s ``_official_meta``/``source=``
strings and ``datahub/steward.py``'s live/published dataset table.
- eastmoney/tencent interfaces mirror the ``observability.observe(...)``
call sites added in ``datahub/realtime_serve.py`` for HEL-543.
- ifind interfaces mirror ``datahub/steward.py``'s ``IFIND_APIS`` table.
"""
from __future__ import annotations
from typing import Any
CATALOG: list[dict[str, Any]] = [
{
"provider": "tushare",
"label": "Tushare",
"role": "official_primary",
"credential_key": "tushare_token",
"status_source": "src_health (legacy, kept) + provider_health (unified, HEL-543)",
"interfaces": [
{"interface": "trade_cal", "capability": "交易日历", "datasets": ["calendar"]},
{"interface": "stock_basic", "capability": "股票主档", "datasets": ["stocks"]},
{"interface": "daily", "capability": "个股日K", "datasets": ["daily"]},
{"interface": "adj_factor", "capability": "复权因子", "datasets": ["daily"]},
{"interface": "daily_basic", "capability": "估值", "datasets": ["valuation"]},
{"interface": "index_daily", "capability": "指数日K", "datasets": ["index_daily"]},
{"interface": "moneyflow", "capability": "资金流", "datasets": ["moneyflow"]},
{"interface": "stk_auction", "capability": "集合竞价", "datasets": ["auction"]},
{"interface": "limit_list_d", "capability": "涨跌停池", "datasets": ["limit_events"]},
{"interface": "ths_hot", "capability": "同花顺人气榜", "datasets": ["popularity"]},
{"interface": "dc_hot", "capability": "东方财富人气榜", "datasets": ["popularity"]},
{"interface": "hm_detail", "capability": "龙虎榜游资明细", "datasets": ["dragon_tiger"]},
{"interface": "ths_daily", "capability": "同花顺概念行情", "datasets": ["sector_daily"]},
{"interface": "dc_index", "capability": "东方财富板块行情", "datasets": ["sector_daily"]},
{"interface": "sw_daily", "capability": "申万行业行情", "datasets": ["sector_daily"]},
],
},
{
"provider": "eastmoney",
"label": "东方财富",
"role": "provisional_primary",
"credential_key": None,
"status_source": "provider_health (unified, HEL-543)",
"interfaces": [
{"interface": "indices", "capability": "指数实时报价", "datasets": ["index_quotes"]},
{"interface": "market_quotes", "capability": "全市场实时快照", "datasets": ["quotes_latest"]},
{"interface": "named_quotes", "capability": "指定个股实时报价", "datasets": ["quotes_latest"]},
{"interface": "sector_quote", "capability": "申万板块实时报价(单个)", "datasets": ["sectors_quote"]},
{"interface": "sector_quotes_batch", "capability": "申万板块批量报价(预热)", "datasets": ["sectors_quote"]},
{"interface": "limit_pool", "capability": "涨停/炸板池(盘中)", "datasets": ["limit_pool"]},
{"interface": "intraday", "capability": "分时走势", "datasets": ["intraday_points"]},
],
},
{
"provider": "tencent",
"label": "腾讯行情",
"role": "provisional_backup",
"credential_key": None,
"status_source": "provider_health (unified, HEL-543)",
"interfaces": [
{"interface": "indices", "capability": "指数实时报价(东财失败时备用)", "datasets": ["index_quotes"]},
{
"interface": "market_quotes_fallback",
"capability": "全市场快照(备用;按本地股票主档逐只请求拼接)",
"datasets": ["quotes_latest"],
},
{"interface": "named_quotes", "capability": "指定个股实时报价(东财失败时备用)", "datasets": ["quotes_latest"]},
],
},
{
"provider": "ifind",
"label": "同花顺 iFinD",
"role": "licensed_optional",
"credential_key": "ifind_refresh_token",
"status_source": "provider_health (unified, HEL-543) + adapter.status()",
"interfaces": [
{"interface": "wencai", "capability": "问财自然语言选股", "datasets": ["ifind_wencai"]},
{"interface": "snapshots", "capability": "快照", "datasets": ["ifind_snapshots"]},
{"interface": "history", "capability": "历史行情", "datasets": ["ifind_history"]},
{"interface": "realtime", "capability": "实时行情", "datasets": ["ifind_realtime"]},
{"interface": "intraday", "capability": "分时(高频)", "datasets": ["ifind_intraday"]},
],
},
{
"provider": "ths",
"label": "同花顺(预留)",
"role": "reserved",
"credential_key": None,
"status_source": "adapter.probe()(占位,本阶段未接入真实数据)",
"interfaces": [],
},
{
"provider": "xgb",
"label": "选股宝(预留)",
"role": "reserved",
"credential_key": None,
"status_source": "adapter.probe()(占位,本阶段未接入真实数据)",
"interfaces": [],
},
{
"provider": "akshare",
"label": "AKShare(预留)",
"role": "reserved",
"credential_key": None,
"status_source": "adapter.probe()(占位,本阶段未接入真实数据)",
"interfaces": [],
},
]
def snapshot(db: Any, auth: Any = None) -> list[dict[str, Any]]:
"""Merge the static catalog with live credential/health facts.
Purely read-only: never touches routing, credentials, or adapters. Any
failure while enriching one entry only degrades that entry's live data;
it never drops the entry or raises, so a directory read can never break
on a partially-unhealthy database.
"""
result: list[dict[str, Any]] = []
for entry in CATALOG:
item: dict[str, Any] = {
"provider": entry["provider"],
"label": entry.get("label", entry["provider"]),
"role": entry["role"],
"status_source": entry["status_source"],
"interfaces": [dict(i) for i in entry.get("interfaces", [])],
}
cred_key = entry.get("credential_key")
if cred_key:
cred = None
try:
if auth is not None:
cred = auth.credential_status(cred_key)
except Exception:
cred = None
item["credential"] = cred or {"configured": False, "last4": "", "updated_at": ""}
else:
item["credential"] = {"configured": True, "last4": "", "updated_at": "", "note": "无需凭证"}
health_rows: list[dict[str, Any]] = []
try:
if db is not None:
health_rows = db.fetchall(
"SELECT interface, state, last_ok_at, last_error, last_fallback_reason, "
"consec_failures, last_latency_ms, last_data_age_seconds, updated_at "
"FROM provider_health WHERE provider = ? ORDER BY interface",
(entry["provider"],),
)
except Exception:
health_rows = []
item["live_interfaces"] = health_rows
result.append(item)
return result
+6 -1
View File
@@ -12,6 +12,7 @@ from typing import Any
from datahub.adapters.base import AdapterError
from datahub.adapters.tushare import TUSHARE_FIELDS
from datahub import observability
from datahub.numbers import finite_number
from datahub.realtime_serve import (
RealtimeApiError,
@@ -152,8 +153,12 @@ def _ifind_query(api, api_name: str, params: dict[str, Any], fields: str) -> dic
)
if not adapter.configured:
raise ApiError("SOURCE_UNAVAILABLE", "iFinD 尚未配置")
db = getattr(api, "db", None)
try:
rows = adapter.fetch(dataset, dict(params))
rows = observability.observe(
db, "ifind", dataset, lambda: adapter.fetch(dataset, dict(params)),
classify=lambda r: observability.classify_rows(r, freshness_field=None),
)
except AdapterError as exc:
raise ApiError("SOURCE_UNAVAILABLE", str(exc)) from exc
return envelope(
@@ -0,0 +1,180 @@
from __future__ import annotations
import json
import tempfile
import threading
import unittest
from http.server import ThreadingHTTPServer
from pathlib import Path
from urllib.request import Request, urlopen
from datahub.adapters.tushare import TushareAdapter
from datahub.crypto import SecretVault
from datahub.httpapp import make_handler
from datahub.hub import Hub
from datahub.settings import Settings
from tests.fixtures import TRADE_DATE, fake_transport
class AdminObservabilityApiTests(unittest.TestCase):
"""HEL-543: new read-only admin endpoints for provider status, source
catalog and lineage. These must never require write access and must
never touch the existing routing/publish logic."""
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
settings = Settings(
encryption_key=SecretVault.generate_key(),
api_token="z" * 32,
admin_password="StartPass1",
tushare_token="real-tushare-token-abcdef",
db_path=Path(self.tmp.name) / "hub.db",
scheduler_enabled=False,
)
self.hub = Hub(settings, adapter=TushareAdapter("real-tushare-token-abcdef", transport=fake_transport))
handler = make_handler(self.hub)
self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
self.base = f"http://127.0.0.1:{self.server.server_address[1]}"
_, body, cookie_header = self._json(
"/admin/api/login", "POST", {"username": "hub_admin", "password": "StartPass1"}
)
cookie = cookie_header.split(";")[0]
csrf = body["csrf"]
self._json(
"/admin/api/change-password",
"POST",
{"current": "StartPass1", "new_password": "NewPass123"},
cookie=cookie,
csrf=csrf,
)
self.cookie = cookie
self.csrf = csrf
def tearDown(self) -> None:
self.server.shutdown()
self.server.server_close()
self.tmp.cleanup()
def _json(self, path, method="GET", body=None, cookie="", csrf=""):
data = None if body is None else json.dumps(body).encode()
headers = {"Content-Type": "application/json"}
if cookie:
headers["Cookie"] = cookie
if csrf:
headers["X-CSRF-Token"] = csrf
req = Request(self.base + path, data=data, headers=headers, method=method)
with urlopen(req, timeout=5) as resp:
set_cookie = resp.headers.get("Set-Cookie", "")
return resp.status, json.loads(resp.read().decode()), set_cookie
def _get(self, path):
return self._json(path, cookie=self.cookie, csrf=self.csrf)
def test_providers_status_reflects_real_pipeline_activity(self) -> None:
pipeline = self.hub.pipeline
pipeline.ingest_reference(TRADE_DATE)
pipeline.run_dataset("daily", TRADE_DATE)
status, body, _ = self._get("/admin/api/providers/status")
self.assertEqual(status, 200)
health = body["health"]
self.assertTrue(any(item["provider"] == "tushare" and item["interface"] == "daily" for item in health))
row = next(item for item in health if item["provider"] == "tushare" and item["interface"] == "daily")
self.assertEqual(row["state"], "ok")
recent = body["recent_calls"]
self.assertTrue(any(item["provider"] == "tushare" and item["interface"] == "daily" for item in recent))
def test_providers_status_filters_by_provider(self) -> None:
pipeline = self.hub.pipeline
pipeline.ingest_reference(TRADE_DATE)
pipeline.run_dataset("daily", TRADE_DATE)
status, body, _ = self._get("/admin/api/providers/status?provider=tushare")
self.assertEqual(status, 200)
self.assertTrue(all(item["provider"] == "tushare" for item in body["health"]))
self.assertTrue(all(item["provider"] == "tushare" for item in body["recent_calls"]))
status, body, _ = self._get("/admin/api/providers/status?provider=eastmoney")
self.assertEqual(status, 200)
self.assertEqual(body["health"], [])
self.assertEqual(body["recent_calls"], [])
def test_source_catalog_lists_known_providers_without_leaking_secrets(self) -> None:
status, body, _ = self._get("/admin/api/source-catalog")
self.assertEqual(status, 200)
blob = json.dumps(body)
self.assertNotIn("real-tushare-token-abcdef", blob)
providers = {item["provider"] for item in body["items"]}
self.assertIn("tushare", providers)
self.assertIn("eastmoney", providers)
self.assertIn("tencent", providers)
self.assertIn("ifind", providers)
def test_lineage_snapshot_and_affected_query(self) -> None:
status, body, _ = self._get("/admin/api/lineage")
self.assertEqual(status, 200)
self.assertTrue(len(body["items"]) > 0)
datasets = {item["dataset"] for item in body["items"]}
self.assertIn("stocks", datasets)
status, body, _ = self._get("/admin/api/lineage/affected?provider=tushare&interface=daily")
self.assertEqual(status, 200)
self.assertEqual(body["provider"], "tushare")
self.assertEqual(body["interface"], "daily")
def test_disabled_kill_switch_reports_enabled_false_with_empty_structure(self) -> None:
# HEL-543 total-review 🔴: flip the runtime kill switch the same way
# Hub.__init__ wires Settings.observability_enabled onto the db
# handle, then confirm every new endpoint reports disabled with an
# explicit empty structure rather than silently going quiet.
self.hub.db.observability_enabled = False
try:
pipeline = self.hub.pipeline
pipeline.ingest_reference(TRADE_DATE)
pipeline.run_dataset("daily", TRADE_DATE) # must still fully succeed
status, body, _ = self._get("/admin/api/providers/status")
self.assertEqual(status, 200)
self.assertEqual(body, {"enabled": False, "health": [], "recent_calls": []})
status, body, _ = self._get("/admin/api/source-catalog")
self.assertEqual(status, 200)
self.assertEqual(body, {"enabled": False, "items": []})
status, body, _ = self._get("/admin/api/lineage")
self.assertEqual(status, 200)
self.assertFalse(body["enabled"])
self.assertEqual(body["items"], [])
status, body, _ = self._get("/admin/api/lineage/affected?provider=tushare")
self.assertEqual(status, 200)
self.assertEqual(
body, {"enabled": False, "provider": "tushare", "interface": "", "items": []}
)
# Nothing was ever written while disabled.
self.assertEqual(self.hub.db.fetchall("SELECT * FROM provider_call_log"), [])
finally:
self.hub.db.observability_enabled = True
def test_must_change_password_blocks_new_endpoints_too(self) -> None:
from urllib.error import HTTPError
_, body, cookie_header = self._json(
"/admin/api/login", "POST", {"username": "hub_admin", "password": "NewPass123"}
)
# Freshly logged-in user has already changed password in setUp, so
# this login should not require a change; verify the endpoint is
# reachable with a valid, non-must-change session (regression guard
# against accidentally bypassing the must-change gate for these new
# routes).
cookie = cookie_header.split(";")[0]
csrf = body["csrf"]
status, _, _ = self._json("/admin/api/source-catalog", cookie=cookie, csrf=csrf)
self.assertEqual(status, 200)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,105 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from datahub.adapters.ifind import IfindAdapter
from datahub.db import HubDB
from datahub.serving import ApiError
from datahub.steward import steward_query
class _Resp:
def __init__(self, payload: dict, status: int = 200) -> None:
import json
self.status = status
self._raw = json.dumps(payload).encode("utf-8")
def read(self):
return self._raw
def __enter__(self):
return self
def __exit__(self, *args):
return False
class IfindObservabilityTests(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.db = HubDB(Path(self.tmp.name) / "hub.db")
def tearDown(self) -> None:
self.tmp.cleanup()
def _adapter_with_urlopen(self, urlopen) -> IfindAdapter:
return IfindAdapter(refresh_token="rt", access_token="at", urlopen=urlopen)
def test_successful_fetch_is_logged_without_changing_rows(self) -> None:
def urlopen(request, timeout=None):
return _Resp(
{
"errorcode": 0,
"tables": [{"thscode": ["000001.SZ"], "table": {"涨停原因": ["重组"]}}],
}
)
adapter = self._adapter_with_urlopen(urlopen)
class _Api:
ifind = adapter
db = self.db
payload = steward_query(
_Api(), {"api_name": "ifind_wencai", "params": {"query": "涨停"}}
)
self.assertEqual(payload["data"][0]["thscode"], "000001.SZ")
log = self.db.fetchone("SELECT * FROM provider_call_log WHERE provider = 'ifind'")
self.assertIsNotNone(log)
self.assertEqual(log["interface"], "wencai")
self.assertEqual(log["status"], "ok")
def test_failed_fetch_reraises_and_logs_error(self) -> None:
def urlopen(request, timeout=None):
return _Resp({"errorcode": -9999, "errmsg": "quota exceeded"})
adapter = self._adapter_with_urlopen(urlopen)
class _Api:
ifind = adapter
db = self.db
with self.assertRaises(ApiError) as ctx:
steward_query(_Api(), {"api_name": "ifind_wencai", "params": {"query": "涨停"}})
self.assertEqual(ctx.exception.code, "SOURCE_UNAVAILABLE")
log = self.db.fetchone("SELECT * FROM provider_call_log WHERE provider = 'ifind'")
self.assertIsNotNone(log)
self.assertEqual(log["status"], "error")
def test_status_check_alone_does_not_dial_or_log_a_fetch_call(self) -> None:
class _Api:
ifind = IfindAdapter()
db = self.db
payload = steward_query(_Api(), {"api_name": "ifind_status", "params": {}})
self.assertFalse(payload["data"][0]["configured"])
log = self.db.fetchall("SELECT * FROM provider_call_log WHERE provider = 'ifind'")
self.assertEqual(log, [])
def test_api_double_without_db_attribute_still_works(self) -> None:
# Mirrors tests/test_ifind_adapter.py's `_Api` double, which has no
# `db` attribute at all. Observability must not require it.
class _Api:
ifind = IfindAdapter()
payload = steward_query(_Api(), {"api_name": "ifind_status", "params": {}})
self.assertFalse(payload["data"][0]["configured"])
with self.assertRaises(ApiError):
steward_query(_Api(), {"api_name": "ifind_wencai", "params": {"query": "涨停"}})
if __name__ == "__main__":
unittest.main()
+306
View File
@@ -0,0 +1,306 @@
from __future__ import annotations
import tempfile
import unittest
from contextlib import contextmanager
from pathlib import Path
from datahub import observability
from datahub.db import HubDB
class _BrokenDB:
"""A db double whose write() always raises, to prove fail-open."""
@contextmanager
def write(self):
raise RuntimeError("disk is full")
yield None # pragma: no cover - unreachable, keeps this a generator
def fetchall(self, sql, params=()):
raise RuntimeError("disk is full")
def fetchone(self, sql, params=()):
raise RuntimeError("disk is full")
class ClassifyRowsTests(unittest.TestCase):
def test_empty_list_is_flagged_empty(self):
status, reason, age = observability.classify_rows([])
self.assertEqual(status, "empty")
self.assertEqual(reason, "no_rows_returned")
self.assertIsNone(age)
def test_empty_dict_result_is_flagged_empty(self):
status, reason, _ = observability.classify_rows({})
self.assertEqual(status, "empty")
self.assertEqual(reason, "no_rows_returned")
def test_missing_required_field_is_flagged(self):
rows = [{"ts_code": "600000.SH", "close": 10.2}, {"ts_code": "000001.SZ"}]
status, reason, _ = observability.classify_rows(rows, required_fields=("close",), freshness_field=None)
self.assertEqual(status, "missing_fields")
self.assertIn("close", reason)
def test_fresh_rows_are_ok(self):
import time
rows = [{"ts_code": "600000.SH", "close": 10.2, "quote_time_epoch": int(time.time())}]
status, reason, age = observability.classify_rows(rows)
self.assertEqual(status, "ok")
self.assertEqual(reason, "")
self.assertIsNotNone(age)
self.assertLess(age, 5)
def test_stale_rows_are_flagged(self):
import time
rows = [{"ts_code": "600000.SH", "close": 10.2, "quote_time_epoch": int(time.time()) - 3600}]
status, reason, age = observability.classify_rows(rows, max_age_seconds=300)
self.assertEqual(status, "stale")
self.assertEqual(reason, "data_age_exceeds_threshold")
self.assertGreaterEqual(age, 3600 - 5)
def test_classifier_never_raises_on_garbage_input(self):
status, reason, age = observability.classify_rows(object())
self.assertEqual(status, "empty")
self.assertIsNone(age)
# Malformed rows inside a list must not raise either.
status, _, _ = observability.classify_rows(["not-a-dict", 123, None])
self.assertEqual(status, "empty")
class ClassifyErrorTests(unittest.TestCase):
def test_blocked_page_markers_are_detected(self):
status, reason = observability.classify_error("eastmoney request failed: Expecting value: line 1 column 1")
self.assertEqual(status, "blocked")
self.assertEqual(reason, "response_looks_like_intercept_page")
def test_timeout_is_detected(self):
status, _ = observability.classify_error("tencent request failed: timed out")
self.assertEqual(status, "timeout")
def test_generic_error_falls_back(self):
status, reason = observability.classify_error("connection reset by peer")
self.assertEqual(status, "error")
self.assertEqual(reason, "")
def test_never_raises_on_none(self):
status, reason = observability.classify_error(None)
self.assertEqual(status, "error")
class RecordCallTests(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.db = HubDB(Path(self.tmp.name) / "hub.db")
def tearDown(self) -> None:
self.tmp.cleanup()
def test_record_call_writes_log_and_health(self):
observability.record_call(self.db, "eastmoney", "indices", status="ok", latency_ms=42)
log_rows = self.db.fetchall("SELECT * FROM provider_call_log")
self.assertEqual(len(log_rows), 1)
self.assertEqual(log_rows[0]["provider"], "eastmoney")
self.assertEqual(log_rows[0]["interface"], "indices")
self.assertEqual(log_rows[0]["status"], "ok")
health = self.db.fetchone(
"SELECT * FROM provider_health WHERE provider = ? AND interface = ?",
("eastmoney", "indices"),
)
self.assertIsNotNone(health)
self.assertEqual(health["state"], "ok")
self.assertEqual(health["consec_failures"], 0)
def test_consecutive_failures_increment_and_reset(self):
observability.record_call(self.db, "tencent", "named_quotes", status="error", error="boom")
observability.record_call(self.db, "tencent", "named_quotes", status="error", error="boom again")
health = self.db.fetchone(
"SELECT * FROM provider_health WHERE provider = ? AND interface = ?",
("tencent", "named_quotes"),
)
self.assertEqual(health["consec_failures"], 2)
self.assertEqual(health["state"], "error")
observability.record_call(self.db, "tencent", "named_quotes", status="ok")
health = self.db.fetchone(
"SELECT * FROM provider_health WHERE provider = ? AND interface = ?",
("tencent", "named_quotes"),
)
self.assertEqual(health["consec_failures"], 0)
self.assertEqual(health["state"], "ok")
def test_none_db_is_a_silent_noop(self):
# Must not raise even though there is nowhere to write.
observability.record_call(None, "ifind", "wencai", status="ok")
def test_broken_db_write_does_not_raise(self):
observability.record_call(_BrokenDB(), "eastmoney", "indices", status="ok")
class ObserveTests(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.db = HubDB(Path(self.tmp.name) / "hub.db")
def tearDown(self) -> None:
self.tmp.cleanup()
def test_returns_exact_success_value_unmodified(self):
sentinel = {"ts_code": "600000.SH", "close": 10.2}
result = observability.observe(self.db, "eastmoney", "indices", lambda: sentinel)
self.assertIs(result, sentinel)
rows = self.db.fetchall("SELECT * FROM provider_call_log")
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["status"], "ok")
def test_reraises_exact_exception_on_failure(self):
boom = ValueError("upstream exploded")
def fn():
raise boom
with self.assertRaises(ValueError) as ctx:
observability.observe(self.db, "eastmoney", "indices", fn)
self.assertIs(ctx.exception, boom)
rows = self.db.fetchall("SELECT * FROM provider_call_log")
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["status"], "error")
self.assertIn("upstream exploded", rows[0]["error"])
def test_classify_downgrades_success_to_stale_without_changing_return_value(self):
sentinel = [{"ts_code": "600000.SH", "quote_time_epoch": 1}]
result = observability.observe(
self.db, "eastmoney", "indices", lambda: sentinel,
classify=lambda rows: observability.classify_rows(rows),
)
self.assertIs(result, sentinel)
row = self.db.fetchone("SELECT * FROM provider_call_log")
self.assertEqual(row["status"], "stale")
def test_broken_db_never_breaks_a_successful_call(self):
sentinel = {"ok": True}
result = observability.observe(_BrokenDB(), "eastmoney", "indices", lambda: sentinel)
self.assertIs(result, sentinel)
def test_broken_db_never_masks_a_real_failure(self):
def fn():
raise RuntimeError("real upstream failure")
with self.assertRaises(RuntimeError) as ctx:
observability.observe(_BrokenDB(), "eastmoney", "indices", fn)
self.assertEqual(str(ctx.exception), "real upstream failure")
def test_classifier_exception_does_not_break_the_call(self):
sentinel = {"ok": True}
def bad_classify(_result):
raise KeyError("classifier bug")
result = observability.observe(self.db, "eastmoney", "indices", lambda: sentinel, classify=bad_classify)
self.assertIs(result, sentinel)
row = self.db.fetchone("SELECT * FROM provider_call_log")
# A classifier bug must degrade to "ok", never silently drop the row
# nor claim the call failed when it did not.
self.assertEqual(row["status"], "ok")
def test_none_db_is_transparent_passthrough(self):
sentinel = object()
result = observability.observe(None, "eastmoney", "indices", lambda: sentinel)
self.assertIs(result, sentinel)
class _ToggleDB(HubDB):
"""A real HubDB subclass so we can flip the HEL-543 kill switch the same
way Hub.__init__ does, without needing a full Hub/Settings wiring."""
class KillSwitchTests(unittest.TestCase):
"""HEL-543 total-review 🔴: the observability side channel must be
disable-able at runtime, and disabling it must leave existing behavior
completely unchanged (pure passthrough, zero db access)."""
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.db = _ToggleDB(Path(self.tmp.name) / "hub.db")
def tearDown(self) -> None:
self.tmp.cleanup()
def test_is_enabled_defaults_true_when_attribute_absent(self):
# A bare HubDB (as used throughout the rest of this test suite, and
# by any pre-HEL-543 call site) must default to enabled.
self.assertTrue(observability.is_enabled(self.db))
self.assertTrue(observability.is_enabled(None))
def test_disabled_record_call_writes_nothing(self):
self.db.observability_enabled = False
observability.record_call(self.db, "eastmoney", "indices", status="ok", latency_ms=1)
self.assertEqual(self.db.fetchall("SELECT * FROM provider_call_log"), [])
self.assertEqual(self.db.fetchall("SELECT * FROM provider_health"), [])
def test_disabled_observe_is_a_pure_passthrough_on_success(self):
self.db.observability_enabled = False
sentinel = {"ts_code": "600000.SH"}
calls = {"n": 0}
def fn():
calls["n"] += 1
return sentinel
result = observability.observe(self.db, "eastmoney", "indices", fn)
self.assertIs(result, sentinel)
self.assertEqual(calls["n"], 1)
self.assertEqual(self.db.fetchall("SELECT * FROM provider_call_log"), [])
def test_disabled_observe_still_reraises_the_exact_exception(self):
self.db.observability_enabled = False
boom = RuntimeError("upstream exploded")
def fn():
raise boom
with self.assertRaises(RuntimeError) as ctx:
observability.observe(self.db, "eastmoney", "indices", fn)
self.assertIs(ctx.exception, boom)
self.assertEqual(self.db.fetchall("SELECT * FROM provider_call_log"), [])
def test_re_enabling_resumes_recording(self):
self.db.observability_enabled = False
observability.observe(self.db, "eastmoney", "indices", lambda: {"ok": True})
self.assertEqual(self.db.fetchall("SELECT * FROM provider_call_log"), [])
self.db.observability_enabled = True
observability.observe(self.db, "eastmoney", "indices", lambda: {"ok": True})
self.assertEqual(len(self.db.fetchall("SELECT * FROM provider_call_log")), 1)
class SettingsToggleTests(unittest.TestCase):
"""The kill switch follows the same env-var pattern as DATAHUB_SCHEDULER."""
def test_defaults_to_enabled(self):
from datahub.settings import load_settings
settings = load_settings(env={})
self.assertTrue(settings.observability_enabled)
def test_datahub_observability_zero_disables(self):
from datahub.settings import load_settings
settings = load_settings(env={"DATAHUB_OBSERVABILITY": "0"})
self.assertFalse(settings.observability_enabled)
def test_datahub_observability_off_disables(self):
from datahub.settings import load_settings
settings = load_settings(env={"DATAHUB_OBSERVABILITY": "off"})
self.assertFalse(settings.observability_enabled)
def test_datahub_observability_one_keeps_enabled(self):
from datahub.settings import load_settings
settings = load_settings(env={"DATAHUB_OBSERVABILITY": "1"})
self.assertTrue(settings.observability_enabled)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,82 @@
from __future__ import annotations
import unittest
from datahub.pipeline import RetryError
from tests.fixtures import TRADE_DATE
from tests.test_pipeline import make_pipeline
class PipelineObservabilityTests(unittest.TestCase):
"""HEL-543: Tushare calls must keep writing the existing `src_calls`
record unchanged, while also feeding the new cross-provider
`provider_call_log` / `provider_health` side channel."""
def test_successful_fetch_logs_to_both_src_calls_and_provider_call_log(self) -> None:
pipe, db = make_pipeline()
pipe.ingest_reference(TRADE_DATE)
result = pipe.run_dataset("daily", TRADE_DATE)
self.assertEqual(result["state"], "published")
src_calls = db.fetchall("SELECT * FROM src_calls WHERE provider = 'tushare' AND endpoint = 'daily'")
self.assertTrue(any(row["ok"] == 1 for row in src_calls))
log = db.fetchall(
"SELECT * FROM provider_call_log WHERE provider = 'tushare' AND interface = 'daily'"
)
self.assertTrue(len(log) >= 1)
self.assertEqual(log[-1]["status"], "ok")
health = db.fetchone(
"SELECT * FROM provider_health WHERE provider = 'tushare' AND interface = 'daily'"
)
self.assertIsNotNone(health)
self.assertEqual(health["state"], "ok")
self.assertEqual(health["consec_failures"], 0)
def test_failed_fetch_logs_error_to_both_channels_and_still_raises(self) -> None:
pipe, db = make_pipeline()
pipe.ingest_reference(TRADE_DATE)
def boom(dataset, params):
raise RuntimeError("tushare upstream 500")
pipe.adapter.fetch = boom # type: ignore[assignment]
with self.assertRaises(RetryError):
pipe.run_dataset("daily", TRADE_DATE, attempts=1)
src_calls = db.fetchall("SELECT * FROM src_calls WHERE provider = 'tushare' AND endpoint = 'daily' AND ok = 0")
self.assertTrue(len(src_calls) >= 1)
self.assertIn("tushare upstream 500", src_calls[-1]["error"])
log = db.fetchall(
"SELECT * FROM provider_call_log WHERE provider = 'tushare' AND interface = 'daily' AND status != 'ok'"
)
self.assertTrue(len(log) >= 1)
self.assertIn("tushare upstream 500", log[-1]["error"])
health = db.fetchone(
"SELECT * FROM provider_health WHERE provider = 'tushare' AND interface = 'daily'"
)
self.assertIsNotNone(health)
self.assertNotEqual(health["state"], "ok")
self.assertGreaterEqual(health["consec_failures"], 1)
def test_provider_call_log_is_purged_by_existing_cleanup_job(self) -> None:
pipe, db = make_pipeline()
pipe.ingest_reference(TRADE_DATE)
pipe.run_dataset("daily", TRADE_DATE)
self.assertTrue(db.fetchall("SELECT * FROM provider_call_log"))
# Force everything to look ancient so cleanup() sweeps it.
db.execute("UPDATE provider_call_log SET created_at = '2000-01-01T00:00:00+08:00'")
db.execute("UPDATE src_calls SET created_at = '2000-01-01T00:00:00+08:00'")
db.execute("UPDATE job_runs SET started_at = '2000-01-01T00:00:00+08:00'")
pipe.cleanup()
self.assertEqual(db.fetchall("SELECT * FROM provider_call_log"), [])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,163 @@
from __future__ import annotations
import tempfile
import unittest
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import patch
from datahub.adapters.base import AdapterError
from datahub.db import HubDB
from datahub.realtime_serve import fetch_index_quotes, fetch_intraday, fetch_quotes
class _WriteBreaksDB:
"""Wraps a real HubDB but breaks only the write path, to prove the
real serving path (reads/caches) is untouched by an observability
failure while still exercising real fetch/cache code around it."""
def __init__(self, real: HubDB) -> None:
self._real = real
def fetchall(self, sql, params=()):
return self._real.fetchall(sql, params)
def fetchone(self, sql, params=()):
return self._real.fetchone(sql, params)
def execute(self, sql, params=()):
return self._real.execute(sql, params)
def executemany(self, sql, rows):
return self._real.executemany(sql, rows)
@contextmanager
def write(self):
raise RuntimeError("db is not writable right now")
yield None # pragma: no cover
class RealtimeObservabilityTests(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.db = HubDB(Path(self.tmp.name) / "hub.db")
def tearDown(self) -> None:
self.tmp.cleanup()
def test_eastmoney_success_is_logged_without_changing_payload(self) -> None:
rows = [
{"ts_code": "000001.SH", "code": "000001", "name": "上证指数", "price": 3000.0,
"previous_close": 2990.0, "quote_time_epoch": 0, "source": "eastmoney_push2"},
{"ts_code": "399001.SZ", "code": "399001", "name": "深证成指", "price": 9000.0,
"previous_close": 8990.0, "quote_time_epoch": 0, "source": "eastmoney_push2"},
{"ts_code": "399006.SZ", "code": "399006", "name": "创业板指", "price": 1800.0,
"previous_close": 1790.0, "quote_time_epoch": 0, "source": "eastmoney_push2"},
]
with patch("datahub.realtime_serve.EastmoneyAdapter") as mocked:
mocked.return_value.fetch_indices.return_value = rows
payload = fetch_index_quotes(self.db)
self.assertEqual(payload["data"], rows)
self.assertEqual(payload["meta"]["source"], "eastmoney:ulist")
log = self.db.fetchall("SELECT * FROM provider_call_log WHERE provider = 'eastmoney'")
self.assertEqual(len(log), 1)
self.assertEqual(log[0]["interface"], "indices")
self.assertEqual(log[0]["status"], "ok")
health = self.db.fetchone(
"SELECT * FROM provider_health WHERE provider = 'eastmoney' AND interface = 'indices'"
)
self.assertEqual(health["state"], "ok")
def test_eastmoney_failure_falls_back_to_tencent_and_logs_both(self) -> None:
tencent_rows = [
{"ts_code": "000001.SH", "code": "000001", "name": "上证指数", "price": 3000.0,
"previous_close": 2990.0, "quote_time_epoch": 0, "source": "tencent_qt"},
{"ts_code": "399001.SZ", "code": "399001", "name": "深证成指", "price": 9000.0,
"previous_close": 8990.0, "quote_time_epoch": 0, "source": "tencent_qt"},
{"ts_code": "399006.SZ", "code": "399006", "name": "创业板指", "price": 1800.0,
"previous_close": 1790.0, "quote_time_epoch": 0, "source": "tencent_qt"},
]
with patch("datahub.realtime_serve.EastmoneyAdapter") as eastmoney, patch(
"datahub.realtime_serve.TencentAdapter"
) as tencent:
eastmoney.return_value.fetch_indices.side_effect = AdapterError("Eastmoney returned 0/3 indices")
tencent.return_value.fetch_indices.return_value = tencent_rows
payload = fetch_index_quotes(self.db)
self.assertEqual(payload["meta"]["source"], "tencent:qt")
self.assertEqual(payload["data"], tencent_rows)
east_log = self.db.fetchone("SELECT * FROM provider_call_log WHERE provider = 'eastmoney'")
self.assertEqual(east_log["status"], "empty")
tencent_log = self.db.fetchone("SELECT * FROM provider_call_log WHERE provider = 'tencent'")
self.assertEqual(tencent_log["status"], "ok")
def test_observability_db_failure_never_breaks_a_real_successful_fetch(self) -> None:
rows = [
{"ts_code": "000001.SH", "price": 3000.0, "previous_close": 2990.0, "quote_time_epoch": 0},
{"ts_code": "399001.SZ", "price": 9000.0, "previous_close": 8990.0, "quote_time_epoch": 0},
{"ts_code": "399006.SZ", "price": 1800.0, "previous_close": 1790.0, "quote_time_epoch": 0},
]
broken = _WriteBreaksDB(self.db)
with patch("datahub.realtime_serve.EastmoneyAdapter") as mocked:
mocked.return_value.fetch_indices.return_value = rows
payload = fetch_index_quotes(broken)
self.assertEqual(payload["data"], rows)
self.assertEqual(payload["meta"]["source"], "eastmoney:ulist")
def test_observability_db_failure_never_masks_a_real_source_outage(self) -> None:
broken = _WriteBreaksDB(self.db)
with patch("datahub.realtime_serve.EastmoneyAdapter") as eastmoney, patch(
"datahub.realtime_serve.TencentAdapter"
) as tencent:
eastmoney.return_value.fetch_indices.side_effect = AdapterError("down")
tencent.return_value.fetch_indices.side_effect = AdapterError("also down")
with self.assertRaises(Exception):
fetch_index_quotes(broken)
def test_named_quotes_records_both_providers_on_partial_merge(self) -> None:
with patch("datahub.realtime_serve.EastmoneyAdapter") as eastmoney, patch(
"datahub.realtime_serve.TencentAdapter"
) as tencent:
eastmoney.return_value.fetch_quotes.return_value = [
{"ts_code": "000001.SZ", "close": 10, "pre_close": 9, "quote_date": "20260907"},
]
tencent.return_value.fetch_quotes.return_value = [
{"ts_code": "000002.SZ", "close": 20, "pre_close": 19, "quote_date": "20260907"},
]
payload = fetch_quotes(self.db, ["000001.SZ", "000002.SZ"])
self.assertEqual(payload["meta"]["complete"], True)
east_log = self.db.fetchone(
"SELECT * FROM provider_call_log WHERE provider = 'eastmoney' AND interface = 'named_quotes'"
)
self.assertEqual(east_log["status"], "ok")
tencent_log = self.db.fetchone(
"SELECT * FROM provider_call_log WHERE provider = 'tencent' AND interface = 'named_quotes'"
)
self.assertEqual(tencent_log["status"], "ok")
def test_intraday_success_is_logged_as_ok(self) -> None:
payload_data = {
"entity_type": "stock", "ts_code": "601318.SH", "trade_date": "2026-09-07",
"previous_close": 55.8, "points": [{"date": "2026-09-07", "time": "09:30", "close": 55.9}],
}
with patch("datahub.realtime_serve.EastmoneyAdapter") as mocked:
mocked.return_value.fetch_intraday.return_value = payload_data
payload = fetch_intraday(self.db, "601318.SH")
self.assertEqual(payload["data"], payload_data)
log = self.db.fetchone(
"SELECT * FROM provider_call_log WHERE provider = 'eastmoney' AND interface = 'intraday'"
)
self.assertEqual(log["status"], "ok")
def test_intraday_failure_is_logged_as_empty(self) -> None:
with patch("datahub.realtime_serve.EastmoneyAdapter") as mocked:
mocked.return_value.fetch_intraday.side_effect = AdapterError("No intraday chart data returned")
with self.assertRaises(Exception):
fetch_intraday(self.db, "000001.SZ")
log = self.db.fetchone(
"SELECT * FROM provider_call_log WHERE provider = 'eastmoney' AND interface = 'intraday'"
)
self.assertEqual(log["status"], "empty")
if __name__ == "__main__":
unittest.main()