Files
xiaobai-review/xiaobai-datahub/admin/app.js
T
31da37b49e feat(HEL-529): 按第十二版实现三页数据中枢管理后台
以 hub-kimi.html + SPEC-V11.md(v12) 为唯一视觉基线,将 /admin/ 重写为
运行总览 / 数据源配置 / 数据血缘 三页单一入口;接入 HEL-543 只读旁路
与既有 admin API,保留调度/发布/审计与危险操作入口,零新依赖。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-14 21:11:45 +08:00

1098 lines
64 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* =====================================================================
小白复盘 · 数据中枢 admin/app.js — HEL-529 第十二版
三页真实数据中枢:运行总览 / 数据源配置 / 数据血缘。
视觉系统逐行照抄已通过打样 hub-kimi.html + SPEC-V11.mdv12 血缘压缩增补),
但本文件不含任何随机/模拟数据——全部状态、数字、事件均来自真实接口:
既有 /admin/api/{overview,sources,jobs,batches,datasets,audit,...}
与 HEL-543 新增只读旁路 /admin/api/{providers/status,source-catalog,
lineage,lineage/affected}。
===================================================================== */
/* ================= 基础:DOM helper / API / CSRF ================= */
function $(id) { return document.getElementById(id); }
function esc(v) { return String(v ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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().catch(() => ({}));
if (!res.ok) {
const msg = (body.error && body.error.message) || body.error || res.statusText;
throw new Error(msg);
}
return body;
}
/* ================= 认证:登录 / 改密 / 登出 ================= */
function showView(id) {
['login-view', 'change-view'].forEach((k) => { $(k).hidden = k !== id; });
$('appRoot').hidden = id !== 'shell';
}
async function boot() {
try {
const session = await api('/admin/api/session');
state.csrf = session.csrf;
state.username = session.username;
$('who').textContent = session.username;
if (session.must_change) { showView('change-view'); return; }
showView('shell');
await startApp();
} catch {
showView('login-view');
}
}
$('login-form').addEventListener('submit', async (event) => {
event.preventDefault();
const form = new FormData(event.target);
$('login-error').hidden = true;
try {
const result = await api('/admin/api/login', {
method: 'POST',
body: JSON.stringify({ username: form.get('username'), password: form.get('password') }),
});
state.csrf = result.csrf;
if (result.must_change) { showView('change-view'); return; }
const session = await api('/admin/api/session');
state.username = session.username;
$('who').textContent = session.username;
showView('shell');
await startApp();
} catch (err) {
$('login-error').hidden = false;
$('login-error').textContent = err.message;
}
});
$('change-form').addEventListener('submit', async (event) => {
event.preventDefault();
const form = new FormData(event.target);
try {
await api('/admin/api/change-password', {
method: 'POST',
body: JSON.stringify({ current: form.get('current'), new_password: form.get('new_password') }),
});
const session = await api('/admin/api/session');
state.username = session.username;
$('who').textContent = session.username;
showView('shell');
await startApp();
} catch (err) {
$('change-error').hidden = false;
$('change-error').textContent = err.message;
}
});
$('logout-btn').addEventListener('click', async () => {
try { await api('/admin/api/logout', { method: 'POST', body: '{}' }); } catch {}
Poller.stop();
showView('login-view');
});
/* ================= 状态归类(真实状态字符串 → 7 类视觉桶) =================
桶: ok / slow / fail / plan / review / degrade / off
来源覆盖:observability.classify_error/classify_rows、CircuitBreaker、
eod/revision progress、batches/publications、job_runs 的全部真实取值。 */
const HEALTH = {
ok: { label: '正常', led: 'ok', color: '#34d399' },
slow: { label: '慢', led: 'slow', color: '#fbbf24' },
fail: { label: '失败', led: 'fail', color: '#f87171' },
plan: { label: '计划', led: 'plan', color: '#8b9bb4' },
review: { label: '复核中', led: 'rev', color: '#22d3ee' },
degrade: { label: '降级', led: 'slow', color: '#fbbf24' },
off: { label: '未配置', led: 'off', color: '#54637e' },
};
const STATE_MAP = {
ok: 'ok', closed: 'ok', published: 'ok', done: 'ok', aligned: 'ok',
half_open: 'slow', waiting_upstream: 'slow',
open: 'fail', timeout: 'fail', blocked: 'fail', empty: 'fail', missing_fields: 'fail',
error: 'fail', failed: 'fail', cutoff_failed: 'fail', cutoff: 'fail', review_failed: 'fail',
degraded: 'degrade', stale: 'degrade',
pending_first_attempt: 'plan', pending_publish: 'plan',
running: 'review', staged: 'review', waiting_review: 'review',
unconfigured: 'off', reserved: 'off', unknown: 'off', closed_day: 'off',
};
function bucketOf(rawState) { return STATE_MAP[String(rawState || '').toLowerCase()] || 'off'; }
const SEV = { fail: 6, degrade: 5, slow: 4, review: 3, plan: 2, off: 1, ok: 0 };
function worstBucket(list) {
let best = 'ok', bestSev = -1;
for (const b of list) { const s = SEV[b] ?? 0; if (s > bestSev) { bestSev = s; best = b; } }
return list.length ? best : 'ok';
}
/* ================= 渲染小工具(结构照抄 hub-kimi.html ================= */
function ledHtml(st, pulse) { return `<span class="led ${st}${pulse ? ' pulse' : ''}"></span>`; }
function pillHtml(bucket, label) { const h = HEALTH[bucket] || HEALTH.off; return `<span class="spill" style="color:${h.color}">${ledHtml(h.led, bucket === 'fail' || bucket === 'review')}${label || h.label}</span>`; }
function tagHtml(tone, inner) { return `<span class="tag ${tone}">${inner}</span>`; }
function panelHtml(title, right, body, pad) {
return `<section class="panel"><header class="panel-hd"><span><span style="color:#22d3ee;margin-right:6px">//</span><b>${title}</b></span>${right ? `<span style="display:flex;align-items:center;gap:8px">${right}</span>` : ''}</header><div class="${pad === false ? '' : 'panel-b'}">${body}</div></section>`;
}
function kpiHtml(label, value, sub, tone, live) {
const tc = { txt: '', mint: 'glow-mint', amb: 'glow-amb', rd: 'glow-rd', cy: 'glow-cy' }[tone || 'txt'];
return `<div class="kpi"><span class="lab">${label}</span><span class="num kv ${tc}">${value}${live ? '<span class="blink" style="color:#22d3ee;margin-left:2px">_</span>' : ''}</span>${sub ? `<span class="ks">${sub}</span>` : ''}</div>`;
}
function radarBlipsHtml(pulseOk) {
return pulseOk
? `<div class="blip pulse-dot" style="left:64%;top:30%;background:#34d399"></div><div class="blip pulse-dot" style="left:30%;top:62%;background:#f87171;animation-delay:.6s"></div>`
: '';
}
function latHtml(ms, warn) {
if (ms == null) return '<span style="color:#54637e">—</span>';
const color = ms > 1000 ? '#fbbf24' : (warn ? '#fbbf24' : '#d7e1f0');
return `<span class="num" style="color:${color}">${Number(ms).toLocaleString('en-US')}ms</span>`;
}
function ageFromIso(iso) {
if (!iso) return null;
const t = Date.parse(iso.includes('T') ? iso : iso.replace(' ', 'T'));
if (Number.isNaN(t)) return null;
return Math.max(0, Math.floor((Date.now() - t) / 1000));
}
function ageHtml(iso) {
const s = ageFromIso(iso);
if (s == null) return '<span style="color:#54637e">—</span>';
const txt = s < 90 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : `${Math.floor(s / 3600)}h`;
return `<span class="num" style="color:${s > 90 ? '#fbbf24' : '#8b9bb4'}">${txt}</span>`;
}
function hm(iso) {
if (!iso) return '—';
const s = String(iso);
const idx = s.indexOf('T') >= 0 ? s.indexOf('T') : s.indexOf(' ');
if (idx < 0 || s.length < idx + 6) return s.slice(0, 5) || '—';
return s.slice(idx + 1, idx + 6);
}
const p2 = (x) => String(x).padStart(2, '0');
/* ================= 数据层:拉取真实接口并汇总为一份快照 ================= */
const RAW = {
overview: null, sources: null, providers: null, catalog: null,
lineage: null, jobs: null, batches: null, datasets: null, audit: null,
};
async function fetchAll() {
const [overview, sources, providers, catalog, lineage, jobs, batches, datasets, 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/datasets?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.datasets = datasets; RAW.audit = audit;
}
function today() { return RAW.overview ? RAW.overview.trade_date : ''; }
/* 按 provider(+interface) 索引 provider_health,供各页复用 */
function healthIndex() {
const idx = {};
const rows = (RAW.providers && RAW.providers.health) || [];
for (const row of rows) {
idx[row.provider] = idx[row.provider] || [];
idx[row.provider].push(row);
}
return idx;
}
function callsIndex() {
const idx = {};
const rows = (RAW.providers && RAW.providers.recent_calls) || [];
for (const row of rows) { (idx[row.provider] = idx[row.provider] || []).push(row); }
return idx;
}
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: '股票主档',
};
/* =====================================================================
总览页
===================================================================== */
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 hidx = healthIndex();
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 数据集
let fallbackPages = 0;
(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') fallbackPages += 1;
});
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 的真实 provider_health 行
const catalogByProv = {};
(RAW.catalog && RAW.catalog.items || []).forEach((it) => { catalogByProv[it.provider] = it; });
const observers = [];
['eastmoney', 'tencent'].forEach((prov) => {
const rows = hidx[prov] || [];
const cat = catalogByProv[prov];
rows.forEach((row) => {
const iface = (cat && cat.interfaces.find((i) => i.interface === row.interface)) || {};
observers.push({
name: iface.capability || row.interface,
provider: prov,
interface: row.interface,
lastOk: row.last_ok_at,
lat: row.last_latency_ms,
bucket: bucketOf(row.state),
note: row.last_error || row.last_fallback_reason || '',
});
});
});
// 主站影响:lineage() 条目按 known_consumers 展开
const siteImpact = [];
(RAW.lineage && RAW.lineage.items || []).forEach((it) => {
const rows = it.live_provider_health || [];
const bucket = it.tier === 'official'
? (it.publication ? (it.publication.state === 'published' ? 'ok' : (it.publication.state === 'degraded' ? 'degrade' : 'fail')) : (rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'plan'))
: (rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'off');
const note = rows.find((r) => r.last_error)?.last_error || rows.find((r) => r.last_fallback_reason)?.last_fallback_reason || '';
(it.known_consumers || []).forEach((c) => {
siteImpact.push({ page: c, dataset: it.dataset, bucket, note });
});
});
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 };
});
// 待处理异常:批次失败/停滞 + 主源连续失败 + 未配置授权源
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: `已试 ${eod.attempts || 0}${(eod.missing_datasets || []).length ? ' · 缺 ' + eod.missing_datasets.join(',') : ''}`, dataset: (eod.missing_datasets || [])[0] || '' });
}
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,
};
}
function heroHtml(vm) {
const anomalyTxt = vm.anomalyCount ? `<span class="glow-rd">${vm.anomalyCount} 项异常</span>` : `<span style="color:#34d399">无异常</span>`;
const tags = [];
if (vm.eod.state === 'waiting_upstream') tags.push(tagHtml('amb', `<span class="minidot pulse-dot" style="background:#fbbf24"></span>等待上游 · 已试 ${vm.eod.attempts || 0} 次`));
if (vm.rev.state === 'waiting_review') tags.push(tagHtml('cy', '复核 · 待发布'));
const subParts = [];
if (vm.eod.attempts) subParts.push(`已试 <span style="color:#fbbf24">${vm.eod.attempts}</span> 次`);
if (vm.eod.next_retry_at) subParts.push(`下次重试 <span style="color:#fbbf24">${hm(vm.eod.next_retry_at)}</span>`);
if ((vm.eod.missing_datasets || []).length) subParts.push(`缺 <span style="color:#f87171">${esc(vm.eod.missing_datasets.join(','))}</span>`);
if (vm.rev.window) subParts.push(`复核窗口 <span style="color:#22d3ee">${esc(vm.rev.window)}</span>`);
return `<div class="panel hero">` +
`<div class="hero-l">` +
`<div class="hero-title"><span style="color:#e8f1ff">${vm.anomalyCount ? '部分异常' : '整体可用'}</span><span class="dot">·</span>${anomalyTxt}</div>` +
`<div class="hero-tags">${tags.join('')}</div>` +
(subParts.length ? `<div class="hero-sub">${subParts.join('<span class="sep">|</span>')}</div>` : '') +
`</div><span class="flex1"></span>` +
`<div class="kpis">` +
kpiHtml('已发布数据集', `<span class="glow-mint">${vm.publishedCount}</span><span style="color:#54637e;font-size:18px">/${vm.totalOfficial}</span>`, null) +
kpiHtml('今日调用成功率', vm.successRate == null ? '<span style="color:#54637e">—</span>' : `<span>${vm.successRate.toFixed(1)}%</span>`, null, 'mint', vm.successRate != null) +
kpiHtml('主站回退页面', `${vm.fallbackPages} 页`, null, vm.fallbackPages ? 'amb' : 'txt') +
kpiHtml('EOD 尝试', `${vm.eod.attempts || 0} 次`, '上限 5 · 23:30 止', vm.eod.attempts ? 'rd' : 'txt') +
`</div></div>`;
}
function datasetStripHtml(vm) {
return `<div class="ds-grid">${vm.datasetCards.map((d) => {
const ring = d.bucket === 'fail' ? 'fail' : d.bucket === 'degrade' ? 'slow' : d.bucket === 'review' ? 'review' : '';
let note = '';
if (d.time) note += `<span class="num" style="color:#8b9bb4">${esc(d.time)} · </span>`;
if (d.rows) note += `<span class="num">${Number(d.rows).toLocaleString('en-US')} 行</span>`;
if (d.err) note += `<span style="color:#f87171">${esc(d.err)}</span>`;
if (!d.time && !d.rows && !d.err) note = '<span style="color:#54637e">今日尚未发布</span>';
return `<div class="panel dsc ${ring}" data-dataset="${d.id}">` +
`<div class="dsc-top"><span class="dsc-name">${esc(d.name)}</span>${pillHtml(d.bucket)}</div>` +
`<div class="dsc-note">${note}</div>` +
`</div>`;
}).join('')}</div>`;
}
function observatoryHtml(vm) {
const legendByProv = { eastmoney: 'EM 东财', tencent: 'TX 腾讯' };
const right = `<span class="legend">${Object.keys(legendByProv).map((p) => `<span>${ledHtml('ok')}${legendByProv[p]}</span>`).join('')}</span>`;
const rows = vm.observers.length ? vm.observers.map((o) => `<tr>` +
`<td style="color:#e8f1ff">${esc(o.name)}</td>` +
`<td style="color:#8b9bb4">${o.provider === 'eastmoney' ? '东方财富' : '腾讯行情'}</td>` +
`<td>${ageHtml(o.lastOk)}</td>` +
`<td>${latHtml(o.lat, o.bucket !== 'ok')}</td>` +
`<td><span style="display:flex;align-items:center;gap:8px">${pillHtml(o.bucket)}${o.note ? `<span class="obs-note">${esc(o.note)}</span>` : ''}</span></td>` +
`</tr>`).join('') : `<tr><td colspan="5" class="empty-hint">尚无真实观测记录(等待首次探测/调用)</td></tr>`;
const body = `<table class="dtable"><thead><tr><th>观察项</th><th>当前来源</th><th>最近成功</th><th>延迟</th><th>状态 · 备注</th></tr></thead><tbody>${rows}</tbody></table>`;
return panelHtml('实时观察层', right, body, false);
}
function siteImpactHtml(vm) {
const right = `<span style="font-size:10px"><span style="color:#34d399">${vm.okPages} 项正常</span><span style="color:#54637e"> · </span><span style="color:#fbbf24">${vm.siteImpact.length - vm.okPages} 项待关注</span></span>`;
const rows = vm.siteImpact.length ? vm.siteImpact.map((s) => `<tr>` +
`<td style="color:#e8f1ff;width:36%">${esc(s.page)}</td>` +
`<td style="width:18%">${pillHtml(s.bucket)}</td>` +
`<td style="font-size:11px;color:#54637e">${s.note ? esc(s.note) : '—'}</td>` +
`<td style="text-align:right"><button class="tbtn mini" data-goto-lineage="${esc(s.dataset)}">查看血缘</button></td>` +
`</tr>`).join('') : `<tr><td colspan="4" class="empty-hint">尚无血缘记录</td></tr>`;
return panelHtml('主站影响', right, `<table class="dtable"><tbody>${rows}</tbody></table>`, false);
}
function timelineHtml(vm) {
const nodes = vm.timeline;
if (!nodes.length) return panelHtml('今晚时间线', '', '<div class="empty-hint">暂无可展示的调度任务</div>', 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 = `<svg viewBox="0 0 ${W} ${H}" style="width:100%;display:block">` +
`<defs><linearGradient id="tlprog" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#34d399"/><stop offset="1" stop-color="#22d3ee"/></linearGradient></defs>` +
`<line x1="${x0 - 8}" y1="${y}" x2="${x1 + 8}" y2="${y}" stroke="#243152" stroke-width="1"/>` +
`<line x1="${x0 - 8}" y1="${y}" x2="${nowX}" y2="${y}" stroke="url(#tlprog)" stroke-width="1.6" class="flowline"/>`;
nodes.forEach((n, i) => {
const nx = X(i), up = i % 2 === 0;
const ty = up ? y - 26 : y + 34, ly = up ? y - 12 : y + 20;
const color = n.state === 'done' ? '#34d399' : n.state === 'fail' ? '#f87171' : '#54637e';
const anchor = i === 0 ? 'start' : i === nodes.length - 1 ? 'end' : 'middle';
s += `<g><line x1="${nx}" y1="${up ? y - 6 : y + 6}" x2="${nx}" y2="${up ? ly - 3 : ly + 3}" stroke="#243152" stroke-width="1"/>`;
if (n.state === 'fail') s += `<circle cx="${nx}" cy="${y}" r="6" fill="rgba(248,113,113,.15)" stroke="#f87171" stroke-width="1.2"/><text x="${nx}" y="${y + 3.2}" text-anchor="middle" font-size="9" fill="#f87171">×</text>`;
else s += `<circle cx="${nx}" cy="${y}" r="${n.state === 'done' ? 4.2 : 3.4}" fill="${n.state === 'done' ? color : '#0c1220'}" stroke="${color}" stroke-width="1.2"/>`;
s += `<text x="${nx}" y="${ly + (up ? -1 : 1)}" text-anchor="${anchor}" font-size="9.5" fill="${n.state === 'fail' ? '#f87171' : '#a9bcd6'}" font-weight="600" class="num">${esc(n.t)}</text>` +
`<text x="${nx}" y="${ty + (up ? -1 : 1)}" text-anchor="${anchor}" font-size="9" fill="${n.state === 'fail' ? 'rgba(248,113,113,.75)' : '#54637e'}">${esc(n.label)}</text></g>`;
});
s += `<g><line x1="${nowX}" y1="${y - 44}" x2="${nowX}" y2="${y + 44}" stroke="#22d3ee" stroke-width="1" stroke-dasharray="2 3" opacity=".7"/>` +
`<circle cx="${nowX}" cy="${y}" r="3.6" fill="#22d3ee" class="now-dot now-r"/>` +
`<text x="${nowX}" y="${y - 50}" text-anchor="middle" font-size="9" fill="#22d3ee" font-weight="600" class="num">NOW</text></g></svg>`;
const right = `<span style="font-size:10px;color:#54637e">现在 <span class="num" style="color:#22d3ee">${p2(now.getHours())}:${p2(now.getMinutes())}</span> · <span style="color:#8b9bb4">SCHEDULER.PY</span></span>`;
return panelHtml('今晚时间线', right, s, false);
}
function incidentsHtml(vm) {
if (!vm.incidents.length) return panelHtml('待处理异常', tagHtml('mint', '0 OPEN'), '<div class="empty-hint">当前无待处理异常</div>', 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));
return `<div class="inc ${it.sev}">${tag}` +
`<div style="flex:1;min-width:0">` +
`<div class="inc-title"${it.sev === 'off' ? ' style="color:#8b9bb4"' : ''}>${esc(it.title)}</div>` +
`<div class="inc-meta">${esc(it.meta)}</div>` +
`</div>` +
`<button class="tbtn" style="flex:none" data-incident-action data-dataset="${esc(it.dataset || '')}" data-provider="${esc(it.provider || '')}">去处理 →</button>` +
`</div>`;
}).join('');
return panelHtml('待处理异常', tagHtml('rd', vm.incidents.length + ' OPEN'), `<div class="col" style="gap:10px">${cards}</div>`, true);
}
function overviewHtml() {
const vm = buildOverviewVM();
return `<div class="col">${heroHtml(vm)}${datasetStripHtml(vm)}` +
`<div class="ov-grid"><div class="ov-l col">${observatoryHtml(vm)}${siteImpactHtml(vm)}</div>` +
`<div class="ov-r col">${timelineHtml(vm)}${incidentsHtml(vm)}</div></div></div>`;
}
/* =====================================================================
数据源配置页
===================================================================== */
const srcUiState = {};
function buildSourcesVM() {
const catalog = (RAW.catalog && RAW.catalog.items) || [];
const legacy = {};
(RAW.sources && RAW.sources.items || []).forEach((it) => { legacy[it.provider] = it; });
const hidx = healthIndex();
const cidx = callsIndex();
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);
const usedIfaces = active.reduce((n, c) => n + c.interfaces.filter((i) => (hidx[c.provider] || []).some((h) => h.interface === i.interface)).length, 0);
const cards = active.map((c) => {
const leg = legacy[c.provider] || {};
const legState = (leg.health && (leg.health.state || leg.health.status)) || null;
const rows = hidx[c.provider] || [];
const worst = rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : (legState ? bucketOf(legState) : 'off');
const cred = c.credential || {};
const lastCall = (cidx[c.provider] || [])[0];
if (!(c.provider in srcUiState)) srcUiState[c.provider] = { open: true, probing: false, probeResult: null };
const ifaceRows = c.interfaces.map((i) => {
const h = rows.find((r) => r.interface === i.interface);
return {
api: i.interface, use: i.capability,
ok: h ? (h.last_ok_at ? hm(h.last_ok_at) : '—') : '—',
lat: h ? h.last_latency_ms : null,
st: h ? bucketOf(h.state) : 'off',
err: h && h.state !== 'ok' ? (h.last_error || h.last_fallback_reason || '') : '',
};
});
return {
id: c.provider, name: c.label, role: c.role, tag: c.credential_key ? 'licensed' : 'free',
bucket: worst, credential: cred, usedCount: ifaceRows.filter((r) => r.st !== 'off').length, totalCount: ifaceRows.length,
lastProbe: lastCall ? `${hm(lastCall.created_at)} · ${lastCall.ok ? `✓ ${lastCall.latency_ms}ms` : `× ${lastCall.error || 'ERROR'}`}` : '—',
latency: rows.length ? (rows.map((r) => r.last_latency_ms).filter((v) => v != null)[0] || null) : null,
error: rows.find((r) => r.last_error)?.last_error || '',
ifaceRows,
};
});
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.pycredential_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, card.role];
let h = `<section class="panel" style="${ringStyle}">`;
h += `<header class="src-head">` +
ledHtml(ledSt, card.bucket === 'fail') +
`<span class="src-name">${esc(card.name)}</span>` +
`<span class="src-role">${esc(card.tag)}</span>` +
pillHtml(card.bucket) +
`<span style="display:flex;gap:6px">${badges.map((b) => tagHtml(card.bucket === 'off' ? 'dashed' : 'dim', esc(b))).join('')}</span>` +
`<span class="flex1"></span>` +
`<button class="tbtn" data-probe="${card.id}"${ui.probing ? ' disabled' : ''}>${ui.probing ? '<span class="animate-spin" style="margin-right:4px">◌</span>探测中…' : '探测一次'}</button>` +
`<button class="tbtn" data-toggle="${card.id}">${ui.open ? '收起接口清单' : '展开接口清单'}</button>` +
`</header>`;
h += `<div class="src-meta">` +
`<span>接口 <span class="num" style="color:#22d3ee">${card.usedCount}/${card.totalCount}</span> 在用</span>` +
`<span class="msep">|</span><span>最后探测 <span class="num" style="color:${card.lastProbe.includes('×') ? '#f87171' : '#8b9bb4'}">${esc(card.lastProbe)}</span></span>` +
`<span class="msep">|</span><span>延迟 ${latHtml(card.latency)}</span>` +
(card.error ? `<span class="msep">|</span><span style="color:#f87171">最近错误 ${esc(card.error)}</span>` : `<span class="msep">|</span><span style="color:#54637e">无最近错误</span>`) +
`</div>`;
if (ui.open && card.ifaceRows.length) {
h += `<div class="mx-grid"><div style="min-width:0;grid-column:1/-1">` +
`<table class="dtable"><thead><tr><th>接口</th><th>用途</th><th>最近成功</th><th>延迟</th><th>状态</th></tr></thead><tbody>` +
card.ifaceRows.map((it) => `<tr>` +
`<td style="color:#22d3ee;font-size:11px;word-break:break-all;min-width:90px">${esc(it.api)}</td>` +
`<td style="color:#8b9bb4;font-size:11px;white-space:nowrap">${esc(it.use)}</td>` +
`<td class="num" style="font-size:11px;color:${it.st === 'fail' ? '#f87171' : '#8b9bb4'}">${esc(it.ok)}</td>` +
`<td style="font-size:11px">${latHtml(it.lat)}</td>` +
`<td style="white-space:nowrap"><span style="display:flex;align-items:center;gap:6px">${pillHtml(it.st)}${it.err ? `<span style="font-size:10px;color:#f87171">${esc(it.err)}</span>` : ''}</span></td>` +
`</tr>`).join('') + `</tbody></table></div></div>`;
} else if (!card.ifaceRows.length) {
h += `<div style="padding:16px"><div class="offbox">该来源尚无已登记接口</div></div>`;
}
return h + `</section>`;
}
function sourcesHtml() {
const vm = buildSourcesVM();
const alerts = [];
vm.cards.forEach((c) => {
if (c.bucket === 'fail') alerts.push(`<span style="display:inline-flex;align-items:center;gap:6px;font-size:11px">${ledHtml('fail', true)}<span style="color:#f87171">${esc(c.name)} 故障${c.error ? ' · ' + esc(c.error) : ''}</span></span>`);
else if (c.bucket === 'degrade' || c.bucket === 'slow') alerts.push(`<span style="display:inline-flex;align-items:center;gap:6px;font-size:11px">${ledHtml('slow')}<span style="color:#fbbf24">${esc(c.name)} 降级/延迟</span></span>`);
});
return `<div class="col">` +
`<div class="panel strip">` +
`<span style="font-size:13px;color:#e8f1ff">数据源 <span class="num" style="color:#22d3ee">${vm.cards.length}</span> 路 · 接口 <span class="num" style="color:#22d3ee">${vm.usedIfaces}/${vm.totalIfaces}</span> 在用</span>` +
alerts.join('') +
`<span class="flex1"></span>` +
`<span style="font-size:10px;color:#54637e;letter-spacing:.12em">凭证安全存储 · 探测只读 · 不影响盘中链路</span>` +
`</div>` +
`<div id="srcCards" class="col">${vm.cards.map(sourceCardHtml).join('')}</div>` +
`<div class="rsv-grid">${vm.reserved.map((r) => `<div class="rsv">${ledHtml('off')}<span style="font-size:13px">${esc(r.label)}</span>${tagHtml('dashed', '预留位 · 未接入')}</div>`).join('')}</div>` +
`</div>`;
}
function renderSourceCards() {
const el = $('srcCards');
if (!el) return;
const vm = buildSourcesVM();
el.innerHTML = vm.cards.map(sourceCardHtml).join('');
}
/* =====================================================================
数据血缘页
===================================================================== */
function pageNameOf(consumer) {
const idx = consumer.indexOf('(');
return (idx > 0 ? consumer.slice(0, idx) : consumer).trim();
}
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 pageDatasets = {};
const edgesSD = [];
const edgesDP = [];
const rows = [];
items.forEach((it) => {
const rowsHealth = it.live_provider_health || [];
let bucket;
if (it.tier === 'official') {
bucket = it.publication ? (it.publication.state === 'published' ? 'ok' : (it.publication.state === 'degraded' ? 'degrade' : 'fail')) : (rowsHealth.length ? worstBucket(rowsHealth.map((r) => bucketOf(r.state))) : 'plan');
} else {
bucket = rowsHealth.length ? worstBucket(rowsHealth.map((r) => bucketOf(r.state))) : 'off';
}
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]);
(pageDatasets[pg] = pageDatasets[pg] || []).push(it);
});
const lat = rowsHealth.map((r) => r.last_latency_ms).find((v) => v != null);
const ok = it.tier === 'official' && it.publication ? it.publication.published_at : (rowsHealth.find((r) => r.last_ok_at) || {}).last_ok_at;
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' ? '主(授权)' : '主(无备)'),
ok: ok ? hm(ok) : '—', lat, st: bucket,
note: rowsHealth.find((r) => r.last_error)?.last_error || rowsHealth.find((r) => r.last_fallback_reason)?.last_fallback_reason || (it.publication && it.publication.state !== 'published' ? '未正式发布' : ''),
});
});
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 = `<svg viewBox="0 0 ${GW} ${GH}" style="width:100%;display:block" id="linSvg">`;
const colStyle = 'font-size="10" letter-spacing="2" fill="#54637e"';
if (vm.sources.length) h += `<text x="${gpos['s:' + vm.sources[0].id].x}" y="12" text-anchor="middle" ${colStyle}>来源 · ${vm.sources.length}</text>`;
if (vm.datasets.length) h += `<text x="${gpos['d:' + vm.datasets[0].id].x}" y="12" text-anchor="middle" ${colStyle}>数据集 · ${vm.datasets.length}</text>`;
if (vm.pages.length) h += `<text x="${gpos['p:' + vm.pages[0].id].x}" y="12" text-anchor="middle" ${colStyle}>主站页面 · ${vm.pages.length}</text>`;
const dsBy = {}; vm.datasets.forEach((d) => { dsBy[d.id] = d.st; });
vm.edgesSD.forEach(([s, d]) => {
if (!gpos['s:' + s] || !gpos['d:' + d]) return;
h += `<path data-link data-kind="sd" data-a="${esc(s)}" data-b="${esc(d)}" d="${linkPath(gpos['s:' + s], gpos['d:' + d])}" fill="none" stroke="${stColorOf(dsBy[d])}" stroke-width="1" stroke-opacity="0.28" class="${dsBy[d] === 'off' ? '' : 'flowline'}" style="transition:stroke-opacity .18s ease,stroke-width .18s ease"/>`;
});
vm.edgesDP.forEach(([d, p]) => {
if (!gpos['d:' + d] || !gpos['p:' + p]) return;
h += `<path data-link data-kind="dp" data-a="${esc(d)}" data-b="${esc(p)}" d="${linkPath(gpos['d:' + d], gpos['p:' + p])}" fill="none" stroke="${stColorOf(dsBy[d])}" stroke-width="1" stroke-opacity="0.22" class="${dsBy[d] === 'off' ? '' : 'flowline'}" style="transition:stroke-opacity .18s ease,stroke-width .18s ease"/>`;
});
vm.sources.forEach((s) => {
const p = gpos['s:' + s.id]; if (!p) return;
h += `<g data-nid="${esc(s.id)}" data-gkind="src" style="cursor:pointer;transition:opacity .18s">` +
`<circle cx="${p.x}" cy="${p.y}" r="16" fill="rgba(34,211,238,.05)" stroke="#243152"/>` +
`<circle cx="${p.x}" cy="${p.y}" r="5" fill="${stColorOf(s.st)}"${s.st !== 'off' ? ' class="gnode-pulse"' : ''}/>` +
`<text x="${p.x + 24}" y="${p.y + 4}" font-size="12" fill="#d7e1f0" font-weight="600">${esc(s.name)}</text></g>`;
});
vm.datasets.forEach((d) => {
const p = gpos['d:' + d.id]; if (!p) return;
h += `<g data-nid="${esc(d.id)}" data-gkind="ds" style="cursor:pointer;transition:opacity .18s">` +
`<circle cx="${p.x}" cy="${p.y}" r="4" fill="#0c1220" stroke="${stColorOf(d.st)}" stroke-width="1.4"/>` +
`<text x="${p.x - 12}" y="${p.y + 3.5}" text-anchor="end" font-size="10.5" fill="#8b9bb4">${esc(d.id)}</text></g>`;
});
vm.pages.forEach((pg) => {
const p = gpos['p:' + pg.id]; if (!p) return;
h += `<g data-nid="${esc(pg.id)}" data-gkind="pg" style="cursor:pointer;transition:opacity .18s">` +
`<circle cx="${p.x}" cy="${p.y}" r="3" fill="${stColorOf(pg.st)}"/>` +
`<text x="${p.x - 12}" y="${p.y + 3.5}" text-anchor="end" font-size="10.5" fill="#8b9bb4">${esc(pg.name)}</text></g>`;
});
h += `</svg><div class="lin-hint" id="linHint">悬停节点 · 追踪上下游链路</div>`;
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' : '';
body.innerHTML = rows.map((r) => `<tr class="${cls(r.st)}">` +
`<td style="color:#e8f1ff;white-space:nowrap">${esc(r.page)}</td>` +
`<td style="color:#8b9bb4;font-size:11px;white-space:nowrap">${esc(r.item)}</td>` +
`<td style="color:#22d3ee;font-size:11px">${esc(r.dataset)}</td>` +
`<td style="color:#8b9bb4;font-size:11px;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.via)}">${esc(r.via)}</td>` +
`<td style="color:#54637e;font-size:11px;white-space:nowrap">${esc(r.role)}</td>` +
`<td class="num" style="font-size:11px;white-space:nowrap;color:#8b9bb4">${esc(r.ok)}</td>` +
`<td>${latHtml(r.lat)}</td>` +
`<td><span style="display:flex;align-items:center;gap:8px;white-space:nowrap">${pillHtml(r.st)}${r.note ? `<span style="font-size:10px;color:#54637e">${esc(r.note)}</span>` : ''}</span></td>` +
`</tr>`).join('') || `<tr><td colspan="8" class="empty-hint">无匹配记录 · 调整筛选条件</td></tr>`;
if (bodyN) bodyN.innerHTML = rows.map((r) => `<tr class="${cls(r.st)}">` +
`<td style="color:#e8f1ff;white-space:nowrap">${esc(r.page)}<span class="sub" style="color:#8b9bb4">${esc(r.item)}</span></td>` +
`<td style="color:#22d3ee;font-size:11px">${esc(r.dataset)}</td>` +
`<td style="color:#8b9bb4;font-size:11px;word-break:break-all">${esc(r.via)}</td>` +
`<td class="num" style="font-size:11px;white-space:nowrap;color:#8b9bb4">${esc(r.ok)}<span class="sub">${r.lat != null ? r.lat + 'ms' : '—'}</span></td>` +
`<td>${pillHtml(r.st)}${r.note ? `<span class="sub">${esc(r.note)}</span>` : ''}</td>` +
`</tr>`).join('') || `<tr><td colspan="5" class="empty-hint">无匹配记录 · 调整筛选条件</td></tr>`;
}
function lineageHtml() {
const vm = buildLineageVM();
const anomalyPages = new Set();
vm.rows.forEach((r) => { if (r.st === 'fail') (r.page || '').split(' / ').forEach((p) => anomalyPages.add(p)); });
const html = `<div class="col">` +
`<div class="panel strip" style="gap:8px 20px">` +
`<span style="font-size:13px;color:#e8f1ff">血缘地图</span>` +
`<span class="num" style="font-size:11px;color:#8b9bb4"><span style="color:#22d3ee">${vm.sources.length}</span> 源 → <span style="color:#22d3ee">${vm.datasets.length}</span> 数据集 → <span style="color:#22d3ee">${vm.pages.length}</span> 页面</span>` +
`<span class="legend" style="color:#54637e;letter-spacing:0"><span>${ledHtml('ok')}健康</span><span>${ledHtml('slow')}慢/降级</span><span>${ledHtml('fail')}失败</span><span>${ledHtml('plan')}计划</span><span>${ledHtml('off')}未配置</span></span>` +
`<span class="flex1"></span>` +
(anomalyPages.size ? tagHtml('rd', `${[...anomalyPages][0]} 断链影响 ${anomalyPages.size} 页`) : tagHtml('mint', '暂无断链影响')) +
`</div>` +
`<section class="panel"><div style="position:relative">${lineageGraphHtml(vm)}</div></section>` +
`<section class="panel">` +
`<div class="lin-filter">` +
`<select class="lin-sel" id="linSel">${['全部', 'Tushare', '东方财富', '腾讯', 'iFinD'].map((o) => `<option${linFilter.src === o ? ' selected' : ''}>按源筛选 · ${o}</option>`).join('')}</select>` +
`<button class="tbtn${linFilter.onlyBad ? ' on' : ''}" id="linBad">${ledHtml(linFilter.onlyBad ? 'fail' : 'plan')} 只看异常</button>` +
`<span class="flex1"></span>` +
`<span class="num" style="font-size:10px;color:#54637e" id="linCount"></span>` +
`<input class="lin-q" id="linQ" placeholder="搜索页面 / 接口 / 数据集" value="${esc(linFilter.q)}">` +
`</div>` +
`<div class="lin-scroll"><table class="dtable lin-table"><thead><tr><th>主站页面</th><th>数据项</th><th>数据集</th><th>源</th><th>主/备</th><th>最近成功</th><th>延迟</th><th>状态</th></tr></thead><tbody id="linBody"></tbody></table>` +
`<table class="dtable lin-table-n"><thead><tr><th>主站页面</th><th>数据集</th><th>源</th><th>最近成功</th><th>状态</th></tr></thead><tbody id="linBodyN"></tbody></table></div>` +
`</section>` +
`</div>`;
window.__linVM = vm;
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();
else if (p === 'sources') mainEl.innerHTML = sourcesHtml();
else {
mainEl.innerHTML = lineageHtml();
renderLinRows(window.__linVM);
applyLinHover(window.__linVM);
const svg = $('linSvg');
if (svg) svg.addEventListener('mouseleave', () => setLinHover(null, window.__linVM));
fitLineageTable();
}
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: 血缘详情表随真实数据集数量(当前 17 条,非打样假设的 13 条)
动态撑高;宽档(>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;
$('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 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: '{}' }); } catch (err) { toast(err.message, 'err'); }
ui.probing = false;
await refreshQuiet();
renderSourceCards();
}
/* ---------- EVENT TAPE:真实事件差量渲染 ---------- */
const tapeSeen = { call: 0, provCall: 0, audit: 0, job: 0, first: true };
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;
}
let tapeBuf = [];
function updateTape() {
const evs = tapeEventsFromRaw();
if (tapeSeen.first) { tapeSeen.first = false; return; } // 首次加载只建基线,不补播历史事件
if (!evs.length) return;
tapeBuf = tapeBuf.concat(evs).slice(-16);
const html = tapeBuf.map((e) => `<span class="tape-item"><span class="tt num">${esc(hm(e.ts))}</span><span class="ts">${esc(e.src)}</span><span class="tm">${esc(e.msg)}</span>` +
(e.ok ? `<span class="tok num">✓${e.ms ? ` ${e.ms}ms` : ''}</span>` : '<span class="tbad">× 失败</span>') +
`<span class="tsep">///</span></span>`).join('');
$('tapeA').innerHTML = html; $('tapeB').innerHTML = html;
}
/* ---------- 抽屉:调度任务 / 盘后发布 / 审计 ---------- */
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 = `
<div class="drawer-section">
<div class="lab">调度任务定义</div>
<table class="dtable"><thead><tr><th>任务</th><th>时刻</th><th>操作</th></tr></thead><tbody>
${jobs.map((j) => `<tr><td style="color:#e8f1ff">${esc(j.id)} · ${esc(j.title)}</td><td class="num" style="color:#8b9bb4">${esc(j.at)}</td>
<td><input class="lin-q" data-run-date="${esc(j.id)}" placeholder="YYYYMMDD 留空=今天" style="width:150px"><button class="opbtn" data-run="${esc(j.id)}">手动触发</button></td></tr>`).join('')}
</tbody></table>
</div>
<div class="drawer-section">
<div class="lab">最近运行(最新 20 条)</div>
<table class="dtable"><thead><tr><th>ID</th><th>任务</th><th>状态</th><th>开始</th><th>结束</th><th>错误</th></tr></thead><tbody>
${runs.slice(0, 20).map((r) => `<tr><td class="num">${r.id}</td><td>${esc(r.job_id)}</td><td>${pillHtml(bucketOf(r.state))}</td><td class="num" style="font-size:11px">${esc(r.started_at || '')}</td><td class="num" style="font-size:11px">${esc(r.finished_at || '')}</td><td style="color:#f87171;font-size:11px">${esc(r.error || '')}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-hint">暂无运行记录</td></tr>'}
</tbody></table>
</div>`;
$('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 = `
<div class="dtoolbar"><label style="font-size:11px;color:#8b9bb4">日期 <input id="relDate" value="${esc(date)}" style="margin-left:6px"></label>
<button class="opbtn" id="relLoad">查看</button>
<button class="opbtn danger" id="relBackfill">补数</button></div>
<div id="relBody"></div>`;
$('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 = `
<div class="drawer-section"><div class="lab">发布映射</div>
<table class="dtable"><thead><tr><th>数据集</th><th>活跃批次</th><th>上一批次</th><th>状态</th><th>发布时间</th><th>操作</th></tr></thead><tbody>
${data.publications.map((row) => `<tr><td style="color:#e8f1ff">${esc(row.dataset)}</td><td class="num" style="font-size:11px">${esc(row.active_batch)}</td><td class="num" style="font-size:11px">${esc(row.prev_batch || '')}</td><td>${pillHtml(bucketOf(row.state))}</td><td class="num" style="font-size:11px">${esc(row.published_at || '')}</td>
<td>${row.prev_batch ? `<button class="opbtn danger" data-rollback="${esc(row.dataset)}">回滚</button>` : '—'}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-hint">当日暂无发布记录</td></tr>'}
</tbody></table></div>
<div class="drawer-section"><div class="lab">批次</div>
<table class="dtable"><thead><tr><th>batch_id</th><th>数据集</th><th>状态</th><th>行数</th><th>错误</th></tr></thead><tbody>
${data.batches.map((row) => `<tr><td class="num" style="font-size:11px">${esc(row.batch_id)}</td><td>${esc(row.dataset)}</td><td>${pillHtml(bucketOf(row.state))}</td><td class="num">${row.rows_out ?? ''}</td><td style="color:#f87171;font-size:11px">${esc(row.error || '')}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-hint">当日暂无批次</td></tr>'}
</tbody></table></div>`;
$('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 = `<table class="dtable"><thead><tr><th>时间</th><th>操作者</th><th>动作</th><th>对象</th><th>详情</th></tr></thead><tbody>
${items.map((row) => `<tr><td class="num" style="font-size:11px">${esc(row.created_at)}</td><td>${esc(row.actor)}</td><td style="color:#22d3ee">${esc(row.action)}</td><td style="font-size:11px">${esc(row.target || '')}</td><td style="font-size:11px;color:#8b9bb4;max-width:220px;overflow:hidden;text-overflow:ellipsis">${esc(row.detail || '')}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-hint">暂无审计记录</td></tr>'}
</tbody></table>`;
}
/* ---------- 危险操作确认弹层(回滚 / 补数) ---------- */
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);
}
/* ================= 顶栏:时钟 / 减少动态 / 主题 ================= */
function updateClock() {
const now = new Date();
$('ckD').textContent = `${now.getFullYear()}-${p2(now.getMonth() + 1)}-${p2(now.getDate())}`;
$('ckH').textContent = p2(now.getHours());
$('ckM').textContent = p2(now.getMinutes());
$('ckS').textContent = p2(now.getSeconds());
}
function applyCalm(on) {
state.calm = on;
$('appRoot').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);
}
/* ================= 轮询:拉取真实数据、驱动渲染、可见性/网络门禁 ================= */
const Poller = (() => {
let timer = null;
let running = false;
const INTERVAL = 4000;
async function tick() {
if (!running) return;
try {
await fetchAll();
updateTape();
if (state.page === 'overview') mainEl.innerHTML = overviewHtml();
else if (state.page === 'sources') renderSourceCards();
else if (state.page === 'lineage') { /* 血缘图交互态复杂,仅在用户操作或下次轮询后整体重绘一次 */ mainEl.innerHTML = lineageHtml(); renderLinRows(window.__linVM); applyLinHover(window.__linVM); fitLineageTable(); }
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; },
};
})();
function resetTapeBaselines() {
// 恢复轮询时静默重建基线,不补播暂停期间错过的事件。
tapeSeen.call = tapeSeen.provCall = tapeSeen.audit = tapeSeen.job = Number.MAX_SAFE_INTEGER;
tapeSeen.first = true;
}
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() {
setInterval(updateClock, 1000);
updateClock();
await fetchAll();
tapeSeen.first = true;
renderPage(pageFromHash());
await Poller.start();
}
boot();