Files
xiaobai-review/xiaobai-datahub/admin/app.js
施工员2号andmultica-agent 3bafd30aad feat(HEL-529): 按定稿100%重做三页数据中枢 + 数据修正1-5
视觉:admin/app.js 按已确认打样 hub-kimi.html 逐行重写三页 DOM 与动效
(sparkline/缓存年龄秒增/延迟变化闪烁/EVENT TAPE 预装滚动/分组接口表/
更新频率列/时钟冒号 blink/雷达 blip),CSS 补真实调用脉冲 node-ping。
数据修正:
1) pipeline._stage 批内按暂存表业务键确定性去重(保留最后一条),
   修复人气榜/龙虎榜自 09-07 起每日 UNIQUE constraint 落库失败;
2) overview.anomalies 收敛为「最新批次未成功且当日未发布」的当前异常,
   历史已恢复批次留在审计明细;
3) source_catalog 接口补真实批次分组 + 观测 join(接口名或数据集名
   双向匹配,标注 observed/observed_basis),消除「全部未配置/0/30」误报;
4) lineage 逐数据集按其服务接口过滤健康行(接口级状态),
   provisional 已配置无观测显示「已配置 · 待观测」,仅 iFinD 为未配置;
5) lineage 补 update_freq 真实频率字段。
测试:新增 tests/test_hel529_rework.py(8 项),全套 191 项通过
(1 项环境依赖失败在基线 d9358ab 上同样复现,与本改动无关)。

Co-authored-by: multica-agent <github@multica.ai>
2026-09-15 08:57:29 +08:00

