diff --git a/xiaobai-datahub/admin/app.js b/xiaobai-datahub/admin/app.js
index afaf599..0f07ed9 100644
--- a/xiaobai-datahub/admin/app.js
+++ b/xiaobai-datahub/admin/app.js
@@ -1,12 +1,26 @@
-const state = { csrf: "", page: "overview" };
+'use strict';
+/* =====================================================================
+ 小白复盘 · 数据中枢 admin/app.js — HEL-529 返工:按定稿 100% 还原
+ 视觉与 DOM 结构逐行照抄已确认打样 hub-kimi.html(v12 血缘压缩增补),
+ 但本文件不含任何随机/模拟数据——全部状态、数字、事件均来自真实接口:
+ /admin/api/{overview,sources,jobs,batches,audit,...} 与 HEL-543 只读旁路
+ /admin/api/{providers/status,source-catalog,lineage,lineage/affected}。
+ 动效遵循 SPEC-V11:marquee 46s 线性 / flowline 1.4s / radar 4.2s /
+ blink 1.1s steps(2) / ledPing 1.6s / sparkBlink 1.4s / 时钟冒号 blink /
+ 延迟仅在真实值变化时闪烁(禁止随机伪造数值)/ 缓存年龄每秒真实增长。
+ ===================================================================== */
+/* ================= 基础:DOM helper / API / CSRF ================= */
function $(id) { return document.getElementById(id); }
+function esc(v) { return String(v ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
+
+const state = { csrf: '', page: 'overview', calm: false, username: '' };
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();
+ 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().catch(() => ({}));
if (!res.ok) {
const msg = (body.error && body.error.message) || body.error || res.statusText;
throw new Error(msg);
@@ -14,286 +28,1349 @@ async function api(path, options = {}) {
return body;
}
-function show(id) {
- ["login-view", "change-view", "shell"].forEach((key) => { $(key).hidden = key !== id; });
-}
-
-function esc(value) {
- return String(value ?? "").replace(/[&<>"]/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[ch]));
-}
-
-function table(headers, rows) {
- const thead = headers.map((h) => `
-
-
-
+/* ================= 数据层:拉取真实接口并汇总为一份快照 ================= */
+const RAW = {
+ overview: null, sources: null, providers: null, catalog: null,
+ lineage: null, jobs: null, batches: null, audit: null,
+};
+async function fetchAll() {
+ const [overview, sources, providers, catalog, lineage, jobs, batches, audit] = await Promise.all([
+ api('/admin/api/overview'),
+ api('/admin/api/sources'),
+ api('/admin/api/providers/status?limit=200'),
+ api('/admin/api/source-catalog'),
+ api('/admin/api/lineage'),
+ api('/admin/api/jobs'),
+ api(`/admin/api/batches?date=${today()}`),
+ api('/admin/api/audit'),
+ ]);
+ RAW.overview = overview; RAW.sources = sources; RAW.providers = providers; RAW.catalog = catalog;
+ RAW.lineage = lineage; RAW.jobs = jobs; RAW.batches = batches; RAW.audit = audit;
+ buildLatHist();
+}
+function today() { return RAW.overview ? RAW.overview.trade_date : ''; }
+
+const OFFICIAL_DATASET_IDS = ['daily', 'valuation', 'moneyflow', 'auction', 'index_daily', 'limit_events', 'popularity', 'dragon_tiger', 'sector_daily', 'stocks'];
+const DATASET_NAME = {
+ daily: '日K', valuation: '估值', moneyflow: '资金流', auction: '竞价', index_daily: '指数日K',
+ limit_events: '涨停池', popularity: '人气榜', dragon_tiger: '龙虎榜', sector_daily: '题材板块', stocks: '股票主档',
+};
+
+/* =====================================================================
+ 总览页(结构照抄 hub-kimi.html,数据全部真实)
+ ===================================================================== */
+function buildOverviewVM() {
+ const ov = RAW.overview || {};
+ const eod = ov.eod_status || {};
+ const rev = ov.revision_status || {};
+ const pubsByDs = {};
+ (ov.publications || []).forEach((p) => { pubsByDs[p.dataset] = p; });
+ const batchByDs = {};
+ ((RAW.batches && RAW.batches.batches) || []).forEach((b) => { if (!batchByDs[b.dataset] || b.started_at > batchByDs[b.dataset].started_at) batchByDs[b.dataset] = b; });
+ const totalOfficial = OFFICIAL_DATASET_IDS.length;
+ const publishedCount = OFFICIAL_DATASET_IDS.filter((id) => pubsByDs[id] && pubsByDs[id].state === 'published').length;
+ const anomalyCount = (ov.anomalies || []).length;
+ const recentCalls = ov.recent_calls || [];
+ const successRate = recentCalls.length ? (recentCalls.filter((c) => c.ok).length / recentCalls.length * 100) : null;
+
+ // 主站回退页面:主源异常且备源健康 → 判定为“回退中”的 provisional 数据集
+ const fallbackPages = [];
+ (RAW.lineage && RAW.lineage.items || []).forEach((it) => {
+ if (it.tier !== 'provisional' || !it.backup_source) return;
+ const rows = it.live_provider_health || [];
+ const primaryProv = (it.providers || [])[0];
+ const backupProv = (it.providers || [])[1];
+ const primaryState = worstBucket(rows.filter((r) => r.provider === primaryProv).map((r) => bucketOf(r.state)));
+ const backupState = worstBucket(rows.filter((r) => r.provider === backupProv).map((r) => bucketOf(r.state)));
+ if ((primaryState === 'fail' || primaryState === 'degrade' || primaryState === 'slow') && backupState === 'ok') {
+ (it.known_consumers || []).forEach((c) => fallbackPages.push(c));
+ }
+ });
+
+ const datasetCards = OFFICIAL_DATASET_IDS.map((id) => {
+ const pub = pubsByDs[id];
+ const batch = batchByDs[id];
+ let bucket = 'plan', note = '';
+ if (pub) {
+ bucket = pub.state === 'published' ? 'ok' : (pub.state === 'degraded' ? 'degrade' : 'fail');
+ note = pub.published_at ? hm(pub.published_at) : '';
+ } else if (batch) {
+ bucket = batch.state === 'failed' ? 'fail' : (batch.state === 'staged' ? 'review' : 'plan');
+ note = batch.error ? esc(batch.error) : '';
+ }
+ const rows = (batch && batch.rows_out) || (pub && pub.rows_out) || null;
+ return { id, name: DATASET_NAME[id] || id, bucket, time: note && !batch?.error ? note : '', rows, err: batch && batch.error ? batch.error : '' };
+ });
+
+ // 实时观察层:eastmoney / tencent 目录接口的真实观测(observed=true)
+ const catalogByProv = {};
+ (RAW.catalog && RAW.catalog.items || []).forEach((it) => { catalogByProv[it.provider] = it; });
+ const observers = [];
+ ['eastmoney', 'tencent'].forEach((prov) => {
+ const cat = catalogByProv[prov];
+ if (!cat) return;
+ cat.interfaces.forEach((iface) => {
+ if (!iface.observed) return;
+ observers.push({
+ jid: `${prov}:${iface.interface}`,
+ name: iface.capability, provider: prov, interface: iface.interface,
+ lastOk: iface.observed_at, lat: iface.observed_latency_ms,
+ bucket: bucketOf(iface.observed_state),
+ note: iface.observed_note || '',
+ });
+ });
+ });
+
+ // 主站影响:lineage() 条目按 known_consumers 展开(接口级状态)
+ const siteImpact = [];
+ (RAW.lineage && RAW.lineage.items || []).forEach((it) => {
+ const bucket = lineageItemBucket(it);
+ (it.known_consumers || []).forEach((c) => {
+ siteImpact.push({ page: c, short: pageNameOf(c), dataset: it.dataset, bucket, note: lineageItemNote(it) });
+ });
+ });
+ const okPages = siteImpact.filter((s) => s.bucket === 'ok').length;
+
+ // 今晚时间线:真实 jobs() 定义 + job_runs
+ const jobDefs = (RAW.jobs && RAW.jobs.jobs || []).filter((j) => j.at !== 'manual');
+ const runsByJob = {};
+ ((RAW.jobs && RAW.jobs.runs) || []).forEach((r) => { (runsByJob[r.job_id] = runsByJob[r.job_id] || []).push(r); });
+ const timeline = jobDefs.map((j) => {
+ const runs = (runsByJob[j.id] || []).slice().sort((a, b) => (a.id < b.id ? 1 : -1));
+ const latest = runs[0];
+ let st = 'wait';
+ if (latest) st = latest.state === 'ok' ? 'done' : (latest.state === 'failed' ? 'fail' : 'wait');
+ return { t: (j.at.split('-')[0] || j.at).slice(0, 5), label: j.title, state: st, jobId: j.id };
+ });
+
+ // 待处理异常:overview.anomalies 已在后端收敛为“仍未恢复的最新异常”
+ const incidents = [];
+ (ov.anomalies || []).forEach((b) => {
+ incidents.push({ sev: 'fail', tag: b.state, title: `${DATASET_NAME[b.dataset] || b.dataset} ${b.state === 'failed' ? '失败' : '停滞'}`, meta: `${b.error ? esc(b.error) : '无错误详情'} · 批次 ${b.batch_id}`, dataset: b.dataset });
+ });
+ if (eod.state === 'waiting_upstream' || eod.state === 'cutoff_failed') {
+ incidents.push({ sev: eod.state === 'cutoff_failed' ? 'fail' : 'slow', tag: eod.state, title: eod.state === 'cutoff_failed' ? '盘后批已截止失败' : '自动重试窗口', meta: `15:15–23:30 · 每 30 分钟 · 成功即停 · 已试 ${eod.attempts || 0} 次${(eod.missing_datasets || []).length ? ' · 缺 ' + eod.missing_datasets.join(',') : ''}`, dataset: (eod.missing_datasets || [])[0] || '', cd: eod.next_retry_at });
+ }
+ const hidx = {};
+ ((RAW.providers && RAW.providers.health) || []).forEach((r) => { (hidx[r.provider] = hidx[r.provider] || []).push(r); });
+ Object.keys(hidx).forEach((prov) => {
+ const bad = (hidx[prov] || []).filter((r) => (r.consec_failures || 0) > 0 && bucketOf(r.state) === 'fail');
+ if (bad.length) incidents.push({ sev: 'fail', tag: prov, title: `${prov} 部分接口连续失败`, meta: bad.map((r) => `${r.interface}×${r.consec_failures}`).join(' · '), provider: prov });
+ });
+ const ifindCat = catalogByProv.ifind;
+ if (ifindCat && !(ifindCat.credential && ifindCat.credential.configured)) {
+ incidents.push({ sev: 'off', tag: '未配置', title: 'iFinD · 授权源未启用', meta: '凭证未配置 · 功能隐藏 · 配置后启用', provider: 'ifind' });
+ }
+
+ return {
+ trade_date: ov.trade_date, session_phase: ov.session_phase, eod, rev,
+ publishedCount, totalOfficial, anomalyCount, successRate,
+ fallbackPages, datasetCards, observers, siteImpact, okPages, timeline, incidents,
+ unpublished: OFFICIAL_DATASET_IDS.filter((id) => !pubsByDs[id] || pubsByDs[id].state !== 'published'),
+ };
+}
+
+function heroHtml(vm) {
+ const anomalyTxt = vm.anomalyCount ? `
${vm.anomalyCount} 项异常` : `
无异常`;
+ const tags = [];
+ if (vm.eod.state === 'waiting_upstream') tags.push(tagHtml('amb', `
等待上游 · 已试 ${vm.eod.attempts || 0} 次`));
+ if (vm.rev.state === 'waiting_review') tags.push(tagHtml('cy', '复核 · 待发布'));
+ const subParts = [];
+ if (vm.eod.attempts) subParts.push(`已试
${vm.eod.attempts} 次`);
+ if (vm.eod.next_retry_at) subParts.push(`下次重试
--:--`);
+ if ((vm.eod.missing_datasets || []).length) subParts.push(`缺
${esc(vm.eod.missing_datasets.join(','))}`);
+ if (vm.rev.window) subParts.push(`复核窗口
${esc(vm.rev.window)}`);
+ return `
` +
+ `
` +
+ `
${vm.anomalyCount ? '部分异常' : '整体可用'}·${anomalyTxt}
` +
+ `
${tags.join('')}
` +
+ (subParts.length ? `
${subParts.join('|')}
` : '') +
+ `
` +
+ `
` +
+ kpiHtml('已发布数据集', `${vm.publishedCount}/${vm.totalOfficial}`, vm.unpublished.length ? `${vm.unpublished.map((d) => DATASET_NAME[d] || d).join(' · ')} 未发布` : '全部已发布') +
+ kpiHtml('今日调用成功率', vm.successRate == null ? '—' : `${vm.successRate.toFixed(1)}%`, null, 'mint', vm.successRate != null) +
+ kpiHtml('主站回退页面', `${vm.fallbackPages.length} 页`, vm.fallbackPages.length ? esc(vm.fallbackPages[0]) : '实时层主源正常', vm.fallbackPages.length ? 'amb' : 'txt') +
+ kpiHtml('EOD 尝试', `${vm.eod.attempts || 0} 次`, '上限 5 · 23:30 止', vm.eod.attempts ? 'rd' : 'txt') +
+ `
`;
+}
+function datasetStripHtml(vm) {
+ return `
${vm.datasetCards.map((d) => {
+ const ring = d.bucket === 'fail' ? 'fail' : d.bucket === 'degrade' ? 'slow' : d.bucket === 'review' ? 'review' : '';
+ const sparkTone = d.bucket === 'fail' ? 'rd' : d.bucket === 'degrade' ? 'amb' : d.bucket === 'review' ? 'cy' : 'mint';
+ let note = '';
+ if (d.time) note += `
${esc(d.time)} · `;
+ if (d.rows) note += `
${Number(d.rows).toLocaleString('en-US')} 行`;
+ if (d.err) { const brief = String(d.err).length > 22 ? String(d.err).slice(0, 22) + '…' : d.err; note += `
${esc(brief)}`; }
+ if (!d.time && !d.rows && !d.err) note = '
今日尚未发布';
+ return `
` +
+ `
${esc(d.name)}${pillHtml(d.bucket)}
` +
+ `
${note}
` +
+ sparkHtml(`ds:${d.id}`, sparkTone, 96, 16) +
+ `
`;
+ }).join('')}
`;
+}
+function observatoryHtml(vm) {
+ const legendByProv = { eastmoney: 'EM 东财', tencent: 'TX 腾讯' };
+ const right = `
${Object.keys(legendByProv).map((p) => `${ledHtml('ok')}${legendByProv[p]}`).join('')}`;
+ const rows = vm.observers.length ? vm.observers.map((o) => `
` +
+ `| ${esc(o.name)} | ` +
+ `${o.provider === 'eastmoney' ? '东方财富' : '腾讯行情'} | ` +
+ `${ageHtml(o.lastOk)} | ` +
+ `${latHtml(o.lat, o.bucket !== 'ok')} | ` +
+ `${sparkHtml(o.jid, o.bucket === 'slow' ? 'amb' : 'mint', 64, 16)} | ` +
+ `${pillHtml(o.bucket)}${o.note ? `${esc(o.note)}` : ''} | ` +
+ `
`).join('') : `
| 尚无真实观测记录(等待首次探测/调用) |
`;
+ const body = `
| 观察项 | 当前来源 | 缓存年龄 | 延迟 | 趋势 | 状态 · 备注 |
${rows}
`;
+ return panelHtml('实时观察层', right, body, false);
+}
+function siteImpactHtml(vm) {
+ const bad = vm.siteImpact.filter((s) => s.bucket !== 'ok');
+ const right = `
${vm.okPages} 项正常 · ${vm.siteImpact.length - vm.okPages} 项待关注`;
+ const rows = bad.length ? bad.slice(0, 5).map((s) => `
` +
+ `| ${esc(s.page)} | ` +
+ `${pillHtml(s.bucket)} | ` +
+ `${s.note ? esc(s.note) : '—'} | ` +
+ ` | ` +
+ `
`).join('') : `
| 全部页面正常 | ${pillHtml('ok')} | — | |
`;
+ const footPages = vm.siteImpact.map((s) => ({ name: s.short, bucket: s.bucket }));
+ const byPage = new Map();
+ footPages.forEach((p) => { const cur = byPage.get(p.name); if (!cur || SEV[p.bucket] > SEV[cur]) byPage.set(p.name, p.bucket); });
+ const foot = ``;
+ return panelHtml('主站影响', right, `
${foot}`, false);
+}
+function timelineHtml(vm) {
+ const nodes = vm.timeline;
+ if (!nodes.length) return panelHtml('今晚时间线', '', '
暂无可展示的调度任务
', true);
+ const W = 560, H = 150, x0 = 34, x1 = W - 30, y = 75;
+ const step = (x1 - x0) / Math.max(1, nodes.length - 1);
+ const X = (i) => x0 + i * step;
+ const now = new Date();
+ const nowMinutes = now.getHours() * 60 + now.getMinutes();
+ const toMin = (t) => { const [h, m] = t.split(':').map(Number); return h * 60 + (m || 0); };
+ let nowX = X(0);
+ for (let i = 0; i < nodes.length - 1; i++) {
+ const a = toMin(nodes[i].t), b = toMin(nodes[i + 1].t);
+ if (nowMinutes >= a && nowMinutes <= b && b > a) { nowX = X(i) + (X(i + 1) - X(i)) * ((nowMinutes - a) / (b - a)); break; }
+ if (nowMinutes < toMin(nodes[0].t)) { nowX = X(0); break; }
+ if (nowMinutes > toMin(nodes[nodes.length - 1].t)) { nowX = X(nodes.length - 1); }
+ }
+ let s = `
`;
+ const range = nodes.length ? `${nodes[0].t} → ${nodes[nodes.length - 1].t}` : '';
+ const right = `
现在 ${p2(now.getHours())}:${p2(now.getMinutes())} · ${range} · SCHEDULER.PY`;
+ return panelHtml('今晚时间线', right, s, false);
+}
+function incidentsHtml(vm) {
+ if (!vm.incidents.length) return panelHtml('待处理异常', tagHtml('mint', '0 OPEN'), '
当前无待处理异常
', true);
+ const cards = vm.incidents.map((it) => {
+ const tag = it.sev === 'fail' ? tagHtml('rd', esc(it.tag)) : it.sev === 'slow' ? tagHtml('amb', esc(it.tag)) : tagHtml('dashed', esc(it.tag));
+ const meta = it.cd
+ ? `${it.meta}
· 下次重试 --m--s`
+ : it.meta;
+ return `
${tag}` +
+ `
` +
+ `
${esc(it.title)}
` +
+ `
${meta}
` +
+ `
` +
+ `
` +
+ `
`;
+ }).join('');
+ return panelHtml('待处理异常', tagHtml('rd', vm.incidents.length + ' OPEN'), `
${cards}
`, true);
+}
+function overviewHtml() {
+ const vm = buildOverviewVM();
+ return `
${heroHtml(vm)}${datasetStripHtml(vm)}` +
+ `
${observatoryHtml(vm)}${siteImpactHtml(vm)}
` +
+ `
${timelineHtml(vm)}${incidentsHtml(vm)}
`;
+}
+
+/* =====================================================================
+ 数据源配置页(分组接口表照抄打样 mx-grid 结构,数据全部真实)
+ ===================================================================== */
+const srcUiState = {};
+const PROV_ROLE_TEXT = {
+ tushare: 'official · 盘后正式数据唯一源',
+ eastmoney: 'free · 盘中观察主源',
+ tencent: 'free · 盘中观察备源',
+ ifind: 'licensed · 图表 / 问财 / 实时快照',
+};
+function buildSourcesVM() {
+ const catalog = (RAW.catalog && RAW.catalog.items) || [];
+ const legacy = {};
+ (RAW.sources && RAW.sources.items || []).forEach((it) => { legacy[it.provider] = it; });
+ const cidx = {};
+ ((RAW.providers && RAW.providers.recent_calls) || []).forEach((c) => { if (!cidx[c.provider]) cidx[c.provider] = c; });
+ const active = catalog.filter((c) => c.role !== 'reserved');
+ const reserved = catalog.filter((c) => c.role === 'reserved');
+ const totalIfaces = active.reduce((n, c) => n + c.interfaces.length, 0);
+ // “在用”= 已登记且已观察到调用(接口名或数据集名匹配,后端 join 已标注)
+ const usedIfaces = active.reduce((n, c) => n + c.interfaces.filter((i) => i.observed).length, 0);
+ const cards = active.map((c) => {
+ const cred = c.credential || {};
+ const observedRows = c.interfaces.filter((i) => i.observed);
+ const worst = observedRows.length ? worstBucket(observedRows.map((i) => bucketOf(i.observed_state))) : null;
+ const configured = cred.configured;
+ let bucket;
+ if (worst) bucket = worst;
+ else if (configured) bucket = 'plan';
+ else bucket = 'off';
+ const lastCall = cidx[c.provider];
+ if (!(c.provider in srcUiState)) srcUiState[c.provider] = { open: true, probing: false, probeResult: null };
+ const groups = [];
+ const groupMap = new Map();
+ c.interfaces.forEach((i) => {
+ const g = i.group || '其他';
+ if (!groupMap.has(g)) groupMap.set(g, []);
+ groupMap.get(g).push(i);
+ });
+ groupMap.forEach((items, name) => groups.push({ name, items }));
+ const ifaceRows = (group) => group.items.map((i) => ({
+ api: i.interface, use: i.capability,
+ ok: i.observed ? (i.observed_at ? hm(i.observed_at) : '—') : '—',
+ lat: i.observed ? i.observed_latency_ms : null,
+ st: i.observed ? bucketOf(i.observed_state) : (configured ? 'plan' : 'off'),
+ obs: i.observed, basis: i.observed_basis,
+ err: i.observed && i.observed_state !== 'ok' ? (i.observed_note || '') : '',
+ }));
+ return {
+ id: c.provider, name: c.label, roleText: PROV_ROLE_TEXT[c.provider] || c.role,
+ tag: c.credential_key ? 'licensed' : 'free',
+ bucket, credential: cred,
+ usedCount: c.interfaces.filter((i) => i.observed).length, totalCount: c.interfaces.length,
+ lastProbe: lastCall ? `${hm(lastCall.fetched_at)} ${lastCall.status === 'ok' ? `✓ ${lastCall.latency_ms ?? ''}ms` : `× ${lastCall.error || 'ERROR'}`}` : '—',
+ latency: observedRows.length ? (observedRows.map((r) => r.observed_latency_ms).filter((v) => v != null)[0] || null) : null,
+ error: observedRows.find((r) => r.observed_state !== 'ok' && r.observed_note)?.observed_note || '',
+ groups: groups.map((g) => ({ name: g.name, rows: ifaceRows(g) })),
+ };
+ });
+ return { cards, reserved, totalIfaces, usedIfaces };
+}
+function sourceCardHtml(card) {
+ const ui = srcUiState[card.id];
+ const ringStyle = card.bucket === 'fail' ? 'border-color:rgba(248,113,113,.35)' :
+ card.bucket === 'degrade' ? 'border-color:rgba(251,191,36,.25)' :
+ card.bucket === 'slow' ? 'border-color:rgba(251,191,36,.3)' :
+ card.bucket === 'off' ? 'border-style:dashed;border-color:#243152' : '';
+ const ledSt = HEALTH[card.bucket].led;
+ // source_catalog.py:credential_key 为空的来源(eastmoney/tencent 等公开
+ // 接口)真实语义是"无需凭证"而非"已配置",两者都体现为 configured:true,
+ // 靠 note 字段区分,这里必须原样展示 note,不能一律折叠成"已配置"。
+ const credBadge = card.credential.note ? card.credential.note :
+ card.credential.configured ? `已配置${card.credential.last4 ? ' · ' + card.credential.last4 : ''}` : '未配置';
+ const badges = [credBadge];
+ if (card.credential.configured && card.credential.updated_at) badges.push(`${String(card.credential.updated_at).slice(5, 10).replace('-', '/')} 更新`);
+ badges.push(card.id === 'tushare' ? '主(无备)' : (card.id === 'ifind' ? '图表主源(未启用)' : (card.id === 'tencent' ? '实时层备' : '实时层主')));
+ let h = `
`;
+ h += `` +
+ ledHtml(ledSt, card.bucket === 'fail') +
+ `${esc(card.name)}` +
+ `${esc(card.roleText)}` +
+ pillHtml(card.bucket, card.bucket === 'plan' ? '已配置 · 待观测' : '') +
+ `${badges.map((b) => tagHtml(card.bucket === 'off' ? 'dashed' : 'dim', esc(b))).join('')}` +
+ `` +
+ `` +
+ (card.bucket === 'off'
+ ? ``
+ : ``) +
+ ``;
+ const probeCell = ui.probeResult
+ ? `${ui.probeResult.t} ${ui.probeResult.ok ? `✓ ${ui.probeResult.ms}ms` : '× TIMEOUT'}`
+ : `${esc(card.lastProbe)}`;
+ h += `` +
+ `接口 ${card.usedCount}/${card.totalCount} 在用` +
+ `|最后探测 ${probeCell}` +
+ `|延迟 ${latHtml(card.latency)}` +
+ (card.error ? `|最近错误 ${esc(card.error)}` : (card.bucket !== 'off' ? `|无最近错误` : '')) +
+ `
`;
+ if (ui.open && card.bucket !== 'off') {
+ h += `` + card.groups.map((g) => `
` +
+ `
${esc(g.name)}
` +
+ `
| 接口 | 用途 | 最近成功 | 延迟 | 状态 |
` +
+ g.rows.map((it) => `` +
+ `| ${esc(it.api)} | ` +
+ `${esc(it.use)} | ` +
+ `${esc(it.ok)} | ` +
+ `${latHtml(it.lat)} | ` +
+ `${pillHtml(it.st, it.st === 'plan' && it.obs === false ? '已配置 · 待观测' : '')}${it.err ? `${esc(it.err)}` : ''} | ` +
+ `
`).join('') +
+ `
`).join('') + `
`;
+ }
+ if (card.bucket === 'off') {
+ h += `凭证未配置 · ${card.totalCount} 个预留接口处于停用状态 · 配置后将成为图表 / 问财 / 实时快照主源` +
+ `
${card.groups[0].rows.map((i) => tagHtml('dashed', esc(i.api))).join('')}
` +
+ `
`;
+ }
+ return h + ``;
+}
+function sourcesHtml() {
+ const vm = buildSourcesVM();
+ const alerts = [];
+ vm.cards.forEach((c) => {
+ if (c.bucket === 'fail') alerts.push(`
${ledHtml('fail', true)}${esc(c.name)} 故障${c.error ? ' · ' + esc(c.error) : ''}`);
+ else if (c.bucket === 'degrade' || c.bucket === 'slow') alerts.push(`
${ledHtml('slow')}${esc(c.name)} 降级/延迟`);
+ });
+ return `
` +
+ `
` +
+ `数据源 ${vm.cards.length} 路 · 接口 ${vm.usedIfaces}/${vm.totalIfaces} 在用` +
+ alerts.join('') +
+ `` +
+ `凭证安全存储 · 探测只读 · 不影响盘中链路` +
+ `
` +
+ `
${vm.cards.map(sourceCardHtml).join('')}
` +
+ `
${vm.reserved.map((r) => `
${ledHtml('off')}${esc(r.label)}${tagHtml('dashed', '预留位 · 未接入')}
`).join('')}
` +
+ `
`;
+}
+function renderSourceCards() {
+ const el = $('srcCards');
+ if (!el) return;
+ el.innerHTML = buildSourcesVM().cards.map(sourceCardHtml).join('');
+ paintSparks();
+}
+
+/* =====================================================================
+ 数据血缘页(接口级状态 + 已配置/待观测 + 真实频率 + 事件脉冲)
+ ===================================================================== */
+function pageNameOf(consumer) {
+ const idx = consumer.indexOf('(');
+ return (idx > 0 ? consumer.slice(0, idx) : consumer).trim();
+}
+/* 一条血缘项的服务接口集合(primary/backup 冒号后的真实接口名) */
+function servingInterfaces(item) {
+ const out = new Set([item.dataset]);
+ [item.primary_source, item.backup_source].forEach((src) => {
+ if (!src) return;
+ src.split(':').slice(1).join(':').split('+').forEach((api) => {
+ const name = api.trim();
+ if (name) out.add(name);
+ });
+ });
+ return out;
+}
+/* 接口级状态:只看本数据集真正走的那几条接口,不用提供商级汇总顶替。 */
+function lineageItemRowsHealth(item) {
+ const apis = servingInterfaces(item);
+ return (item.live_provider_health || []).filter((r) => apis.has(r.interface));
+}
+function lineageItemBucket(item) {
+ const rows = lineageItemRowsHealth(item);
+ if (item.tier === 'official') {
+ if (item.publication) return item.publication.state === 'published' ? 'ok' : (item.publication.state === 'degraded' ? 'degrade' : 'fail');
+ return rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'plan';
+ }
+ if (item.tier === 'licensed') {
+ const cred = ((RAW.catalog && RAW.catalog.items) || []).find((c) => c.provider === 'ifind');
+ if (cred && !(cred.credential && cred.credential.configured)) return 'off';
+ return rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'off';
+ }
+ // provisional:已有数据来源;有观测行看接口级状态,暂无观测行=已配置/待观测
+ return rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'plan';
+}
+function lineageItemNote(item) {
+ const rows = lineageItemRowsHealth(item);
+ const note = rows.find((r) => r.last_error)?.last_error || rows.find((r) => r.last_fallback_reason)?.last_fallback_reason || '';
+ if (note) return note;
+ if (item.tier === 'licensed') return 'iFinD 未配置 · 功能隐藏';
+ if (item.tier === 'provisional' && !rows.length) return '已配置 · 暂无观测记录';
+ return '';
+}
+
+const linFilter = { onlyBad: false, src: '全部', q: '' };
+let linHover = null;
+let GW = 1500, GH = 286;
+const gpos = {};
+const adj = { sd: new Map(), ds: new Map(), dp: new Map(), pd: new Map() };
+function adjAdd(m, k, v) { if (!m.has(k)) m.set(k, new Set()); m.get(k).add(v); }
+
+function buildLineageVM() {
+ const items = (RAW.lineage && RAW.lineage.items) || [];
+ const PROV_LABEL = { tushare: 'Tushare', eastmoney: '东方财富', tencent: '腾讯行情', ifind: 'iFinD' };
+ const srcState = {};
+ const dsState = {};
+ const pageState = {};
+ const edgesSD = [];
+ const edgesDP = [];
+ const rows = [];
+ items.forEach((it) => {
+ const rowsHealth = lineageItemRowsHealth(it);
+ const bucket = lineageItemBucket(it);
+ dsState[it.dataset] = bucket;
+ (it.providers || []).forEach((p) => {
+ srcState[p] = worstBucket([srcState[p] || 'ok', ...rowsHealth.filter((r) => r.provider === p).map((r) => bucketOf(r.state))]);
+ edgesSD.push([p, it.dataset]);
+ });
+ const consumers = it.known_consumers || [];
+ const pages = [...new Set(consumers.map(pageNameOf))];
+ pages.forEach((pg) => {
+ pageState[pg] = worstBucket([pageState[pg] || 'ok', bucket]);
+ edgesDP.push([it.dataset, pg]);
+ });
+ rows.push({
+ page: pages.join(' / ') || '—', item: consumers.join(' / '), dataset: it.dataset,
+ via: `${it.primary_source}${it.backup_source ? ' → ' + it.backup_source : ''}`,
+ role: it.backup_source ? '主/备' : (it.tier === 'licensed' ? '主(授权)' : '主(无备)'),
+ freq: it.update_freq || '—',
+ ok: it.tier === 'official' && it.publication ? it.publication.published_at : (rowsHealth.find((r) => r.last_ok_at) || {}).last_ok_at,
+ lat: rowsHealth.map((r) => r.last_latency_ms).find((v) => v != null),
+ st: bucket,
+ note: lineageItemNote(it),
+ });
+ });
+ const sources = Object.keys(srcState).map((id) => ({ id, name: PROV_LABEL[id] || id, st: srcState[id] }));
+ const datasets = Object.keys(dsState).map((id) => ({ id, st: dsState[id] }));
+ const pages = Object.keys(pageState).map((id) => ({ id, name: id, st: pageState[id] }));
+ return { sources, datasets, pages, edgesSD, edgesDP, rows };
+}
+
+function spread(n, y0, y1) { return Array.from({ length: n }, (_, i) => (n === 1 ? (y0 + y1) / 2 : y0 + (i / (n - 1)) * (y1 - y0))); }
+function layoutGraph(vm) {
+ const wide = (window.innerWidth || 1440) > 1100;
+ GW = wide ? 1500 : 1000; GH = wide ? 286 : 560;
+ vm.sources.forEach((s, i) => { gpos['s:' + s.id] = { x: wide ? 80 : 60, y: spread(vm.sources.length, wide ? 60 : 90, wide ? 232 : 500)[i] }; });
+ vm.datasets.forEach((d, i) => { gpos['d:' + d.id] = { x: wide ? 700 : 480, y: spread(vm.datasets.length, wide ? 30 : 45, wide ? 264 : 525)[i] }; });
+ vm.pages.forEach((p, i) => { gpos['p:' + p.id] = { x: wide ? 1400 : 930, y: spread(vm.pages.length, wide ? 26 : 30, wide ? 276 : 540)[i] }; });
+}
+function linkPath(p1, p2) { const mx = (p1.x + p2.x) / 2; return `M ${p1.x} ${p1.y} C ${mx} ${p1.y}, ${mx} ${p2.y}, ${p2.x} ${p2.y}`; }
+const stColorOf = (b) => (HEALTH[b] || HEALTH.off).color;
+
+function lineageGraphHtml(vm) {
+ layoutGraph(vm);
+ adj.sd.clear(); adj.ds.clear(); adj.dp.clear(); adj.pd.clear();
+ vm.edgesSD.forEach(([s, d]) => { adjAdd(adj.sd, s, d); adjAdd(adj.ds, d, s); });
+ vm.edgesDP.forEach(([d, p]) => { adjAdd(adj.dp, d, p); adjAdd(adj.pd, p, d); });
+ let h = `
悬停节点 · 追踪上下游链路
`;
+ return h;
+}
+function activeSetCalc(hover, vm) {
+ if (!hover) return null;
+ const set = new Set([hover]);
+ const isSrc = vm.sources.some((s) => s.id === hover);
+ const isDs = vm.datasets.some((d) => d.id === hover);
+ if (isSrc) { (adj.sd.get(hover) || new Set()).forEach((d) => { set.add(d); (adj.dp.get(d) || new Set()).forEach((p) => set.add(p)); }); }
+ else if (isDs) { (adj.ds.get(hover) || new Set()).forEach((s) => set.add(s)); (adj.dp.get(hover) || new Set()).forEach((p) => set.add(p)); }
+ else { (adj.pd.get(hover) || new Set()).forEach((d) => { set.add(d); (adj.ds.get(d) || new Set()).forEach((s) => set.add(s)); }); }
+ return set;
+}
+function applyLinHover(vm) {
+ const act = activeSetCalc(linHover, vm);
+ const isSrcHov = vm.sources.some((s) => s.id === linHover);
+ document.querySelectorAll('#linSvg path[data-link]').forEach((p) => {
+ const a = p.dataset.a, b = p.dataset.b;
+ const shown = !act || (act.has(a) && act.has(b));
+ let hot = false;
+ if (act && act.has(a) && act.has(b)) hot = isSrcHov ? (a === linHover ? true : (adj.sd.get(linHover) || new Set()).has(a)) : true;
+ p.setAttribute('stroke-width', hot ? 1.8 : 1);
+ p.setAttribute('stroke-opacity', shown ? (act ? 0.85 : (p.dataset.kind === 'sd' ? 0.28 : 0.22)) : 0.05);
+ });
+ document.querySelectorAll('#linSvg g[data-nid]').forEach((g) => {
+ const id = g.dataset.nid, kind = g.dataset.gkind;
+ const dimmed = act && !act.has(id);
+ g.setAttribute('opacity', dimmed ? (kind === 'src' ? 0.3 : 0.25) : 1);
+ if (kind !== 'src') { const t = g.querySelector('text'); if (t) t.setAttribute('fill', act && act.has(id) ? '#e8f1ff' : '#8b9bb4'); }
+ });
+ const hint = $('linHint');
+ if (hint) hint.textContent = linHover ? `FOCUS: ${linHover} · 高亮上下游链路` : '悬停节点 · 追踪上下游链路';
+}
+function setLinHover(id, vm) { linHover = id; applyLinHover(vm); }
+function linRowsData(vm) {
+ return vm.rows.filter((r) => {
+ if (linFilter.onlyBad && !['fail', 'slow', 'degrade', 'off', 'review', 'plan'].includes(r.st)) return false;
+ if (linFilter.src !== '全部' && !r.via.includes({ Tushare: 'tushare', 东方财富: 'eastmoney', 腾讯: 'tencent', iFinD: 'ifind' }[linFilter.src] || '')) return false;
+ if (linFilter.q && !(r.page + r.item + r.dataset + r.via).toLowerCase().includes(linFilter.q.toLowerCase())) return false;
+ return true;
+ });
+}
+function renderLinRows(vm) {
+ const body = $('linBody'), cnt = $('linCount'), bodyN = $('linBodyN');
+ if (!body) return;
+ const rows = linRowsData(vm);
+ if (cnt) cnt.textContent = `${rows.length}/${vm.rows.length} 行`;
+ const cls = (st) => st === 'fail' ? 'row-fail' : st === 'degrade' || st === 'slow' ? 'row-slow' : st === 'off' ? 'row-off' : '';
+ const stLabel = (r) => (r.st === 'plan' && r.note.includes('待观测')) ? '已配置 · 待观测' : '';
+ body.innerHTML = rows.map((r) => `
` +
+ `| ${esc(r.page)} | ` +
+ `${esc(r.item)} | ` +
+ `${esc(r.dataset)} | ` +
+ `${esc(r.via)} | ` +
+ `${esc(r.role)} | ` +
+ `${esc(r.freq)} | ` +
+ `${r.ok ? esc(hm(r.ok)) : '—'} | ` +
+ `${r.lat != null ? Number(r.lat).toLocaleString('en-US') + 'ms' : '—'} | ` +
+ `${pillHtml(r.st, stLabel(r))}${r.note && !stLabel(r) ? `${esc(r.note)}` : ''} | ` +
+ `
`).join('') || `
| 无匹配记录 · 调整筛选条件 |
`;
+ if (bodyN) bodyN.innerHTML = rows.map((r) => `
` +
+ `| ${esc(r.page)}${esc(r.item)} | ` +
+ `${esc(r.dataset)} | ` +
+ `${esc(r.via)} | ` +
+ `${esc(r.freq)}${esc(r.role)} | ` +
+ `${r.ok ? esc(hm(r.ok)) : '—'}${r.lat != null ? r.lat + 'ms' : '—'} | ` +
+ `${pillHtml(r.st, stLabel(r))}${r.note && !stLabel(r) ? `${esc(r.note)}` : ''} | ` +
+ `
`).join('') || `
| 无匹配记录 · 调整筛选条件 |
`;
+}
+function lineageHtml() {
+ const vm = buildLineageVM();
+ const failDs = vm.datasets.filter((d) => d.st === 'fail');
+ let impactTag;
+ if (failDs.length) {
+ const pages = new Set();
+ failDs.forEach((d) => { (adj.dp.get(d.id) || new Set()).forEach((p) => pages.add(p)); });
+ impactTag = tagHtml('rd', `${failDs.map((d) => d.id).join('/')} 断链影响 ${pages.size} 页`);
+ } else {
+ impactTag = tagHtml('mint', '暂无断链影响');
+ }
+ const html = `
` +
+ `
` +
+ `血缘地图` +
+ `${vm.sources.length} 源 → ${vm.datasets.length} 数据集 → ${vm.pages.length} 页面` +
+ `${ledHtml('ok')}健康${ledHtml('slow')}慢/降级${ledHtml('fail')}失败${ledHtml('plan')}计划${ledHtml('off')}未配置` +
+ `` +
+ impactTag +
+ `
` +
+ `
` +
+ `
` +
+ `
`;
+ return html;
+}
+
+/* =====================================================================
+ 页面路由 / 顶栏 / EVENT TAPE / 抽屉 / 危险操作弹层
+ ===================================================================== */
+const mainEl = $('mainEl');
+function pageFromHash() {
+ const h = location.hash.replace('#', '');
+ return ['overview', 'sources', 'lineage'].includes(h) ? h : 'overview';
+}
+function renderPage(p) {
+ state.page = p;
+ mainEl.dataset.page = p;
+ document.querySelectorAll('[data-nav]').forEach((b) => b.classList.toggle('act', b.dataset.nav === p));
+ if (p === 'overview') { mainEl.innerHTML = overviewHtml(); paintSparks(); flashChangedLatencies(); }
+ else if (p === 'sources') { mainEl.innerHTML = sourcesHtml(); paintSparks(); }
+ else {
+ mainEl.innerHTML = lineageHtml();
+ const vm = buildLineageVM();
+ window.__linVM = vm;
+ renderLinRows(vm);
+ applyLinHover(vm);
+ const svg = $('linSvg');
+ if (svg) svg.addEventListener('mouseleave', () => setLinHover(null, window.__linVM));
+ fitLineageTable();
+ }
+ window.scrollTo(0, 0);
+ updatePhaseTag();
+}
+window.addEventListener('hashchange', () => renderPage(pageFromHash()));
+document.querySelectorAll('[data-nav]').forEach((b) => b.addEventListener('click', () => { location.hash = b.dataset.nav; }));
+let resizeT = null;
+window.addEventListener('resize', () => {
+ clearTimeout(resizeT);
+ resizeT = setTimeout(() => { if (state.page === 'lineage') renderPage('lineage'); }, 180);
+});
+
+/* HEL-529: 血缘详情表随真实数据集数量动态撑高;宽档(>1100px,覆盖
+ 1920×920/1440×900 验收位)给表格容器一个基于剩余可视高度计算的
+ max-height + 内部滚动,保证整页 scrollHeight 始终 <= clientHeight,
+ 同时全部行仍可在容器内滚动查看。窄档(<=1100px)整页纵滚。 */
+function fitLineageTable() {
+ const wrap = document.querySelector('.lin-scroll');
+ const tape = document.querySelector('.tape');
+ if (!wrap || !tape) return;
+ wrap.style.maxHeight = '';
+ if (window.innerWidth <= 1100) return;
+ const tapeTop = tape.getBoundingClientRect().top;
+ const wrapTop = wrap.getBoundingClientRect().top;
+ const mainPadBottom = parseFloat(getComputedStyle(mainEl).paddingBottom) || 0;
+ const avail = tapeTop - wrapTop - mainPadBottom - 4;
+ if (avail > 0) wrap.style.maxHeight = Math.floor(avail) + 'px';
+}
+
+function updatePhaseTag() {
+ const ov = RAW.overview;
+ if (!ov) return;
+ const bad = (ov.anomalies || []).length > 0;
+ $('radarBlipBad').style.display = bad ? '' : 'none';
+ $('phaseTag').innerHTML = `${ledHtml('rev', true)} ${esc(ov.session_phase || '')}`;
+}
+
+mainEl.addEventListener('click', (e) => {
+ const pb = e.target.closest('[data-probe]');
+ if (pb) { doProbe(pb.dataset.probe); return; }
+ const tg = e.target.closest('[data-toggle]');
+ if (tg) { const ui = srcUiState[tg.dataset.toggle]; if (ui) { ui.open = !ui.open; renderSourceCards(); } return; }
+ const ch = e.target.closest('[data-cred-hint]');
+ if (ch) {
+ toast('iFinD 凭证需在部署侧配置(DATAHUB_IFIND_* 环境变量或 /v1/credentials/ifind),后台暂未开放在线写入', 'err');
+ return;
+ }
+ const goto = e.target.closest('[data-goto-lineage]');
+ if (goto) { linFilter.q = ''; location.hash = 'lineage'; return; }
+ const inc = e.target.closest('[data-incident-action]');
+ if (inc) { openOpsDrawer('release', { dataset: inc.dataset.dataset, provider: inc.dataset.provider }); return; }
+ if (e.target.closest('#linBad')) {
+ linFilter.onlyBad = !linFilter.onlyBad;
+ renderLinRows(window.__linVM);
+ const btn = $('linBad');
+ btn.classList.toggle('on', linFilter.onlyBad);
+ btn.innerHTML = `${ledHtml(linFilter.onlyBad ? 'fail' : 'plan')} 只看异常`;
+ return;
+ }
+});
+mainEl.addEventListener('change', (e) => { if (e.target.id === 'linSel') { linFilter.src = e.target.value.split('· ')[1] || '全部'; renderLinRows(window.__linVM); } });
+mainEl.addEventListener('input', (e) => { if (e.target.id === 'linQ') { linFilter.q = e.target.value; renderLinRows(window.__linVM); } });
+mainEl.addEventListener('mouseover', (e) => { const g = e.target.closest('g[data-nid]'); if (g) setLinHover(g.dataset.nid, window.__linVM); });
+
+async function doProbe(id) {
+ const ui = srcUiState[id];
+ if (!ui || ui.probing) return;
+ ui.probing = true;
+ renderSourceCards();
+ try {
+ await api(`/admin/api/sources/${id}/probe`, { method: 'POST', body: '{}' });
+ ui.probeResult = { t: `${p2(new Date().getHours())}:${p2(new Date().getMinutes())}`, ok: true, ms: null };
+ } catch (err) {
+ ui.probeResult = { t: `${p2(new Date().getHours())}:${p2(new Date().getMinutes())}`, ok: false, ms: null };
+ toast(err.message, 'err');
+ }
+ ui.probing = false;
+ await refreshQuiet();
+ renderSourceCards();
+}
+
+/* ---------- EVENT TAPE:预装最近真实事件 + 持续滚动 + 新事件闪动 ---------- */
+const tapeSeen = { call: 0, provCall: 0, audit: 0, job: 0 };
+let tapeBuf = [];
+let tapeSeeded = false;
+function tapeEventHtml(e, fresh) {
+ return `
${esc(hm(e.ts))}${esc(e.src)}${esc(e.msg)}` +
+ (e.ok ? `✓${e.ms ? ` ${e.ms}ms` : ''}` : '× 失败') +
+ `///`;
+}
+function renderTape(freshIds) {
+ const items = tapeBuf.slice(-14);
+ const html = items.length
+ ? items.map((e) => tapeEventHtml(e, freshIds && freshIds.has(e.sortId))).join('')
+ : `
暂无真实事件 · 等待首次调用/任务///`;
+ $('tapeA').innerHTML = html;
+ $('tapeB').innerHTML = html;
+}
+function tapeEventsFromRaw() {
+ const evs = [];
+ ((RAW.overview && RAW.overview.recent_calls) || []).forEach((c) => { if (c.id > tapeSeen.call) evs.push({ ts: c.created_at, src: 'tushare', msg: c.endpoint, ok: c.ok, ms: c.latency_ms, sortId: 'c' + c.id }); });
+ ((RAW.providers && RAW.providers.recent_calls) || []).forEach((c) => { if (c.id > tapeSeen.provCall) evs.push({ ts: c.fetched_at, src: c.provider, msg: c.interface, ok: c.status === 'ok', ms: c.latency_ms, sortId: 'p' + c.id }); });
+ ((RAW.audit && RAW.audit.items) || []).forEach((a) => { if (a.id > tapeSeen.audit) evs.push({ ts: a.created_at, src: 'hub_admin', msg: `${a.action} ${a.target || ''}`.trim(), ok: true, sortId: 'a' + a.id }); });
+ ((RAW.jobs && RAW.jobs.runs) || []).forEach((r) => { if (r.id > tapeSeen.job) evs.push({ ts: r.finished_at || r.started_at, src: 'scheduler', msg: `${r.job_id} ${r.state}`, ok: r.state !== 'failed', sortId: 'j' + r.id }); });
+ const maxOf = (arr, key) => arr.reduce((m, x) => Math.max(m, x[key] || 0), 0);
+ tapeSeen.call = Math.max(tapeSeen.call, maxOf((RAW.overview && RAW.overview.recent_calls) || [], 'id'));
+ tapeSeen.provCall = Math.max(tapeSeen.provCall, maxOf((RAW.providers && RAW.providers.recent_calls) || [], 'id'));
+ tapeSeen.audit = Math.max(tapeSeen.audit, maxOf((RAW.audit && RAW.audit.items) || [], 'id'));
+ tapeSeen.job = Math.max(tapeSeen.job, maxOf((RAW.jobs && RAW.jobs.runs) || [], 'id'));
+ return evs;
+}
+function seedTape() {
+ // 首次加载:预装最近的真实事件(打样 EVENT TAPE 开场即有内容滚动)
+ const evs = [];
+ ((RAW.providers && RAW.providers.recent_calls) || []).forEach((c) => evs.push({ ts: c.fetched_at, src: c.provider, msg: c.interface, ok: c.status === 'ok', ms: c.latency_ms, sortId: 'p' + c.id }));
+ ((RAW.overview && RAW.overview.recent_calls) || []).forEach((c) => evs.push({ ts: c.created_at, src: 'tushare', msg: c.endpoint, ok: c.ok, ms: c.latency_ms, sortId: 'c' + c.id }));
+ ((RAW.jobs && RAW.jobs.runs) || []).forEach((r) => evs.push({ ts: r.finished_at || r.started_at, src: 'scheduler', msg: `${r.job_id} ${r.state}`, ok: r.state !== 'failed', sortId: 'j' + r.id }));
+ ((RAW.audit && RAW.audit.items) || []).forEach((a) => evs.push({ ts: a.created_at, src: 'hub_admin', msg: `${a.action} ${a.target || ''}`.trim(), ok: true, sortId: 'a' + a.id }));
+ evs.sort((x, y) => String(x.ts).localeCompare(String(y.ts)));
+ tapeBuf = evs.slice(-14);
+ tapeSeeded = true;
+ renderTape(null);
+}
+function updateTape() {
+ if (!tapeSeeded) { seedTape(); return; }
+ const evs = tapeEventsFromRaw();
+ if (!evs.length) return;
+ const fresh = new Set(evs.map((e) => e.sortId));
+ tapeBuf = tapeBuf.concat(evs).slice(-16);
+ renderTape(fresh);
+}
+
+/* ---------- 抽屉:调度任务 / 盘后发布 / 审计 ---------- */
+let drawerTab = 'jobs';
+function openOpsDrawer(tab, ctx) {
+ drawerTab = tab || 'jobs';
+ drawerCtx = ctx || {};
+ $('drawerMask').classList.add('open');
+ renderDrawer();
+}
+function closeOpsDrawer() { $('drawerMask').classList.remove('open'); }
+let drawerCtx = {};
+$('drawerClose').addEventListener('click', closeOpsDrawer);
+$('drawerMask').addEventListener('click', (e) => { if (e.target.id === 'drawerMask') closeOpsDrawer(); });
+$('opsBtn').addEventListener('click', () => openOpsDrawer('jobs', {}));
+$('drawerTabs').addEventListener('click', (e) => {
+ const b = e.target.closest('[data-dtab]');
+ if (!b) return;
+ drawerTab = b.dataset.dtab;
+ renderDrawer();
+});
+function renderDrawer() {
+ $('drawerTabs').querySelectorAll('[data-dtab]').forEach((b) => b.classList.toggle('act', b.dataset.dtab === drawerTab));
+ $('drawerSub').textContent = { jobs: '既有调度手动触发', release: '盘后发布 / 回滚 / 补数', audit: '操作审计日志' }[drawerTab] || '';
+ if (drawerTab === 'jobs') renderDrawerJobs();
+ else if (drawerTab === 'release') renderDrawerRelease();
+ else renderDrawerAudit();
+}
+function renderDrawerJobs() {
+ const jobs = (RAW.jobs && RAW.jobs.jobs) || [];
+ const runs = (RAW.jobs && RAW.jobs.runs) || [];
+ const runsByJob = {};
+ runs.forEach((r) => { (runsByJob[r.job_id] = runsByJob[r.job_id] || []).push(r); });
+ $('drawerBody').innerHTML = `
+
-
当前映射
- ${table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], data.publications.map((row) => [
- esc(row.dataset), esc(row.active_batch), esc(row.prev_batch), esc(row.state), esc(row.published_at),
- row.prev_batch ? `
` : "-",
- ]))}
-
批次
- ${table(["batch_id", "数据集", "状态", "行数", "错误"], data.batches.map((row) => [
- esc(row.batch_id), esc(row.dataset), esc(row.state), row.rows_out ?? "", esc(row.error),
- ]))}
- `;
+
+
最近运行(最新 20 条)
+
| ID | 任务 | 状态 | 开始 | 结束 | 错误 |
+ ${runs.slice(0, 20).map((r) => `| ${r.id} | ${esc(r.job_id)} | ${pillHtml(bucketOf(r.state))} | ${esc(r.started_at || '')} | ${esc(r.finished_at || '')} | ${esc(r.error || '')} |
`).join('') || '| 暂无运行记录 |
'}
+
+
`;
+ $('drawerBody').querySelectorAll('[data-run]').forEach((btn) => {
+ btn.addEventListener('click', async () => {
+ const dateInput = $('drawerBody').querySelector(`[data-run-date="${btn.dataset.run}"]`);
+ try {
+ await api(`/admin/api/jobs/${btn.dataset.run}/run`, { method: 'POST', body: JSON.stringify({ trade_date: (dateInput && dateInput.value) || '' }) });
+ toast('已触发:' + btn.dataset.run, 'ok');
+ await refreshQuiet(); renderDrawer();
+ } catch (err) { toast(err.message, 'err'); }
+ });
+ });
+}
+function renderDrawerRelease() {
+ const date = drawerCtx.date || today();
+ $('drawerBody').innerHTML = `
+
+
+
+
`;
+ $('relLoad').addEventListener('click', async () => { drawerCtx.date = $('relDate').value; await loadRelease(); });
+ $('relBackfill').addEventListener('click', () => openDangerModal('backfill', { dataset: drawerCtx.dataset || '', date: drawerCtx.date || date }));
+ loadRelease();
+}
+async function loadRelease() {
+ const date = drawerCtx.date || today();
+ let data;
+ try { data = await api(`/admin/api/batches?date=${encodeURIComponent(date)}`); } catch (err) { toast(err.message, 'err'); return; }
+ $('relBody').innerHTML = `
+
发布映射
+
| 数据集 | 活跃批次 | 上一批次 | 状态 | 发布时间 | 操作 |
+ ${data.publications.map((row) => `| ${esc(row.dataset)} | ${esc(row.active_batch)} | ${esc(row.prev_batch || '')} | ${pillHtml(bucketOf(row.state))} | ${esc(row.published_at || '')} |
+ ${row.prev_batch ? `` : '—'} |
`).join('') || '| 当日暂无发布记录 |
'}
+
+
批次
+
| batch_id | 数据集 | 状态 | 行数 | 错误 |
+ ${data.batches.map((row) => `| ${esc(row.batch_id)} | ${esc(row.dataset)} | ${pillHtml(bucketOf(row.state))} | ${row.rows_out ?? ''} | ${esc(row.error || '')} |
`).join('') || '| 当日暂无批次 |
'}
+
`;
+ $('relBody').querySelectorAll('[data-rollback]').forEach((btn) => btn.addEventListener('click', () => openDangerModal('rollback', { dataset: btn.dataset.rollback, date })));
+}
+function renderDrawerAudit() {
+ const items = (RAW.audit && RAW.audit.items) || [];
+ $('drawerBody').innerHTML = `
| 时间 | 操作者 | 动作 | 对象 | 详情 |
+ ${items.map((row) => `| ${esc(row.created_at)} | ${esc(row.actor)} | ${esc(row.action)} | ${esc(row.target || '')} | ${esc(row.detail || '')} |
`).join('') || '| 暂无审计记录 |
'}
+
`;
}
-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 }),
+/* ---------- 危险操作确认弹层(回滚 / 补数) ---------- */
+let dangerCtx = null;
+function openDangerModal(kind, ctx) {
+ dangerCtx = { kind, ...ctx };
+ const word = `${ctx.dataset}:${ctx.date}`;
+ $('modalTitle').textContent = kind === 'rollback' ? '确认回滚' : '确认补数';
+ $('modalDesc').textContent = kind === 'rollback'
+ ? `将把 ${ctx.dataset} 在 ${ctx.date} 的发布回退到上一批次,需管理密码与确认词。`
+ : `将对 ${ctx.dataset || '(请先在发布列表选择数据集)'} 于 ${ctx.date} 重新补数并发布,需管理密码与确认词。`;
+ $('modalConfirmWord').textContent = word;
+ $('modalPassword').value = ''; $('modalConfirm').value = ''; $('modalErr').textContent = '';
+ $('modalMask').classList.add('open');
+}
+$('modalCancel').addEventListener('click', () => $('modalMask').classList.remove('open'));
+$('modalMask').addEventListener('click', (e) => { if (e.target.id === 'modalMask') $('modalMask').classList.remove('open'); });
+$('modalOk').addEventListener('click', async () => {
+ if (!dangerCtx) return;
+ const password = $('modalPassword').value;
+ const confirm = $('modalConfirm').value;
+ const path = dangerCtx.kind === 'rollback' ? '/admin/api/rollback' : '/admin/api/backfill';
+ try {
+ await api(path, { method: 'POST', body: JSON.stringify({ dataset: dangerCtx.dataset, trade_date: dangerCtx.date, password, confirm }) });
+ $('modalMask').classList.remove('open');
+ toast('操作成功', 'ok');
+ await refreshQuiet();
+ if (state.page === 'overview') renderPage('overview');
+ if (drawerTab === 'release') loadRelease();
+ } catch (err) {
+ $('modalErr').textContent = err.message;
+ }
+});
+
+/* ---------- toast ---------- */
+let toastT = null;
+function toast(msg, kind) {
+ const el = $('toastEl');
+ el.textContent = msg;
+ el.className = 'toast show' + (kind ? ' ' + kind : '');
+ clearTimeout(toastT);
+ toastT = setTimeout(() => { el.classList.remove('show'); }, 3200);
+}
+
+/* ================= 顶栏:时钟 / 减少动态 ================= */
+let clockEls = null;
+function updateClock() {
+ if (!clockEls) clockEls = { d: $('ckD'), h: $('ckH'), m: $('ckM'), s: $('ckS') };
+ const now = new Date();
+ const ds = `${now.getFullYear()}-${p2(now.getMonth() + 1)}-${p2(now.getDate())}`;
+ if (clockEls.d.textContent !== ds) clockEls.d.textContent = ds;
+ const hs = p2(now.getHours()), ms = p2(now.getMinutes()), ss = p2(now.getSeconds());
+ if (clockEls.h.textContent !== hs) clockEls.h.textContent = hs;
+ if (clockEls.m.textContent !== ms) clockEls.m.textContent = ms;
+ if (clockEls.s.textContent !== ss) clockEls.s.textContent = ss;
+}
+function applyCalm(on) {
+ state.calm = on;
+ // 挂在 body + #app(覆盖 appRoot 及其兄弟 drawer/modal/toast),否则
+ // .reduce-motion * 选不中旁路层过渡。
+ document.body.classList.toggle('reduce-motion', on);
+ const app = $('app');
+ if (app) app.classList.toggle('reduce-motion', on);
+ $('calmBtn').textContent = on ? '恢复动态' : '减少动态';
+ $('calmBtn').classList.toggle('on', on);
+}
+$('calmBtn').addEventListener('click', () => applyCalm(!state.calm));
+if (window.matchMedia) {
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
+ const syncMq = () => { if (mq.matches && !state.calm) applyCalm(true); };
+ syncMq();
+ mq.addEventListener ? mq.addEventListener('change', syncMq) : mq.addListener(syncMq);
+}
+
+/* ================= 动态更新:200ms tick(打样节奏) =================
+ - 时钟每秒翻字(冒号 blink 由 CSS 承担)
+ - 缓存年龄依据真实时间戳每秒增长(>30s 变琥珀)
+ - 倒计时按真实 next_retry_at 每秒递减
+ - sparkline 端点闪烁由 CSS 承担,折线只在真实历史变化时重画
+ - 延迟数字只在真实值变化时闪一下(flash),绝不随机伪造数值 */
+function paintSparks() {
+ document.querySelectorAll('svg.spark').forEach((svg) => sparkPaint(svg));
+}
+const prevLat = new Map();
+function flashChangedLatencies() {
+ document.querySelectorAll('[data-lat]').forEach((el) => {
+ const jid = el.closest('[data-jid]') ? el.closest('[data-jid]').dataset.jid : (el.closest('tr') ? 'row:' + [...el.closest('tr').children].indexOf(el) : '');
+ const v = el.dataset.lat;
+ const key = state.page + ':' + (el.closest('[data-dataset]')?.dataset.dataset || jid) + ':' + v;
+ const prev = prevLat.get(el);
+ if (prev !== undefined && prev !== v) {
+ el.classList.remove('flash');
+ void el.offsetWidth;
+ el.classList.add('flash');
+ }
+ prevLat.set(el, v);
});
- render();
+}
+function updateDynamics() {
+ const now = Date.now();
+ document.querySelectorAll('[data-age-epoch]').forEach((el) => {
+ const ms = +el.dataset.ageEpoch;
+ if (!ms || Number.isNaN(ms)) return;
+ const s = Math.max(0, Math.floor((now - ms) / 1000));
+ const txt = fmtAgeSec(s);
+ if (el.textContent !== txt) el.textContent = txt;
+ const color = s > 30 ? '#fbbf24' : '#8b9bb4';
+ if (el.style.color !== color) el.style.color = color;
+ });
+ document.querySelectorAll('[data-cd-epoch]').forEach((el) => {
+ const target = +el.dataset.cdEpoch;
+ if (!target || Number.isNaN(target)) return;
+ const left = Math.floor((target - now) / 1000);
+ if (left > 0) {
+ const m = Math.floor(left / 60), s = left % 60;
+ const txt = el.dataset.cdfmt === 'ms' ? `${m}m${p2(s)}s` : `${p2(m)}:${p2(s)}`;
+ if (el.textContent !== txt) el.textContent = txt;
+ } else if (el.textContent !== '已到点') {
+ el.textContent = '已到点';
+ }
+ });
+}
+
+/* ================= 轮询:拉取真实数据、驱动渲染、可见性/网络门禁 ================= */
+const dataVersion = { overview: '', sources: '', lineage: '' };
+function snapshotVersion(part) {
+ if (part === 'overview') return JSON.stringify([RAW.overview, RAW.batches && RAW.batches.batches, RAW.providers && RAW.providers.health, RAW.catalog]);
+ if (part === 'sources') return JSON.stringify([RAW.catalog, RAW.providers && RAW.providers.recent_calls, RAW.sources]);
+ return JSON.stringify(RAW.lineage);
+}
+const Poller = (() => {
+ let timer = null;
+ let running = false;
+ const INTERVAL = 4000;
+ async function tick() {
+ if (!running) return;
+ try {
+ await fetchAll();
+ updateTape();
+ const vOv = snapshotVersion('overview'), vSrc = snapshotVersion('sources'), vLin = snapshotVersion('lineage');
+ if (state.page === 'overview' && vOv !== dataVersion.overview) {
+ dataVersion.overview = vOv;
+ mainEl.innerHTML = overviewHtml();
+ paintSparks();
+ flashChangedLatencies();
+ } else if (state.page === 'overview') {
+ flashChangedLatencies();
+ }
+ if (state.page === 'sources' && vSrc !== dataVersion.sources) {
+ dataVersion.sources = vSrc;
+ renderSourceCards();
+ }
+ if (state.page === 'lineage' && vLin !== dataVersion.lineage) {
+ dataVersion.lineage = vLin;
+ mainEl.innerHTML = lineageHtml();
+ const vm = buildLineageVM();
+ window.__linVM = vm;
+ renderLinRows(vm);
+ applyLinHover(vm);
+ const svg = $('linSvg');
+ if (svg) svg.addEventListener('mouseleave', () => setLinHover(null, window.__linVM));
+ fitLineageTable();
+ }
+ // 真实调用事件脉冲:血缘页上对应来源节点亮一圈 ledPing 节奏的光环
+ pulseSourcesOnNewCalls();
+ updatePhaseTag();
+ setNetOk(true);
+ } catch (err) {
+ setNetOk(false);
+ }
+ }
+ function setNetOk(ok) {
+ $('liveSpan').style.opacity = ok ? '1' : '.35';
+ }
+ function scheduleNext() { timer = setTimeout(async () => { await tick(); scheduleNext(); }, INTERVAL); }
+ return {
+ async start() {
+ if (running) return;
+ running = true;
+ await tick();
+ scheduleNext();
+ },
+ stop() { running = false; if (timer) clearTimeout(timer); timer = null; },
+ isRunning() { return running; },
+ };
+})();
+
+/* 真实调用脉冲:provider_call_log 出现新调用时,给血缘图对应来源节点
+ 叠一圈与 ledPing 同节奏(1.6s ease-out)的扩散光环;这是事件动效,
+ 与 gnode-pulse 的静态呼吸不同,一次即消散。 */
+let pulseSeenCall = 0;
+function pulseSourcesOnNewCalls() {
+ if (state.page !== 'lineage') {
+ pulseSeenCall = ((RAW.providers && RAW.providers.recent_calls) || []).reduce((m, c) => Math.max(m, c.id || 0), pulseSeenCall);
+ return;
+ }
+ const calls = (RAW.providers && RAW.providers.recent_calls) || [];
+ const fresh = calls.filter((c) => (c.id || 0) > pulseSeenCall);
+ pulseSeenCall = calls.reduce((m, c) => Math.max(m, c.id || 0), pulseSeenCall);
+ fresh.forEach((c) => {
+ const g = document.querySelector(`#linSvg g[data-nid="${CSS.escape(c.provider)}"]`);
+ if (!g) return;
+ const circle = g.querySelector('circle');
+ if (!circle) return;
+ const ring = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
+ ring.setAttribute('cx', circle.getAttribute('cx'));
+ ring.setAttribute('cy', circle.getAttribute('cy'));
+ ring.setAttribute('r', '5');
+ ring.setAttribute('fill', 'none');
+ ring.setAttribute('stroke', '#22d3ee');
+ ring.setAttribute('stroke-width', '1.2');
+ ring.setAttribute('class', 'node-ping');
+ g.appendChild(ring);
+ setTimeout(() => ring.remove(), 1700);
+ });
+}
+
+function resetTapeBaselines() {
+ // 恢复轮询时静默重建基线,不补播暂停期间错过的事件(但保留已上带内容)。
+ tapeSeen.call = tapeSeen.provCall = tapeSeen.audit = tapeSeen.job = Number.MAX_SAFE_INTEGER;
+}
+document.addEventListener('visibilitychange', () => {
+ if (document.hidden) { Poller.stop(); }
+ else if (!$('appRoot').hidden) { resetTapeBaselines(); Poller.start(); }
+});
+window.addEventListener('offline', () => { Poller.stop(); $('liveSpan').style.opacity = '.35'; });
+window.addEventListener('online', () => { if (!$('appRoot').hidden && !document.hidden) { resetTapeBaselines(); Poller.start(); } });
+
+async function refreshQuiet() {
+ try { await fetchAll(); } catch {}
+}
+
+/* ================= 启动 ================= */
+async function startApp() {
+ await fetchAll();
+ dataVersion.overview = snapshotVersion('overview');
+ dataVersion.sources = snapshotVersion('sources');
+ dataVersion.lineage = snapshotVersion('lineage');
+ pulseSeenCall = ((RAW.providers && RAW.providers.recent_calls) || []).reduce((m, c) => Math.max(m, c.id || 0), 0);
+ renderPage(pageFromHash());
+ updateTape(); // 预装最近真实事件并开始滚动
+ updateClock();
+ setInterval(() => { updateClock(); updateDynamics(); }, 200); // 打样 tick 节奏
+ await Poller.start();
}
boot();
diff --git a/xiaobai-datahub/admin/index.html b/xiaobai-datahub/admin/index.html
index b7eb893..1c9e5a7 100644
--- a/xiaobai-datahub/admin/index.html
+++ b/xiaobai-datahub/admin/index.html
@@ -1,16 +1,18 @@
-
-
-
xiaobai-datahub 管理后台
-
+
+
+
小白复盘 · 数据中枢
+
-
-
+
+
+
+
数据中枢
- 内网管理后台,用于查看源状态、调度和盘后发布批次。
+ 内网管理后台 · 运行总览 / 数据源配置 / 数据血缘。
+
-
+
+
修改初始密码
+ 首次登录需先设置新密码才能进入。
-
-
-
- xiaobai-datahub
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
小白复盘 · 数据中枢DATA-HUB
+
+
+
+
+
LIVE
+
|::
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
危险操作确认
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/xiaobai-datahub/admin/styles.css b/xiaobai-datahub/admin/styles.css
index 1631ac1..15bba78 100644
--- a/xiaobai-datahub/admin/styles.css
+++ b/xiaobai-datahub/admin/styles.css
@@ -1,51 +1,535 @@
+/* JetBrains Mono (variable, latin subset) — 内嵌,零外部依赖 */
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 100 800;
+ font-display: swap;
+ src: url(data:font/woff2;base64,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) format('woff2');
+}
+
+/* ================= index.css 逐行照抄(去掉 tailwind 指令) ================= */
: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;
+ --ink: #050810;
+ --panel: #0c1220;
+ --panel2: #111a2b;
+ --line: #1a2540;
+ --line2: #243152;
+ --cy: #22d3ee;
+ --mint: #34d399;
+ --amb: #fbbf24;
+ --rd: #f87171;
+ --txt: #d7e1f0;
+ --mut: #8b9bb4;
+ --dim: #54637e;
}
-:root[data-theme="night"] {
- color-scheme: dark;
- --bg: #111318;
- --surface: #1b1e24;
- --text: #e8eaed;
- --muted: #9aa0a6;
- --line: #2a2f38;
- --action: #5b8cff;
+
+html, body, #root { height: 100%; }
+html, body { margin: 0; }
+
+/* HEL-529 生产实现补充:确保原生 [hidden] 属性不被下方任何 display 规则
+ (如 .auth-wrap/.app 等 flex/relative 布局)覆盖——两者选择器优先级相同时
+ 源码顺序在后的规则会赢,必须显式声明 !important 兜底。 */
+[hidden] { display: none !important; }
+
+body {
+ background: var(--ink);
+ color: var(--txt);
+ font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
+ "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", monospace;
+ font-size: 13px;
+ -webkit-font-smoothing: antialiased;
+ overflow-x: hidden;
+}
+
+/* ---------- ambient background: radial depth + grid + scanlines ---------- */
+.bg-ambient {
+ background:
+ radial-gradient(1200px 500px at 70% -10%, rgba(34, 211, 238, 0.055), transparent 60%),
+ radial-gradient(900px 420px at 10% 110%, rgba(52, 211, 153, 0.04), transparent 60%),
+ radial-gradient(700px 500px at 100% 100%, rgba(248, 113, 113, 0.03), transparent 60%),
+ var(--ink);
+}
+.bg-gridlines {
+ background-image:
+ linear-gradient(rgba(26, 37, 64, 0.35) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(26, 37, 64, 0.35) 1px, transparent 1px);
+ background-size: 48px 48px;
+ mask-image: radial-gradient(120% 90% at 50% 0%, black 30%, transparent 90%);
+ -webkit-mask-image: radial-gradient(120% 90% at 50% 0%, black 30%, transparent 90%);
+}
+.scanlines {
+ background: repeating-linear-gradient(
+ to bottom,
+ rgba(255, 255, 255, 0.022) 0px,
+ rgba(255, 255, 255, 0.022) 1px,
+ transparent 1px,
+ transparent 3px
+ );
+}
+
+/* ---------- typography helpers ---------- */
+.lab {
+ font-size: 10px;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ color: var(--dim);
+ font-weight: 500;
+}
+.num { font-variant-numeric: tabular-nums; }
+
+/* ---------- panels ---------- */
+.panel {
+ background: linear-gradient(180deg, rgba(17, 26, 43, 0.6), rgba(12, 18, 32, 0.9));
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ position: relative;
+}
+.panel-hd {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--line);
+ font-size: 11px;
+ letter-spacing: 0.14em;
+ color: var(--mut);
+}
+.panel-hd b { color: var(--txt); font-weight: 600; }
+
+/* ---------- glow status LED ---------- */
+.led {
+ width: 7px; height: 7px; border-radius: 9999px;
+ display: inline-block; flex: none;
+ position: relative;
+}
+.led.ok { background: var(--mint); box-shadow: 0 0 6px 1px rgba(52,211,153,.7); }
+.led.slow { background: var(--amb); box-shadow: 0 0 6px 1px rgba(251,191,36,.7); }
+.led.fail { background: var(--rd); box-shadow: 0 0 6px 1px rgba(248,113,113,.8); }
+.led.rev { background: var(--cy); box-shadow: 0 0 6px 1px rgba(34,211,238,.7); }
+.led.plan { background: #475569; box-shadow: none; }
+.led.off { background: transparent; border: 1px dashed #475569; box-shadow: none; }
+.led.pulse::after {
+ content: ""; position: absolute; inset: -4px; border-radius: 9999px;
+ border: 1px solid currentColor; opacity: .6;
+ animation: ledPing 1.6s ease-out infinite;
+}
+.led.ok.pulse { color: var(--mint); }
+.led.fail.pulse { color: var(--rd); }
+.led.rev.pulse { color: var(--cy); }
+.led.slow.pulse { color: var(--amb); }
+@keyframes ledPing {
+ 0% { transform: scale(.4); opacity: .7; }
+ 80%, 100% { transform: scale(1.4); opacity: 0; }
+}
+
+/* ---------- text glow ---------- */
+.glow-cy { color: var(--cy); text-shadow: 0 0 12px rgba(34,211,238,.45); }
+.glow-mint { color: var(--mint); text-shadow: 0 0 12px rgba(52,211,153,.45); }
+.glow-amb { color: var(--amb); text-shadow: 0 0 12px rgba(251,191,36,.45); }
+.glow-rd { color: var(--rd); text-shadow: 0 0 12px rgba(248,113,113,.5); }
+
+/* ---------- blinking / pulsing ---------- */
+.blink { animation: blink 1.1s steps(2, start) infinite; }
+@keyframes blink { 50% { opacity: 0; } }
+.pulse-dot { animation: pulseDot 1.5s ease-in-out infinite; }
+@keyframes pulseDot { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
+
+/* ---------- flash on value change ---------- */
+.flash { animation: flash .45s ease-out; }
+@keyframes flash {
+ 0% { background: rgba(34, 211, 238, 0.22); color: #e8fbff; }
+ 100% { background: transparent; }
+}
+
+/* ---------- ticker marquee ---------- */
+.marquee-track {
+ display: inline-flex;
+ white-space: nowrap;
+ animation: marquee 46s linear infinite;
+ will-change: transform;
+}
+.marquee-track:hover { animation-play-state: paused; }
+@keyframes marquee {
+ from { transform: translateX(0); }
+ to { transform: translateX(-50%); }
+}
+
+/* ---------- flow lines (lineage / timeline) ---------- */
+.flowline {
+ stroke-dasharray: 4 6;
+ animation: flowDash 1.4s linear infinite;
+}
+@keyframes flowDash { to { stroke-dashoffset: -10; } }
+
+/* ---------- radar sweep ---------- */
+.radar-sweep {
+ background: conic-gradient(from 0deg, rgba(34,211,238,.55), transparent 22%, transparent);
+ animation: sweep 4.2s linear infinite;
+ border-radius: 9999px;
+}
+@keyframes sweep { to { transform: rotate(360deg); } }
+
+/* ---------- buttons ---------- */
+.tbtn {
+ font-size: 11px; letter-spacing: .1em;
+ border: 1px solid var(--line2);
+ color: var(--mut);
+ padding: 4px 12px; border-radius: 4px;
+ background: rgba(17,26,43,.5);
+ transition: all .15s ease;
+ cursor: pointer;
+ font-family: inherit;
+}
+.tbtn:hover { color: var(--cy); border-color: rgba(34,211,238,.5); box-shadow: 0 0 10px rgba(34,211,238,.15); }
+.tbtn:active { transform: translateY(1px); }
+.tbtn:disabled { opacity: .45; cursor: default; }
+.tbtn.on { color: var(--cy); border-color: rgba(34,211,238,.6); background: rgba(34,211,238,.08); }
+
+/* ---------- table ---------- */
+.dtable { width: 100%; border-collapse: collapse; }
+.dtable th {
+ font-size: 10px; letter-spacing: .16em; text-transform: uppercase;
+ color: var(--dim); font-weight: 500; text-align: left;
+ padding: 7px 10px; border-bottom: 1px solid var(--line);
+}
+.dtable td { padding: 7px 10px; border-bottom: 1px solid rgba(26,37,64,.55); vertical-align: middle; }
+.dtable tr:last-child td { border-bottom: none; }
+.dtable tbody tr { transition: background .12s ease; }
+.dtable tbody tr:hover { background: rgba(34,211,238,.04); }
+
+/* ---------- scrollbar ---------- */
+::-webkit-scrollbar { width: 6px; height: 6px; }
+::-webkit-scrollbar-track { background: var(--ink); }
+::-webkit-scrollbar-thumb { background: var(--line2); border-radius: 3px; }
+::-webkit-scrollbar-thumb:hover { background: #31406b; }
+* { scrollbar-width: thin; scrollbar-color: var(--line2) var(--ink); }
+
+/* ---------- reduced motion ---------- */
+.reduce-motion *, .reduce-motion *::before, .reduce-motion *::after {
+ animation-duration: .001s !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: .001s !important;
+}
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: .001s !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: .001s !important;
+ }
+}
+
+/* ================= 结构样式(对应 App.tsx / chrome.tsx 的 tailwind 类) ================= */
+#root { min-height: 100%; }
+.app { min-height: 100%; position: relative; }
+.bg-layer { position: fixed; inset: 0; pointer-events: none; }
+.z0 { z-index: 0; } .z50 { z-index: 50; }
+.scanlines.z50 { opacity: .7; }
+
+/* ---------- 顶栏 ---------- */
+.hdr { position: sticky; top: 0; z-index: 30; border-bottom: 1px solid #1a2540; background: rgba(5,8,16,.88); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
+.hdr-in { display: flex; align-items: center; gap: 12px; padding: 0 16px; height: 48px; }
+.hdr-in > * { flex: none; }
+.hdr-in > .flex1 { flex: 1; }
+.brand { display: flex; flex-direction: column; line-height: 1; flex: none; white-space: nowrap; }
+.brand .b1 { font-size: 13px; font-weight: 600; color: #e8f1ff; letter-spacing: .06em; }
+.brand .b2 { font-size: 9px; color: #54637e; letter-spacing: .26em; margin-top: 4px; }
+.vsep { width: 1px; height: 16px; background: #1a2540; margin: 0 4px; display: inline-block; flex: none; }
+.nav { display: flex; align-items: center; gap: 4px; margin-left: 8px; flex: none; }
+.navbtn { position: relative; padding: 6px 14px; font-size: 12px; letter-spacing: .08em; border-radius: 4px; transition: all .15s; background: none; border: none; cursor: pointer; color: #8b9bb4; font-family: inherit; }
+.navbtn:hover { color: #d7e1f0; background: rgba(34,211,238,.04); }
+.navbtn.act { color: #22d3ee; background: rgba(34,211,238,.08); box-shadow: inset 0 0 0 1px rgba(34,211,238,.35), 0 0 14px rgba(34,211,238,.12); }
+.navbtn .tri { margin-right: 4px; font-size: 9px; display: none; }
+.navbtn.act .tri { display: inline; }
+.navbtn .en { margin-left: 6px; font-size: 9px; letter-spacing: .14em; color: #3d4c66; }
+.navbtn.act .en { color: rgba(34,211,238,.6); }
+.flex1 { flex: 1; }
+.mdtag { display: none; align-items: center; gap: 8px; border-radius: 3px; border: 1px solid rgba(34,211,238,.4); background: rgba(34,211,238,.07); padding: 4px 8px; font-size: 10px; letter-spacing: .14em; color: #22d3ee; }
+@media (min-width: 768px) { .mdtag { display: flex; } }
+@media (max-width: 1150px) { .mdtag { display: none; } }
+.livespan { display: none; align-items: center; gap: 6px; font-size: 10px; letter-spacing: .18em; color: #34d399; }
+@media (min-width: 640px) { .livespan { display: flex; } }
+.livedot { width: 6px; height: 6px; border-radius: 9999px; background: #34d399; box-shadow: 0 0 6px 1px rgba(52,211,153,.7); }
+.clock { font-size: 12px; color: #8b9bb4; letter-spacing: .05em; white-space: nowrap; }
+.clock .csep { margin: 0 6px; color: #243152; }
+.clock .ct { color: #d7e1f0; }
+.clock .cc { color: #22d3ee; }
+.demotag { display: inline-flex; align-items: center; gap: 4px; border-radius: 3px; border: 1px solid rgba(251,191,36,.45); background: rgba(251,191,36,.06); color: #fbbf24; padding: 2px 6px; font-size: 10px; letter-spacing: .08em; white-space: nowrap; }
+
+/* ---------- 雷达 logo ---------- */
+.radar { position: relative; flex: none; border-radius: 9999px; border: 1px solid rgba(34,211,238,.5); overflow: hidden; box-shadow: 0 0 12px rgba(34,211,238,.25), inset 0 0 8px rgba(34,211,238,.15); }
+.radar .sweep { position: absolute; inset: 0; }
+.radar .ring1 { position: absolute; border-radius: 9999px; border: 1px solid rgba(34,211,238,.3); }
+.radar .ring2 { position: absolute; border-radius: 9999px; border: 1px solid rgba(34,211,238,.2); }
+.radar .center { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); width: 3px; height: 3px; border-radius: 9999px; background: #22d3ee; box-shadow: 0 0 6px 2px rgba(34,211,238,.8); }
+.radar .blip { position: absolute; width: 2.5px; height: 2.5px; border-radius: 9999px; }
+
+/* ---------- EVENT TAPE ---------- */
+.tape { position: fixed; bottom: 0; left: 0; right: 0; height: 32px; z-index: 40; display: flex; align-items: center; border-top: 1px solid #1a2540; background: rgba(5,8,16,.92); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); overflow: hidden; }
+.tape-label { flex: none; display: flex; align-items: center; gap: 6px; padding: 0 12px; height: 100%; border-right: 1px solid #1a2540; font-size: 10px; letter-spacing: .18em; color: #54637e; }
+.tape-view { flex: 1; overflow: hidden; position: relative; }
+.tape-item { display: inline-flex; align-items: center; gap: 8px; margin: 0 20px; font-size: 10.5px; letter-spacing: .04em; }
+.tape-item .tt { color: #54637e; }
+.tape-item .ts { color: #22d3ee; }
+.tape-item .tm { color: #8b9bb4; }
+.tape-item .tok { color: #34d399; }
+.tape-item .tbad { color: #f87171; }
+.tape-item .tsep { color: #1a2540; margin-left: 12px; }
+
+/* ---------- 主区 ---------- */
+.main { position: relative; z-index: 10; padding: 12px 16px 48px; max-width: 1720px; margin: 0 auto; }
+.col { display: flex; flex-direction: column; gap: 12px; }
+
+/* ---------- tag / pill ---------- */
+.tag { display: inline-flex; align-items: center; gap: 4px; border-radius: 3px; border: 1px solid #243152; padding: 2px 6px; font-size: 10px; letter-spacing: .08em; line-height: 1.5; color: #8b9bb4; white-space: nowrap; }
+.tag.cy { border-color: rgba(34,211,238,.45); color: #22d3ee; background: rgba(34,211,238,.06); }
+.tag.amb { border-color: rgba(251,191,36,.45); color: #fbbf24; background: rgba(251,191,36,.06); }
+.tag.rd { border-color: rgba(248,113,113,.45); color: #f87171; background: rgba(248,113,113,.07); }
+.tag.mint { border-color: rgba(52,211,153,.45); color: #34d399; background: rgba(52,211,153,.06); }
+.tag.dashed { border-style: dashed; border-color: #243152; color: #54637e; }
+.minidot { width: 6px; height: 6px; border-radius: 9999px; display: inline-block; }
+.spill { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; white-space: nowrap; }
+
+/* ---------- 总览 Hero ---------- */
+.hero { padding: 16px 20px; display: flex; flex-wrap: wrap; align-items: center; gap: 16px 32px; }
+.hero-l { display: flex; flex-direction: column; gap: 8px; min-width: 300px; }
+.hero-title { font-size: 22px; font-weight: 600; letter-spacing: .025em; }
+.hero-title .dot { color: #54637e; margin: 0 8px; }
+.hero-tags { display: flex; align-items: center; gap: 8px; }
+.hero-sub { font-size: 11px; color: #54637e; }
+.hero-sub .sep { margin: 0 8px; color: #243152; }
+.kpis { display: flex; align-items: flex-start; gap: 32px; padding-right: 8px; }
+.kpi { display: flex; flex-direction: column; gap: 4px; }
+.kpi .kv { font-size: 26px; line-height: 1; font-weight: 600; letter-spacing: -.025em; color: #e8f1ff; }
+.kpi .ks { font-size: 10px; color: #54637e; }
+
+/* ---------- 数据集卡 ---------- */
+.ds-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
+@media (min-width: 640px) { .ds-grid { grid-template-columns: repeat(5, 1fr); } }
+@media (min-width: 1280px) { .ds-grid { grid-template-columns: repeat(10, 1fr); } }
+.dsc { border-radius: 5px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; transition: all .15s; cursor: default; border: 1px solid #1a2540; }
+.dsc:hover { transform: translateY(-1px); border-color: #243152; }
+.dsc.fail { border-color: rgba(248,113,113,.55); box-shadow: 0 0 18px rgba(248,113,113,.14); }
+.dsc.fail:hover { border-color: rgba(248,113,113,.55); }
+.dsc.slow { border-color: rgba(251,191,36,.4); }
+.dsc.slow:hover { border-color: rgba(251,191,36,.4); }
+.dsc.review { border-color: rgba(34,211,238,.45); box-shadow: 0 0 14px rgba(34,211,238,.1); }
+.dsc.review:hover { border-color: rgba(34,211,238,.45); }
+.dsc-top { display: flex; align-items: center; justify-content: space-between; }
+.dsc-name { font-size: 12px; font-weight: 500; color: #e8f1ff; }
+.dsc-note { font-size: 10px; color: #54637e; line-height: 1.6; min-height: 28px; }
+
+/* ---------- 总览下半网格 ---------- */
+.ov-grid { display: grid; grid-template-columns: 1fr; gap: 12px; }
+@media (min-width: 1280px) { .ov-grid { grid-template-columns: repeat(12, 1fr); } .ov-l { grid-column: span 7; } .ov-r { grid-column: span 5; } }
+.panel-b { padding: 12px; }
+.legend { display: flex; align-items: center; gap: 12px; font-size: 10px; letter-spacing: .1em; }
+.legend span { display: flex; align-items: center; gap: 4px; }
+.jit { padding: 0 4px; border-radius: 3px; }
+.obs-note { font-size: 10px; color: #fbbf24; }
+.site-foot { padding: 10px 12px; border-top: 1px solid #1a2540; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; font-size: 10px; color: #54637e; }
+.tbtn.mini { padding: 3px 8px; }
+
+/* ---------- 时间线 ---------- */
+@keyframes nowPulse { 0%,100% { opacity: 1; } 50% { opacity: .5; } }
+.now-dot { animation: nowPulse 1.6s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
+@keyframes nowR { 0%,100% { r: 3.6px; } 50% { r: 5.2px; } }
+.now-r { animation: nowR 1.6s ease-in-out infinite; }
+
+/* ---------- 异常卡 ---------- */
+.inc { display: flex; align-items: center; gap: 12px; border-radius: 4px; border: 1px solid #1a2540; border-left-width: 3px; padding: 10px 12px; }
+.inc.fail { border-left-color: #f87171; background: rgba(248,113,113,.05); }
+.inc.slow { border-left-color: #fbbf24; background: rgba(251,191,36,.04); }
+.inc.off { border-left-color: #334155; background: transparent; border-style: dashed; border-color: #243152; border-left-width: 3px; border-left-style: solid; border-left-color: #334155; }
+.inc-title { font-size: 12px; color: #e8f1ff; }
+.inc-meta { font-size: 10px; color: #54637e; margin-top: 2px; }
+
+/* ---------- Sources 页 ---------- */
+.strip { padding: 12px 20px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 24px; }
+.src-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 12px 16px; border-bottom: 1px solid #1a2540; }
+.src-name { font-size: 15px; font-weight: 600; color: #e8f1ff; letter-spacing: .025em; }
+.src-role { font-size: 10px; color: #54637e; letter-spacing: .1em; }
+.src-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding: 8px 16px; border-bottom: 1px solid #1a2540; font-size: 11px; color: #8b9bb4; }
+.src-meta .msep { color: #243152; }
+.mx-grid { display: grid; grid-template-columns: 1fr; gap: 16px 24px; padding: 16px; }
+@media (min-width: 768px) { .mx-grid { grid-template-columns: repeat(2, 1fr); } }
+@media (min-width: 1280px) { .mx-grid { grid-template-columns: repeat(3, 1fr); } }
+.mx-grid > div { min-width: 0; }
+.mx-grid table { table-layout: auto; max-width: 100%; }
+.offbox { border-radius: 4px; border: 1px dashed #243152; padding: 24px 16px; text-align: center; font-size: 11px; color: #54637e; }
+.rsv-grid { display: grid; grid-template-columns: 1fr; gap: 12px; }
+@media (min-width: 768px) { .rsv-grid { grid-template-columns: repeat(3, 1fr); } }
+.rsv { border-radius: 6px; border: 1px dashed #243152; padding: 16px; display: flex; align-items: center; gap: 12px; color: #54637e; }
+@keyframes spin { to { transform: rotate(360deg); } }
+.animate-spin { display: inline-block; animation: spin 1s linear infinite; }
+
+/* ---------- Lineage 页 ---------- */
+.lin-cols { display: flex; justify-content: space-between; padding: 4px 24px 0; font-size: 10px; letter-spacing: .2em; color: #54637e; }
+.lin-hint { position: absolute; left: 24px; bottom: 4px; font-size: 10px; color: #54637e; letter-spacing: .1em; }
+@keyframes gnodePulse { 0%,100% { opacity: 1; } 50% { opacity: .55; } }
+.gnode-pulse { animation: gnodePulse 1.8s ease-in-out infinite; }
+/* 真实调用事件脉冲(HEL-529 数据修正第 6 项):血缘图来源节点在真实调用
+ 到达时叠一圈与 ledPing 同节奏(1.6s ease-out 放大淡出)的光环;
+ 事件动效,一次即消散,与 gnode-pulse 的静态呼吸区分。 */
+.node-ping { transform-box: fill-box; transform-origin: center; animation: nodePing 1.6s ease-out forwards; }
+@keyframes nodePing {
+ 0% { transform: scale(.4); opacity: .8; }
+ 80%, 100% { transform: scale(3.2); opacity: 0; }
+}
+.lin-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid #1a2540; }
+.lin-sel, .lin-q { background: #0c1220; border: 1px solid #243152; border-radius: 4px; font-size: 11px; color: #8b9bb4; padding: 6px 8px; outline: none; font-family: inherit; }
+.lin-q { color: #d7e1f0; padding: 6px 10px; width: 220px; }
+.lin-q::placeholder { color: #3d4c66; }
+.lin-sel:focus, .lin-q:focus { border-color: rgba(34,211,238,.5); }
+.lin-scroll { overflow-x: auto; }
+/* HEL-529:宽档下由 app.js 的 fitLineageTable() 按剩余可视高度写入
+ max-height(真实数据集 17 条 > 打样假设的 13 条),此处只声明纵向滚动本身;
+ 未设置 max-height(窄档 <=1100px)时 overflow-y:auto 不产生任何视觉影响。 */
+.lin-scroll { overflow-y: auto; }
+.lin-table { min-width: 1080px; }
+.lin-table-n { display: none; }
+.lin-table-n .sub { display: block; font-size: 10px; color: #54637e; margin-top: 2px; }
+/* ---------- Lineage 页压缩(仅血缘页,其他页不受影响) ---------- */
+#mainEl[data-page="lineage"] .col { gap: 8px; }
+#mainEl[data-page="lineage"] { padding-bottom: 36px; }
+#mainEl[data-page="lineage"] .strip { padding: 7px 16px; gap: 6px 20px; }
+#mainEl[data-page="lineage"] .lin-filter { padding: 6px 12px; }
+#mainEl[data-page="lineage"] .dtable th { padding: 5px 10px; }
+#mainEl[data-page="lineage"] .dtable td { padding: 4px 10px; }
+@media (max-width: 1100px) {
+ .hdr-in { flex-wrap: wrap; height: auto; padding-top: 6px; padding-bottom: 6px; row-gap: 6px; }
+ .hdr-in > .flex1 { flex: 1 1 100%; height: 0; }
+ .mdtag { display: flex; }
+ .lin-table { display: none; }
+ .lin-table-n { display: table; width: 100%; }
+}
+.row-fail { background: rgba(248,113,113,.045); }
+.row-slow { background: rgba(251,191,36,.03); }
+.row-off { opacity: .55; }
+
+/* ---------- sparkline ---------- */
+@keyframes sparkBlink { 0%,100% { opacity: 1; } 50% { opacity: .3; } }
+.spark-end { animation: sparkBlink 1.4s ease-in-out infinite; }
+
+/* =================================================================
+ 以下为 HEL-529 第十二版生产实现补充样式(非 Kimi 打样内容):
+ 登录/改密视图、抽屉(调度任务/盘后发布/审计合并入口)、危险操作弹层。
+ 全部复用上方 token(--ink/--panel/--line/--cy/--mint/--amb/--rd 等),
+ 不引入新色值、不新增动效缓动函数。
+ ================================================================= */
+
+/* ---------- 登录 / 改密(保留原有表单结构,套用新 token) ---------- */
+.auth-wrap {
+ min-height: 100%; display: flex; align-items: center; justify-content: center;
+ padding: 24px;
+}
+.auth-panel {
+ width: 100%; max-width: 380px; padding: 28px 26px 24px;
+}
+.auth-panel h1 { font-size: 16px; font-weight: 600; color: #e8f1ff; margin: 0 0 6px; letter-spacing: .02em; }
+.auth-panel .muted { font-size: 11px; color: var(--dim); line-height: 1.6; margin: 0 0 18px; }
+.auth-panel form { display: flex; flex-direction: column; gap: 12px; }
+.auth-panel label { display: flex; flex-direction: column; gap: 6px; font-size: 11px; color: var(--mut); letter-spacing: .04em; }
+.auth-panel input {
+ background: #0c1220; border: 1px solid var(--line2); border-radius: 4px;
+ color: var(--txt); font-family: inherit; font-size: 13px; padding: 8px 10px; outline: none;
+}
+.auth-panel input:focus { border-color: rgba(34,211,238,.55); box-shadow: 0 0 0 2px rgba(34,211,238,.12); }
+.auth-panel button[type="submit"] {
+ margin-top: 4px; font-size: 12px; letter-spacing: .08em; border: 1px solid rgba(34,211,238,.5);
+ color: #0a1420; background: var(--cy); padding: 9px 12px; border-radius: 4px; cursor: pointer;
+ font-family: inherit; font-weight: 600; transition: filter .15s ease;
+}
+.auth-panel button[type="submit"]:hover { filter: brightness(1.08); }
+.auth-panel .error { color: var(--rd); font-size: 11px; margin: 2px 0 0; }
+
+/* ---------- 抽屉(调度任务 / 盘后发布 / 审计) ---------- */
+.drawer-mask {
+ position: fixed; inset: 0; background: rgba(2,4,10,.55); z-index: 60;
+ opacity: 0; pointer-events: none; transition: opacity .2s ease-out;
+}
+.drawer-mask.open { opacity: 1; pointer-events: auto; }
+.drawer {
+ position: fixed; top: 0; right: 0; bottom: 0; width: min(720px, 92vw);
+ background: linear-gradient(180deg, rgba(17,26,43,.98), rgba(9,13,23,.99));
+ border-left: 1px solid var(--line); z-index: 61; display: flex; flex-direction: column;
+ transform: translateX(100%); transition: transform .28s cubic-bezier(.16,1,.3,1);
+ box-shadow: -18px 0 40px rgba(0,0,0,.4);
+}
+.drawer-mask.open .drawer { transform: translateX(0); }
+.drawer-hd {
+ display: flex; align-items: center; gap: 10px; padding: 14px 18px;
+ border-bottom: 1px solid var(--line); flex: none;
+}
+.drawer-hd .ttl { font-size: 13px; font-weight: 600; color: #e8f1ff; letter-spacing: .04em; }
+.drawer-hd .sub { font-size: 10px; color: var(--dim); }
+.drawer-tabs { display: flex; gap: 4px; padding: 10px 18px 0; flex: none; }
+.drawer-tab {
+ font-size: 11px; letter-spacing: .06em; color: var(--mut); background: none; border: none;
+ padding: 6px 12px; border-radius: 4px 4px 0 0; cursor: pointer; font-family: inherit;
+ border-bottom: 2px solid transparent;
+}
+.drawer-tab.act { color: var(--cy); border-bottom-color: var(--cy); background: rgba(34,211,238,.06); }
+.drawer-body { flex: 1; overflow-y: auto; padding: 14px 18px 24px; }
+.drawer-close {
+ margin-left: auto; background: none; border: 1px solid var(--line2); color: var(--mut);
+ width: 26px; height: 26px; border-radius: 4px; cursor: pointer; font-size: 13px; line-height: 1;
+ font-family: inherit;
+}
+.drawer-close:hover { color: var(--cy); border-color: rgba(34,211,238,.5); }
+.drawer-section { margin-bottom: 18px; }
+.drawer-section .lab { margin-bottom: 8px; }
+.dtoolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
+.dtoolbar input {
+ background: #0c1220; border: 1px solid var(--line2); border-radius: 4px; color: var(--txt);
+ font-family: inherit; font-size: 11px; padding: 5px 8px; outline: none;
+}
+
+/* ---------- 危险操作确认弹层 ---------- */
+.modal-mask {
+ position: fixed; inset: 0; background: rgba(2,4,10,.6); z-index: 70;
+ display: none; align-items: center; justify-content: center; padding: 20px;
+}
+.modal-mask.open { display: flex; }
+.modal-box {
+ width: 100%; max-width: 380px; background: var(--panel2); border: 1px solid rgba(248,113,113,.4);
+ border-radius: 6px; padding: 18px 20px; box-shadow: 0 0 28px rgba(248,113,113,.12);
+}
+.modal-box h3 { margin: 0 0 4px; font-size: 13px; color: var(--rd); letter-spacing: .04em; }
+.modal-box p { margin: 0 0 12px; font-size: 11px; color: var(--mut); line-height: 1.7; }
+.modal-box label { display: flex; flex-direction: column; gap: 5px; font-size: 11px; color: var(--mut); margin-bottom: 10px; }
+.modal-box input {
+ background: #0c1220; border: 1px solid var(--line2); border-radius: 4px; color: var(--txt);
+ font-family: inherit; font-size: 12px; padding: 7px 9px; outline: none;
+}
+.modal-box input:focus { border-color: rgba(248,113,113,.55); }
+.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; }
+.modal-actions .tbtn.danger { color: var(--rd); border-color: rgba(248,113,113,.5); }
+.modal-actions .tbtn.danger:hover { color: #fff; background: rgba(248,113,113,.18); box-shadow: 0 0 10px rgba(248,113,113,.2); }
+.modal-err { color: var(--rd); font-size: 11px; margin: 6px 0 0; min-height: 14px; }
+
+/* ---------- 小工具 ---------- */
+.opbtn {
+ font-size: 10px; letter-spacing: .06em; border: 1px solid var(--line2); color: var(--mut);
+ background: rgba(17,26,43,.5); padding: 3px 9px; border-radius: 3px; cursor: pointer; font-family: inherit;
+}
+.opbtn:hover { color: var(--cy); border-color: rgba(34,211,238,.5); }
+.opbtn.danger { color: var(--rd); border-color: rgba(248,113,113,.4); }
+.opbtn.danger:hover { color: #fff; background: rgba(248,113,113,.14); }
+.empty-hint { text-align: center; padding: 22px 0; color: var(--dim); font-size: 11px; }
+.toast {
+ position: fixed; left: 50%; bottom: 44px; transform: translate(-50%, 8px); z-index: 80;
+ background: var(--panel2); border: 1px solid var(--line2); border-radius: 5px; padding: 9px 16px;
+ font-size: 12px; color: var(--txt); opacity: 0; pointer-events: none; transition: opacity .18s ease-out, transform .18s ease-out;
+ box-shadow: 0 8px 24px rgba(0,0,0,.35);
+}
+.toast.show { opacity: 1; transform: translate(-50%, 0); }
+.toast.err { border-color: rgba(248,113,113,.5); color: #fbd2d2; }
+.toast.ok { border-color: rgba(52,211,153,.5); color: #cdf5e4; }
+
+/* 减少动态:抽屉/弹层的滑入过渡也一并归零,保持与其余过渡同一门禁 */
+.reduce-motion .drawer,
+.reduce-motion .drawer-mask,
+.reduce-motion .toast { transition-duration: .001s !important; }
+@media (prefers-reduced-motion: reduce) {
+ .drawer, .drawer-mask, .toast { transition-duration: .001s !important; }
}
-* { 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; }
diff --git a/xiaobai-datahub/datahub/admin_api.py b/xiaobai-datahub/datahub/admin_api.py
index c5ab3f2..d93e78d 100644
--- a/xiaobai-datahub/datahub/admin_api.py
+++ b/xiaobai-datahub/datahub/admin_api.py
@@ -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
@@ -28,10 +31,18 @@ class AdminAPI:
)
is_open = bool(cal and int(cal["is_open"]) == 1)
pubs = self.db.fetchall("SELECT * FROM publications WHERE trade_date = ?", (today,))
- failed = self.db.fetchall(
- "SELECT * FROM batches WHERE trade_date = ? AND state IN ('failed','staged')",
- (today,),
- )
+ # HEL-529 fix 2: "待处理"只保留当前仍未恢复的最新异常。一条失败/停滞
+ # 批次若已被同数据集更晚的成功批次或当日发布解决,就不再是当前故障,
+ # 历史记录仍完整保留在 batches/audit 明细里,不在此重复展示。
+ latest_by_dataset: dict[str, dict[str, Any]] = {}
+ for row in self.db.fetchall("SELECT * FROM batches WHERE trade_date = ? ORDER BY started_at, batch_id", (today,)):
+ latest_by_dataset[row["dataset"]] = row
+ published_datasets = {row["dataset"] for row in pubs if row["state"] == "published"}
+ anomalies = [
+ row
+ for dataset, row in latest_by_dataset.items()
+ if dataset not in published_datasets and row["state"] in ("failed", "staged")
+ ]
calls = self.db.fetchall(
"SELECT * FROM src_calls ORDER BY id DESC LIMIT 20",
)
@@ -42,7 +53,7 @@ class AdminAPI:
"eod_status": self.scheduler.eod_status(today),
"revision_status": self.scheduler.revision_status(today),
"publications": pubs,
- "anomalies": failed,
+ "anomalies": anomalies,
"recent_calls": _public_calls(calls),
"source_count": len(self.db.fetchall("SELECT provider FROM src_health")),
}
@@ -105,6 +116,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"
diff --git a/xiaobai-datahub/datahub/db.py b/xiaobai-datahub/datahub/db.py
index e7bdb28..c23d3f2 100644
--- a/xiaobai-datahub/datahub/db.py
+++ b/xiaobai-datahub/datahub/db.py
@@ -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"),
diff --git a/xiaobai-datahub/datahub/httpapp.py b/xiaobai-datahub/datahub/httpapp.py
index 8540b22..7685dd1 100644
--- a/xiaobai-datahub/datahub/httpapp.py
+++ b/xiaobai-datahub/datahub/httpapp.py
@@ -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)
diff --git a/xiaobai-datahub/datahub/hub.py b/xiaobai-datahub/datahub/hub.py
index ba0d924..1f58370 100644
--- a/xiaobai-datahub/datahub/hub.py
+++ b/xiaobai-datahub/datahub/hub.py
@@ -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")
diff --git a/xiaobai-datahub/datahub/lineage.py b/xiaobai-datahub/datahub/lineage.py
new file mode 100644
index 0000000..669eb78
--- /dev/null
+++ b/xiaobai-datahub/datahub/lineage.py
@@ -0,0 +1,275 @@
+"""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",
+ "update_freq": "每日 08:45 预检",
+ "v1_endpoint": "/v1/calendar",
+ "primary_source": "tushare:trade_cal",
+ "backup_source": None,
+ "known_consumers": ["智能选股(data_sync.py 交易日历解析)", "问天(交易日推算)"],
+ },
+ {
+ "dataset": "stocks",
+ "tier": "official",
+ "update_freq": "每日 20:00 / 23:10",
+ "v1_endpoint": "/v1/stocks",
+ "primary_source": "tushare:stock_basic",
+ "backup_source": None,
+ "known_consumers": ["智能选股(股票主档)", "问天(market_context.py 股票代码/名称解析)"],
+ },
+ {
+ "dataset": "daily",
+ "tier": "official",
+ "update_freq": "盘后 15:05 · 重试至 23:30",
+ "v1_endpoint": "/v1/bars/daily",
+ "primary_source": "tushare:daily",
+ "backup_source": None,
+ "known_consumers": ["智能选股(data_sync.py 日K因子)", "交易复盘/个股详情日K图表"],
+ },
+ {
+ "dataset": "valuation",
+ "tier": "official",
+ "update_freq": "盘后 15:05 · 复核 20:00",
+ "v1_endpoint": "/v1/valuation",
+ "primary_source": "tushare:daily_basic",
+ "backup_source": None,
+ "known_consumers": ["智能选股(data_sync.py 估值因子)"],
+ },
+ {
+ "dataset": "moneyflow",
+ "tier": "official",
+ "update_freq": "盘后 15:05 · 重试至 23:30",
+ "v1_endpoint": "/v1/moneyflow",
+ "primary_source": "tushare:moneyflow",
+ "backup_source": None,
+ "known_consumers": ["智能选股(data_sync.py 资金流因子)", "个股详情资金流"],
+ },
+ {
+ "dataset": "auction",
+ "tier": "official",
+ "update_freq": "盘后 15:05",
+ "v1_endpoint": "/v1/auction",
+ "primary_source": "tushare:stk_auction",
+ "backup_source": None,
+ "known_consumers": ["智能选股(data_sync.py 竞价快照)", "竞价板块"],
+ },
+ {
+ "dataset": "index_daily",
+ "tier": "official",
+ "update_freq": "盘后 15:10 · 重试至 23:30",
+ "v1_endpoint": "/v1/indexes/bars",
+ "primary_source": "tushare:index_daily",
+ "backup_source": None,
+ "known_consumers": ["问天(market_context.py 指数近20日走势)", "智能选股(基准回看)"],
+ },
+ {
+ "dataset": "limit_events",
+ "tier": "official",
+ "update_freq": "盘后 16:40 · 重试至 23:30",
+ "v1_endpoint": "/v1/limit-events",
+ "primary_source": "tushare:limit_list_d",
+ "backup_source": None,
+ "known_consumers": ["涨停梯队(历史/盘后视图)"],
+ },
+ {
+ "dataset": "popularity",
+ "tier": "official",
+ "update_freq": "盘后 22:40",
+ "v1_endpoint": "/v1/popularity",
+ "primary_source": "tushare:ths_hot+dc_hot",
+ "backup_source": None,
+ "known_consumers": ["人气榜", "智能选股(data_sync.py 人气因子)"],
+ },
+ {
+ "dataset": "dragon_tiger",
+ "tier": "official",
+ "update_freq": "盘后 16:45 · 重试至 23:30",
+ "v1_endpoint": "/v1/dragon-tiger",
+ "primary_source": "tushare:hm_detail",
+ "backup_source": None,
+ "known_consumers": ["龙虎榜"],
+ },
+ {
+ "dataset": "sector_daily",
+ "tier": "official",
+ "update_freq": "盘后 15:20 · 重试至 23:30",
+ "v1_endpoint": "/v1/sectors",
+ "primary_source": "tushare:ths_daily+dc_index+sw_daily",
+ "backup_source": None,
+ "known_consumers": ["主题轮动", "板块梯队"],
+ },
+ {
+ "dataset": "quotes_latest",
+ "tier": "provisional",
+ "update_freq": "盘中 · 缓存 60s",
+ "v1_endpoint": "/v1/quotes/latest",
+ "primary_source": "eastmoney:ulist/clist",
+ "backup_source": "tencent:qt",
+ "known_consumers": ["竞价/股票池盘中价格", "情绪周期盘中快照"],
+ },
+ {
+ "dataset": "index_quotes",
+ "tier": "provisional",
+ "update_freq": "盘中 · 缓存 60s",
+ "v1_endpoint": "/v1/indexes/quotes",
+ "primary_source": "eastmoney:ulist",
+ "backup_source": "tencent:qt",
+ "known_consumers": ["首页大盘指数条"],
+ },
+ {
+ "dataset": "sectors_quote",
+ "tier": "provisional",
+ "update_freq": "盘中 · 缓存 60s",
+ "v1_endpoint": "/v1/sectors/quote",
+ "primary_source": "eastmoney:sw",
+ "backup_source": None,
+ "known_consumers": ["主题轮动盘中板块报价"],
+ },
+ {
+ "dataset": "limit_pool",
+ "tier": "provisional",
+ "update_freq": "盘中 · 缓存 60s",
+ "v1_endpoint": "/v1/limit-pool",
+ "primary_source": "eastmoney:zt_pool",
+ "backup_source": None,
+ "known_consumers": ["涨停梯队盘中视图"],
+ },
+ {
+ "dataset": "intraday_points",
+ "tier": "provisional",
+ "update_freq": "盘中 · 缓存 20s",
+ "v1_endpoint": "/v1/intraday/points",
+ "primary_source": "eastmoney:trends2",
+ "backup_source": None,
+ "known_consumers": ["个股详情分时图"],
+ },
+ {
+ "dataset": "ifind_wencai",
+ "tier": "licensed",
+ "update_freq": "按需调用",
+ "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
diff --git a/xiaobai-datahub/datahub/observability.py b/xiaobai-datahub/datahub/observability.py
new file mode 100644
index 0000000..72bfd9a
--- /dev/null
+++ b/xiaobai-datahub/datahub/observability.py
@@ -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 = (
+ " 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
diff --git a/xiaobai-datahub/datahub/pipeline.py b/xiaobai-datahub/datahub/pipeline.py
index 808886c..7e8fa70 100644
--- a/xiaobai-datahub/datahub/pipeline.py
+++ b/xiaobai-datahub/datahub/pipeline.py
@@ -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,
@@ -98,6 +99,58 @@ STAGING_INSERT = {
**EXTENDED_STAGING_INSERT,
}
+# Staging-table business keys (PRIMARY KEY minus the constant batch_id),
+# mirroring the PRIMARY KEY clauses declared in datahub/db.py and
+# datahub/datasets_ext.py. Used only to collapse within-batch duplicates so
+# a single upstream response cannot fail the whole batch on a UNIQUE
+# constraint (HEL-529: 2026-09-14 dc_hot returned 4 duplicate ts_codes and
+# hm_detail 43 duplicate (ts_code, hm_name) keys in one response, which has
+# blocked popularity/dragon_tiger publishing every day since 09-07).
+STAGING_KEY_FIELDS = {
+ "stocks": ("ts_code", "trade_date"),
+ "daily": ("ts_code", "trade_date"),
+ "valuation": ("ts_code", "trade_date"),
+ "moneyflow": ("ts_code", "trade_date"),
+ "auction": ("ts_code", "trade_date"),
+ "index_daily": ("ts_code", "trade_date"),
+ "limit_events": ("ts_code", "trade_date", "limit_type"),
+ "popularity": ("ts_code", "trade_date", "source"),
+ "dragon_tiger": ("ts_code", "trade_date", "hm_name"),
+ "sector_daily": ("ts_code", "trade_date", "family"),
+}
+
+
+def _dedupe_staging_rows(dataset: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Collapse within-batch duplicates on the staging table's business key.
+
+ Deterministic: keeps the LAST occurrence of each key (the same row the
+ eod copy's INSERT OR REPLACE would keep), preserves first-seen order, and
+ never touches rows across batches. Datasets without a declared business
+ key are returned unchanged.
+ """
+ fields = STAGING_KEY_FIELDS.get(dataset)
+ if not fields:
+ return rows
+ seen: dict[tuple, int] = {}
+ out: list[dict[str, Any]] = []
+ dropped = 0
+ for row in rows:
+ key = tuple(row.get(field) for field in fields)
+ if key in seen:
+ out[seen[key]] = row
+ dropped += 1
+ else:
+ seen[key] = len(out)
+ out.append(row)
+ if dropped:
+ LOGGER.info(
+ "staging dedupe: %s collapsed %d duplicate rows within batch (kept last)",
+ dataset,
+ dropped,
+ extra={"hub": {"dataset": dataset, "deduped": dropped}},
+ )
+ return out
+
EOD_COPY = {
"stocks": (
"INSERT OR REPLACE INTO eod_stocks "
@@ -1634,6 +1687,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:
@@ -1718,6 +1772,7 @@ class Pipeline:
def _stage(self, dataset: str, batch_id: str, rows: list[dict[str, Any]]) -> None:
sql, mapper = STAGING_INSERT[dataset]
+ rows = _dedupe_staging_rows(dataset, rows)
with self.db.write() as connection:
connection.execute(
f"DELETE FROM {DATASET_TABLES[dataset][1]} WHERE batch_id = ?",
@@ -1768,6 +1823,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()
diff --git a/xiaobai-datahub/datahub/realtime_serve.py b/xiaobai-datahub/datahub/realtime_serve.py
index bcd49b1..5d1dd35 100644
--- a/xiaobai-datahub/datahub/realtime_serve.py
+++ b/xiaobai-datahub/datahub/realtime_serve.py
@@ -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)
diff --git a/xiaobai-datahub/datahub/settings.py b/xiaobai-datahub/datahub/settings.py
index 3aa86b0..ff96ddc 100644
--- a/xiaobai-datahub/datahub/settings.py
+++ b/xiaobai-datahub/datahub/settings.py
@@ -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"},
)
diff --git a/xiaobai-datahub/datahub/source_catalog.py b/xiaobai-datahub/datahub/source_catalog.py
new file mode 100644
index 0000000..49a44ca
--- /dev/null
+++ b/xiaobai-datahub/datahub/source_catalog.py
@@ -0,0 +1,196 @@
+"""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"], "group": "日历 / 主档"},
+ {"interface": "stock_basic", "capability": "股票主档", "datasets": ["stocks"], "group": "日历 / 主档"},
+ {"interface": "daily", "capability": "个股日K", "datasets": ["daily"], "group": "盘后 A 批"},
+ {"interface": "adj_factor", "capability": "复权因子", "datasets": ["daily"], "group": "盘后 A 批"},
+ {"interface": "daily_basic", "capability": "估值", "datasets": ["valuation"], "group": "盘后 A 批"},
+ {"interface": "index_daily", "capability": "指数日K", "datasets": ["index_daily"], "group": "指数 B 批"},
+ {"interface": "moneyflow", "capability": "资金流", "datasets": ["moneyflow"], "group": "盘后 A 批"},
+ {"interface": "stk_auction", "capability": "集合竞价", "datasets": ["auction"], "group": "盘后 A 批"},
+ {"interface": "limit_list_d", "capability": "涨跌停池", "datasets": ["limit_events"], "group": "扩展软批"},
+ {"interface": "ths_hot", "capability": "同花顺人气榜", "datasets": ["popularity"], "group": "扩展软批"},
+ {"interface": "dc_hot", "capability": "东方财富人气榜", "datasets": ["popularity"], "group": "扩展软批"},
+ {"interface": "hm_detail", "capability": "龙虎榜游资明细", "datasets": ["dragon_tiger"], "group": "扩展软批"},
+ {"interface": "ths_daily", "capability": "同花顺概念行情", "datasets": ["sector_daily"], "group": "扩展软批"},
+ {"interface": "dc_index", "capability": "东方财富板块行情", "datasets": ["sector_daily"], "group": "扩展软批"},
+ {"interface": "sw_daily", "capability": "申万行业行情", "datasets": ["sector_daily"], "group": "扩展软批"},
+ ],
+ },
+ {
+ "provider": "eastmoney",
+ "label": "东方财富",
+ "role": "provisional_primary",
+ "credential_key": None,
+ "status_source": "provider_health (unified, HEL-543)",
+ "interfaces": [
+ {"interface": "indices", "capability": "指数实时报价", "datasets": ["index_quotes"], "group": "实时快照"},
+ {"interface": "market_quotes", "capability": "全市场实时快照", "datasets": ["quotes_latest"], "group": "实时快照"},
+ {"interface": "named_quotes", "capability": "指定个股实时报价", "datasets": ["quotes_latest"], "group": "实时快照"},
+ {"interface": "sector_quote", "capability": "申万板块实时报价(单个)", "datasets": ["sectors_quote"], "group": "实时快照"},
+ {"interface": "sector_quotes_batch", "capability": "申万板块批量报价(预热)", "datasets": ["sectors_quote"], "group": "实时快照"},
+ {"interface": "limit_pool", "capability": "涨停/炸板池(盘中)", "datasets": ["limit_pool"], "group": "实时快照"},
+ {"interface": "intraday", "capability": "分时走势", "datasets": ["intraday_points"], "group": "实时快照"},
+ ],
+ },
+ {
+ "provider": "tencent",
+ "label": "腾讯行情",
+ "role": "provisional_backup",
+ "credential_key": None,
+ "status_source": "provider_health (unified, HEL-543)",
+ "interfaces": [
+ {"interface": "indices", "capability": "指数实时报价(东财失败时备用)", "datasets": ["index_quotes"], "group": "实时备援"},
+ {
+ "interface": "market_quotes_fallback",
+ "capability": "全市场快照(备用;按本地股票主档逐只请求拼接)",
+ "datasets": ["quotes_latest"],
+ "group": "实时备援",
+ },
+ {"interface": "named_quotes", "capability": "指定个股实时报价(东财失败时备用)", "datasets": ["quotes_latest"], "group": "实时备援"},
+ ],
+ },
+ {
+ "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"], "group": "预留接口"},
+ {"interface": "snapshots", "capability": "快照", "datasets": ["ifind_snapshots"], "group": "预留接口"},
+ {"interface": "history", "capability": "历史行情", "datasets": ["ifind_history"], "group": "预留接口"},
+ {"interface": "realtime", "capability": "实时行情", "datasets": ["ifind_realtime"], "group": "预留接口"},
+ {"interface": "intraday", "capability": "分时(高频)", "datasets": ["ifind_intraday"], "group": "预留接口"},
+ ],
+ },
+ {
+ "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
+ # HEL-529 fix 3: 已登记 ≠ 已观测 ≠ 健康。观测记录有两种真实写法:
+ # eastmoney/tencent 的 observe() 直接写接口名;tushare 官方管线
+ # _log_call() 写的是数据集名(如 daily_basic 接口对应的数据集
+ # valuation)。这里按「接口名 或 该接口声明的 datasets 之一」双向
+ # 匹配,并为每个接口标注观测依据,禁止把"暂无观测"显示成"未配置"。
+ by_interface = {str(row["interface"]): row for row in health_rows}
+ for iface in item["interfaces"]:
+ row = by_interface.get(str(iface["interface"]))
+ basis = "interface" if row is not None else ""
+ if row is None:
+ for dataset in iface.get("datasets", []):
+ candidate = by_interface.get(str(dataset))
+ if candidate is not None:
+ row = candidate
+ basis = "dataset"
+ break
+ if row is None:
+ iface["observed"] = False
+ iface["observed_basis"] = ""
+ iface["observed_state"] = ""
+ iface["observed_at"] = ""
+ iface["observed_latency_ms"] = None
+ iface["observed_note"] = ""
+ else:
+ iface["observed"] = True
+ iface["observed_basis"] = basis
+ iface["observed_state"] = str(row["state"] or "")
+ iface["observed_at"] = str(row["last_ok_at"] or row["updated_at"] or "")
+ iface["observed_latency_ms"] = row["last_latency_ms"]
+ iface["observed_note"] = str(row["last_error"] or row["last_fallback_reason"] or "")
+ result.append(item)
+ return result
diff --git a/xiaobai-datahub/datahub/steward.py b/xiaobai-datahub/datahub/steward.py
index 4a03e8b..d94317f 100644
--- a/xiaobai-datahub/datahub/steward.py
+++ b/xiaobai-datahub/datahub/steward.py
@@ -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(
diff --git a/xiaobai-datahub/tests/test_admin_observability_api.py b/xiaobai-datahub/tests/test_admin_observability_api.py
new file mode 100644
index 0000000..a9723d1
--- /dev/null
+++ b/xiaobai-datahub/tests/test_admin_observability_api.py
@@ -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()
diff --git a/xiaobai-datahub/tests/test_hel529_rework.py b/xiaobai-datahub/tests/test_hel529_rework.py
new file mode 100644
index 0000000..4109328
--- /dev/null
+++ b/xiaobai-datahub/tests/test_hel529_rework.py
@@ -0,0 +1,213 @@
+"""HEL-529 rework regressions: staging dedupe, anomaly convergence,
+source-catalog observation join (dataset-name ↔ interface-name), lineage
+update_freq. All read-only or within-batch fixes; none touch routing, the
+8765 main site, or the 问天 frozen zone.
+"""
+
+from __future__ import annotations
+
+import tempfile
+import unittest
+from pathlib import Path
+
+from datahub.adapters.tushare import TushareAdapter
+from datahub.crypto import SecretVault
+from datahub.hub import Hub
+from datahub.pipeline import _dedupe_staging_rows
+from datahub.settings import Settings
+from tests.fixtures import fake_transport
+
+
+class StagingDedupeTests(unittest.TestCase):
+ def test_popularity_within_batch_duplicates_collapse_keep_last(self) -> None:
+ rows = [
+ {"ts_code": "600000.SH", "trade_date": "20260914", "source": "ths", "rank": 1},
+ {"ts_code": "000868.SZ", "trade_date": "20260914", "source": "dc", "rank": 2},
+ {"ts_code": "600000.SH", "trade_date": "20260914", "source": "dc", "rank": 3, "hot": 9.9},
+ {"ts_code": "600000.SH", "trade_date": "20260914", "source": "dc", "rank": 4, "hot": 8.8},
+ ]
+ out = _dedupe_staging_rows("popularity", rows)
+ self.assertEqual(len(out), 3) # (600000,ths) (000868,dc) (600000,dc)
+ dup = [r for r in out if r["ts_code"] == "600000.SH" and r["source"] == "dc"][0]
+ self.assertEqual(dup["rank"], 4) # keeps LAST occurrence
+ self.assertEqual(out[0]["ts_code"], "600000.SH") # preserves first-seen order
+
+ def test_dragon_tiger_seat_duplicates_collapse(self) -> None:
+ rows = [
+ {"ts_code": "300010.SZ", "trade_date": "20260914", "hm_name": "T王", "buy_amount": 100},
+ {"ts_code": "300010.SZ", "trade_date": "20260914", "hm_name": "T王", "buy_amount": 200},
+ {"ts_code": "300010.SZ", "trade_date": "20260914", "hm_name": "T王", "buy_amount": 300},
+ ]
+ out = _dedupe_staging_rows("dragon_tiger", rows)
+ self.assertEqual(len(out), 1)
+ self.assertEqual(out[0]["buy_amount"], 300)
+
+ def test_different_sources_are_not_duplicates(self) -> None:
+ rows = [
+ {"ts_code": "600000.SH", "trade_date": "20260914", "source": "ths"},
+ {"ts_code": "600000.SH", "trade_date": "20260914", "source": "dc"},
+ ]
+ self.assertEqual(len(_dedupe_staging_rows("popularity", rows)), 2)
+
+ def test_unknown_dataset_passthrough(self) -> None:
+ rows = [{"a": 1}, {"a": 1}]
+ self.assertEqual(_dedupe_staging_rows("calendar", rows), rows)
+
+
+class _Base(unittest.TestCase):
+ 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))
+
+ def tearDown(self) -> None:
+ self.tmp.cleanup()
+
+
+class StagingDedupePublishTests(_Base):
+ def test_duplicate_popularity_and_dragon_tiger_now_publish(self) -> None:
+ """Replays the 2026-09-14 production failure: within-response duplicate
+ keys used to abort the whole batch at the staging INSERT; with dedupe
+ the same upstream payload publishes."""
+ db = self.hub.db
+ trade_date = "20240902"
+ # dc_hot returns 600000.SH twice within one response; hm_detail returns
+ # the same (ts_code, hm_name) seat three times (mirrors live evidence).
+ popularity_rows = [
+ {"ts_code": "600000.SH", "trade_date": trade_date, "source": "ths",
+ "ts_name": "浦发银行", "rank": 1, "pct_change": 1.2, "current_price": 10.2,
+ "hot": 90.0, "concept": "银行", "data_type": "热股"},
+ {"ts_code": "600000.SH", "trade_date": trade_date, "source": "dc",
+ "ts_name": "浦发银行", "rank": 2, "pct_change": 1.2, "current_price": 10.2,
+ "hot": 80.0, "concept": "银行", "data_type": "A股市场"},
+ {"ts_code": "600000.SH", "trade_date": trade_date, "source": "dc",
+ "ts_name": "浦发银行", "rank": 3, "pct_change": 1.3, "current_price": 10.3,
+ "hot": 81.0, "concept": "银行", "data_type": "A股市场"},
+ ]
+ dragon_rows = [
+ {"trade_date": trade_date, "ts_code": "600000.SH", "ts_name": "浦发银行",
+ "buy_amount": 100, "sell_amount": 200, "net_amount": -100,
+ "hm_name": "测试游资", "hm_orgs": "某某营业部", "tag": "超买"},
+ {"trade_date": trade_date, "ts_code": "600000.SH", "ts_name": "浦发银行",
+ "buy_amount": 300, "sell_amount": 0, "net_amount": 300,
+ "hm_name": "测试游资", "hm_orgs": "某某营业部", "tag": "超买"},
+ ]
+ self.hub.pipeline._stage("popularity", "b-dup-pop", popularity_rows)
+ self.hub.pipeline._stage("dragon_tiger", "b-dup-dt", dragon_rows)
+ pop = db.fetchall("SELECT * FROM staging_popularity WHERE batch_id = 'b-dup-pop'")
+ dt = db.fetchall("SELECT * FROM staging_dragon_tiger WHERE batch_id = 'b-dup-dt'")
+ self.assertEqual(len(pop), 2)
+ self.assertEqual(len(dt), 1)
+ kept = dt[0]
+ self.assertEqual(kept["buy_amount"], 300)
+
+
+class OverviewAnomalyConvergenceTests(_Base):
+ def _seed_batches(self, today: str) -> None:
+ db = self.hub.db
+ rows = [
+ # resolved history: earlier failures/stalls, later success
+ ("x-daily-001", "daily", "staged", "empty official batch", "2026-09-14T15:05:00+08:00"),
+ ("x-daily-002", "daily", "failed", "release group not switched", "2026-09-14T16:10:00+08:00"),
+ ("x-daily-006", "daily", "published", "", "2026-09-14T20:00:00+08:00"),
+ # current unresolved faults
+ ("x-pop-001", "popularity", "failed", "UNIQUE constraint failed: staging_popularity", "2026-09-14T22:40:00+08:00"),
+ ("x-dt-001", "dragon_tiger", "failed", "UNIQUE constraint failed: staging_dragon_tiger", "2026-09-14T16:45:00+08:00"),
+ ("x-dt-002", "dragon_tiger", "failed", "UNIQUE constraint failed: staging_dragon_tiger", "2026-09-14T21:48:00+08:00"),
+ # staged-empty later published
+ ("x-idx-001", "index_daily", "staged", "empty official batch", "2026-09-14T15:10:00+08:00"),
+ ("x-idx-003", "index_daily", "published", "", "2026-09-14T16:10:00+08:00"),
+ ]
+ for batch_id, dataset, state, error, started in rows:
+ db.execute(
+ "INSERT INTO batches(batch_id, dataset, trade_date, state, attempt, rows_in, rows_out,"
+ " quality_json, started_at, finished_at, error) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
+ (batch_id, dataset, today, state, 1, None, None, None, started, started if state != "staged" else None, error or None),
+ )
+ db.execute(
+ "INSERT INTO publications(dataset, trade_date, active_batch, prev_batch, state, published_at)"
+ " VALUES ('daily', ?, 'x-daily-006', 'x-daily-005', 'published', '2026-09-14T20:00:22+08:00')",
+ (today,),
+ )
+ db.execute(
+ "INSERT INTO publications(dataset, trade_date, active_batch, prev_batch, state, published_at)"
+ " VALUES ('index_daily', ?, 'x-idx-003', 'x-idx-002', 'published', '2026-09-14T16:10:12+08:00')",
+ (today,),
+ )
+
+ def test_anomalies_only_latest_unresolved(self) -> None:
+ overview = self.hub.admin.overview()
+ today = overview["trade_date"]
+ self._seed_batches(today)
+ anomalies = self.hub.admin.overview()["anomalies"]
+ got = sorted((a["dataset"], a["batch_id"]) for a in anomalies)
+ self.assertEqual(
+ got,
+ [
+ ("dragon_tiger", "x-dt-002"), # latest failed, never published
+ ("popularity", "x-pop-001"), # latest failed, never published
+ ],
+ )
+
+
+class SourceCatalogJoinTests(_Base):
+ def test_observation_join_matches_dataset_named_health_rows(self) -> None:
+ db = self.hub.db
+ now = "2026-09-15T08:00:00+08:00"
+ # tushare observability writes dataset names (real legacy behavior)
+ for iface, state in [("valuation", "ok"), ("popularity", "ok"), ("stocks", "ok")]:
+ db.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 ('tushare', ?, ?, ?, '', '', 0, 300, NULL, ?)",
+ (iface, state, now, now),
+ )
+ # realtime providers write interface names
+ db.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 ('eastmoney', 'indices', 'ok', ?, '', '', 0, 153, NULL, ?)",
+ (now, now),
+ )
+ items = self.hub.admin.source_catalog()["items"]
+ tushare = [i for i in items if i["provider"] == "tushare"][0]
+ by_iface = {i["interface"]: i for i in tushare["interfaces"]}
+ # daily_basic serves valuation → observed via dataset name
+ self.assertTrue(by_iface["daily_basic"]["observed"])
+ self.assertEqual(by_iface["daily_basic"]["observed_basis"], "dataset")
+ self.assertEqual(by_iface["daily_basic"]["observed_state"], "ok")
+ # ths_hot + dc_hot serve popularity → observed via dataset name
+ self.assertTrue(by_iface["ths_hot"]["observed"])
+ self.assertTrue(by_iface["dc_hot"]["observed"])
+ # stock_basic serves stocks
+ self.assertTrue(by_iface["stock_basic"]["observed"])
+ # never-observed interface stays honestly unobserved (not "unconfigured")
+ self.assertFalse(by_iface["trade_cal"]["observed"])
+ self.assertEqual(by_iface["trade_cal"]["observed_state"], "")
+ # interfaces carry real batch groups
+ groups = {i["interface"]: i["group"] for i in tushare["interfaces"]}
+ self.assertEqual(groups["daily"], "盘后 A 批")
+ self.assertEqual(groups["ths_hot"], "扩展软批")
+ self.assertEqual(groups["index_daily"], "指数 B 批")
+ eastmoney = [i for i in items if i["provider"] == "eastmoney"][0]
+ em = {i["interface"]: i for i in eastmoney["interfaces"]}
+ self.assertTrue(em["indices"]["observed"])
+ self.assertEqual(em["indices"]["observed_basis"], "interface")
+ self.assertFalse(em["market_quotes"]["observed"])
+
+ def test_lineage_update_freq_present(self) -> None:
+ items = self.hub.admin.lineage("20240902")["items"]
+ self.assertTrue(items)
+ for item in items:
+ self.assertTrue(item.get("update_freq"), f"missing update_freq for {item['dataset']}")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/xiaobai-datahub/tests/test_ifind_observability.py b/xiaobai-datahub/tests/test_ifind_observability.py
new file mode 100644
index 0000000..fa04288
--- /dev/null
+++ b/xiaobai-datahub/tests/test_ifind_observability.py
@@ -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()
diff --git a/xiaobai-datahub/tests/test_observability.py b/xiaobai-datahub/tests/test_observability.py
new file mode 100644
index 0000000..37accd8
--- /dev/null
+++ b/xiaobai-datahub/tests/test_observability.py
@@ -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()
diff --git a/xiaobai-datahub/tests/test_pipeline_observability.py b/xiaobai-datahub/tests/test_pipeline_observability.py
new file mode 100644
index 0000000..5c797ae
--- /dev/null
+++ b/xiaobai-datahub/tests/test_pipeline_observability.py
@@ -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()
diff --git a/xiaobai-datahub/tests/test_realtime_observability.py b/xiaobai-datahub/tests/test_realtime_observability.py
new file mode 100644
index 0000000..572bc3a
--- /dev/null
+++ b/xiaobai-datahub/tests/test_realtime_observability.py
@@ -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()