'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().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 ``; }
function pillHtml(bucket, label) { const h = HEALTH[bucket] || HEALTH.off; return `${ledHtml(h.led, bucket === 'fail' || bucket === 'review')}${label || h.label}`; }
function tagHtml(tone, inner) { return `${inner}`; }
function panelHtml(title, right, body, pad) {
return `//${title}${right ? `${right}` : ''}${body}
`;
}
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 `
${label}${value}${live ? '_' : ''}${sub ? `${sub}` : ''}
`;
}
/* 延迟:真实值 + 变化时才闪(打样 jit 的真实数据版:不随机抖动数字,
仅当新一轮真实调用改写该值时给 .flash 青色闪烁)。 */
function latHtml(ms, warn) {
if (ms == null) return '—';
const color = ms > 1000 ? '#fbbf24' : (warn ? '#fbbf24' : '#d7e1f0');
return `${Number(ms).toLocaleString('en-US')}ms`;
}
/* 缓存年龄:记录真实时间戳,由 updateDynamics 每秒重算(真实增长)。 */
function ageHtml(iso) {
const ms = Date.parse(String(iso || '').includes('T') ? iso : String(iso || '').replace(' ', 'T'));
if (!iso || Number.isNaN(ms)) return '—';
return `—`;
}
function fmtAgeSec(s) {
if (s == null) return '—';
// 打样口径:缓存年龄一律按秒展示、每秒增长(>30s 变琥珀)
return `${Number(s).toLocaleString('en-US')}s`;
}
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');
/* ================= sparkline(真实历史:来自 provider_call_log) ================= */
const SPARK_COLORS = { cy: '#22d3ee', mint: '#34d399', amb: '#fbbf24', rd: '#f87171' };
function sparkHtml(jid, tone, w, h) {
return ``;
}
function sparkSeries(jid) {
const hist = LatHist.get(jid) || [];
if (hist.length < 2) return null;
const max = Math.max(...hist, 1);
return hist.map((v) => v / max);
}
function sparkPaint(svg) {
const jid = svg.dataset.jid, w = +svg.dataset.w, h = +svg.dataset.h;
const data = sparkSeries(jid);
const poly = svg.querySelector('polyline');
const dot = svg.querySelector('circle');
if (!data) {
const y = (h / 2).toFixed(2);
poly.setAttribute('points', `0,${y} ${w},${y}`);
poly.setAttribute('stroke-opacity', '.25');
dot.style.display = 'none';
return;
}
poly.setAttribute('stroke-opacity', '.8');
dot.style.display = '';
const pts = data.map((v, i) => `${((i / (data.length - 1)) * w).toFixed(2)},${(h - v * (h - 3) - 1).toFixed(2)}`).join(' ');
poly.setAttribute('points', pts);
dot.setAttribute('cx', (w - 1).toFixed(2));
dot.setAttribute('cy', (h - data[data.length - 1] * (h - 3) - 1).toFixed(2));
}
/* 每个“观测对象”(接口或数据集)的真实延迟历史(旧→新),来自
provider_call_log 最近 200 条。 */
const LatHist = new Map();
const CALL_MATCHERS = {};
function buildLatHist() {
const calls = (RAW.providers && RAW.providers.recent_calls) || [];
const buckets = new Map();
[...calls].reverse().forEach((c) => {
if (c.latency_ms == null) return;
const key = `${c.provider}:${c.interface}`;
(buckets.get(key) || buckets.set(key, []).get(key)).push(c.latency_ms);
});
LatHist.clear();
buckets.forEach((arr, key) => LatHist.set(key, arr.slice(-24)));
// 数据集维度的历史:tushare 观测行写的是数据集名,直接按数据集聚合
const dsBuckets = new Map();
[...calls].reverse().forEach((c) => {
if (c.provider !== 'tushare' || c.latency_ms == null) return;
(dsBuckets.get(c.interface) || dsBuckets.set(c.interface, []).get(c.interface)).push(c.latency_ms);
});
dsBuckets.forEach((arr, ds) => LatHist.set(`ds:${ds}`, arr.slice(-24)));
}
/* ================= 数据层:拉取真实接口并汇总为一份快照 ================= */
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 = `
最近运行(最新 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('') || '| 暂无审计记录 |
'}
`;
}
/* ---------- 危险操作确认弹层(回滚 / 补数) ---------- */
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);
});
}
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();