1377 lines
80 KiB
JavaScript
Raw Permalink 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 返工:按定稿 100% 还原
视觉与 DOM 结构逐行照抄已确认打样 hub-kimi.htmlv12 血缘压缩增补),
但本文件不含任何随机/模拟数据——全部状态、数字、事件均来自真实接口:
/admin/api/{overview,sources,jobs,batches,audit,...} 与 HEL-543 只读旁路
/admin/api/{providers/status,source-catalog,lineage,lineage/affected}。
动效遵循 SPEC-V11marquee 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) => ({ '&': '&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>`;
}
/* 延迟:真实值 + 变化时才闪(打样 jit 的真实数据版:不随机抖动数字,
仅当新一轮真实调用改写该值时给 .flash 青色闪烁)。 */
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 jit" data-lat="${Number(ms)}" style="color:${color}">${Number(ms).toLocaleString('en-US')}ms</span>`;
}
/* 缓存年龄:记录真实时间戳,由 updateDynamics 每秒重算(真实增长)。 */
function ageHtml(iso) {
const ms = Date.parse(String(iso || '').includes('T') ? iso : String(iso || '').replace(' ', 'T'));
if (!iso || Number.isNaN(ms)) return '<span style="color:#54637e">—</span>';
return `<span class="num" data-age-epoch="${ms}" style="color:#8b9bb4">—</span>`;
}
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 `<svg width="${w}" height="${h}" class="spark" data-jid="${esc(jid)}" data-w="${w}" data-h="${h}" style="display:block;opacity:.9"><polyline points="" fill="none" stroke="${SPARK_COLORS[tone]}" stroke-width="1.2" stroke-opacity=".8"/><circle class="spark-end" r="2" fill="${SPARK_COLORS[tone]}" cx="0" cy="0"/></svg>`;
}
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:1523: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 ? `<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 class="num" data-cd-epoch="${Date.parse(String(vm.eod.next_retry_at).replace(' ', 'T'))}" data-cdfmt="mmss" style="color:#fbbf24">--:--</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>`, vm.unpublished.length ? `${vm.unpublished.map((d) => DATASET_NAME[d] || d).join(' · ')} 未发布` : '全部已发布') +
kpiHtml('今日调用成功率', vm.successRate == null ? '<span style="color:#54637e">—</span>' : `<span>${vm.successRate.toFixed(1)}%</span>`, 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') +
`</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' : '';
const sparkTone = d.bucket === 'fail' ? 'rd' : d.bucket === 'degrade' ? 'amb' : d.bucket === 'review' ? 'cy' : 'mint';
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) { const brief = String(d.err).length > 22 ? String(d.err).slice(0, 22) + '…' : d.err; note += `<span style="color:#f87171" title="${esc(d.err)}">${esc(brief)}</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>` +
sparkHtml(`ds:${d.id}`, sparkTone, 96, 16) +
`</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>${sparkHtml(o.jid, o.bucket === 'slow' ? 'amb' : 'mint', 64, 16)}</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="6" class="empty-hint">尚无真实观测记录(等待首次探测/调用)</td></tr>`;
const body = `<table class="dtable"><thead><tr><th>观察项</th><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 bad = vm.siteImpact.filter((s) => s.bucket !== 'ok');
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 = bad.length ? bad.slice(0, 5).map((s) => `<tr>` +
`<td style="color:#e8f1ff;width:40%">${esc(s.page)}</td>` +
`<td style="width:20%">${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 style="color:#e8f1ff;width:40%">全部页面正常</td><td>${pillHtml('ok')}</td><td style="font-size:11px;color:#54637e">—</td><td></td></tr>`;
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 = `<div class="site-foot"><span style="color:#34d399;margin-right:4px">正常 ${vm.okPages}</span>` +
[...byPage.entries()].map(([name, bucket]) => bucket === 'ok'
? `<span style="display:inline-flex;align-items:center;gap:6px"><span>${esc(name)}</span><span style="color:#243152">·</span></span>`
: `<span style="display:inline-flex;align-items:center;gap:6px;color:${HEALTH[bucket].color}">${esc(name)}(${HEALTH[bucket].label})</span><span style="color:#243152">·</span></span>`).join('') +
`</div>`;
return panelHtml('主站影响', right, `<table class="dtable"><tbody>${rows}</tbody></table>${foot}`, 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 range = nodes.length ? `${nodes[0].t}${nodes[nodes.length - 1].t}` : '';
const right = `<span style="font-size:10px;color:#54637e">现在 <span class="num" style="color:#22d3ee">${p2(now.getHours())}:${p2(now.getMinutes())}</span> · ${range} · <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));
const meta = it.cd
? `${it.meta}<span class="num" style="color:#fbbf24"> · 下次重试 <span data-cd-epoch="${Date.parse(String(it.cd).replace(' ', 'T'))}" data-cdfmt="ms">--m--s</span></span>`
: it.meta;
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">${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>`;
}
/* =====================================================================
数据源配置页(分组接口表照抄打样 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.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];
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 = `<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.roleText)}</span>` +
pillHtml(card.bucket, card.bucket === 'plan' ? '已配置 · 待观测' : '') +
`<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 || card.bucket === 'off' ? ' disabled' : ''}>${ui.probing ? '<span class="animate-spin" style="margin-right:4px">◌</span>探测中…' : '探测一次'}</button>` +
(card.bucket === 'off'
? `<button class="tbtn" data-cred-hint="${card.id}">更新凭证</button>`
: `<button class="tbtn" data-toggle="${card.id}">${ui.open ? '收起接口清单' : '展开接口清单'}</button>`) +
`</header>`;
const probeCell = ui.probeResult
? `<span class="num" style="color:${ui.probeResult.ok ? '#34d399' : '#f87171'}">${ui.probeResult.t} ${ui.probeResult.ok ? `✓ ${ui.probeResult.ms}ms` : '× TIMEOUT'}</span>`
: `<span class="num"${card.lastProbe.includes('✓') ? ' style="color:#34d399"' : card.lastProbe.includes('×') ? ' style="color:#f87171"' : ''}>${esc(card.lastProbe)}</span>`;
h += `<div class="src-meta">` +
`<span>接口 <span class="num" style="color:${card.bucket === 'off' ? '#54637e' : '#22d3ee'}">${card.usedCount}/${card.totalCount}</span> 在用</span>` +
`<span class="msep">|</span><span>最后探测 ${probeCell}</span>` +
`<span class="msep">|</span><span>延迟 ${latHtml(card.latency)}</span>` +
(card.error ? `<span class="msep">|</span><span style="color:#f87171">最近错误 ${esc(card.error)}</span>` : (card.bucket !== 'off' ? `<span class="msep">|</span><span style="color:#54637e">无最近错误</span>` : '')) +
`</div>`;
if (ui.open && card.bucket !== 'off') {
h += `<div class="mx-grid">` + card.groups.map((g) => `<div style="min-width:0">` +
`<div class="lab" style="margin-bottom:6px">${esc(g.name)}</div>` +
`<table class="dtable"><thead><tr><th>接口</th><th>用途</th><th>最近成功</th><th>延迟</th><th>状态</th></tr></thead><tbody>` +
g.rows.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.ok.includes('×') || it.ok === '昨日' ? '#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.st === 'plan' && it.obs === false ? '已配置 · 待观测' : '')}${it.err ? `<span style="font-size:10px;color:#f87171">${esc(it.err)}</span>` : ''}</span></td>` +
`</tr>`).join('') +
`</tbody></table></div>`).join('') + `</div>`;
}
if (card.bucket === 'off') {
h += `<div style="padding:16px"><div class="offbox">凭证未配置 · ${card.totalCount} 个预留接口处于停用状态 · 配置后将成为图表 / 问财 / 实时快照主源` +
`<div style="margin-top:8px;display:flex;justify-content:center;gap:6px;flex-wrap:wrap">${card.groups[0].rows.map((i) => tagHtml('dashed', esc(i.api))).join('')}</div>` +
`</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;
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 = `<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; });
// 已连接线路全部有可辨识的定向流动(flowline),颜色按数据集真实状态
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' : '';
const stLabel = (r) => (r.st === 'plan' && r.note.includes('待观测')) ? '已配置 · 待观测' : '';
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="color:#8b9bb4;font-size:11px;white-space:nowrap">${esc(r.freq)}</td>` +
`<td class="num" style="font-size:11px;white-space:nowrap;color:#8b9bb4">${r.ok ? esc(hm(r.ok)) : '—'}</td>` +
`<td class="num" style="font-size:11px;color:${r.lat != null && r.lat > 1000 ? '#fbbf24' : '#8b9bb4'}">${r.lat != null ? Number(r.lat).toLocaleString('en-US') + 'ms' : '—'}</td>` +
`<td><span style="display:flex;align-items:center;gap:8px;white-space:nowrap">${pillHtml(r.st, stLabel(r))}${r.note && !stLabel(r) ? `<span style="font-size:10px;color:#54637e">${esc(r.note)}</span>` : ''}</span></td>` +
`</tr>`).join('') || `<tr><td colspan="9" 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="color:#8b9bb4;font-size:11px;white-space:nowrap">${esc(r.freq)}<span class="sub">${esc(r.role)}</span></td>` +
`<td class="num" style="font-size:11px;white-space:nowrap;color:#8b9bb4">${r.ok ? esc(hm(r.ok)) : '—'}<span class="sub">${r.lat != null ? r.lat + 'ms' : '—'}</span></td>` +
`<td>${pillHtml(r.st, stLabel(r))}${r.note && !stLabel(r) ? `<span class="sub">${esc(r.note)}</span>` : ''}</td>` +
`</tr>`).join('') || `<tr><td colspan="6" class="empty-hint">无匹配记录 · 调整筛选条件</td></tr>`;
}
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 = `<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>` +
impactTag +
`</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><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><th>状态</th></tr></thead><tbody id="linBodyN"></tbody></table></div>` +
`</section>` +
`</div>`;
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 `<span class="tape-item${fresh ? ' flash' : ''}"><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>`;
}
function renderTape(freshIds) {
const items = tapeBuf.slice(-14);
const html = items.length
? items.map((e) => tapeEventHtml(e, freshIds && freshIds.has(e.sortId))).join('')
: `<span class="tape-item"><span class="tm" style="color:#54637e">暂无真实事件 · 等待首次调用/任务</span><span class="tsep">///</span></span>`;
$('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 = `
<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);
}
/* ================= 顶栏:时钟 / 减少动态 ================= */
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();