diff --git a/xiaobai-datahub/admin/app.js b/xiaobai-datahub/admin/app.js
index 24066d9..0f07ed9 100644
--- a/xiaobai-datahub/admin/app.js
+++ b/xiaobai-datahub/admin/app.js
@@ -1,12 +1,13 @@
'use strict';
/* =====================================================================
- 小白复盘 · 数据中枢 admin/app.js — HEL-529 第十二版
- 三页真实数据中枢:运行总览 / 数据源配置 / 数据血缘。
- 视觉系统逐行照抄已通过打样 hub-kimi.html + SPEC-V11.md(v12 血缘压缩增补),
+ 小白复盘 · 数据中枢 admin/app.js — HEL-529 返工:按定稿 100% 还原
+ 视觉与 DOM 结构逐行照抄已确认打样 hub-kimi.html(v12 血缘压缩增补),
但本文件不含任何随机/模拟数据——全部状态、数字、事件均来自真实接口:
- 既有 /admin/api/{overview,sources,jobs,batches,datasets,audit,...}
- 与 HEL-543 新增只读旁路 /admin/api/{providers/status,source-catalog,
- lineage,lineage/affected}。
+ /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 ================= */
@@ -136,27 +137,23 @@ 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}` : ''}
`;
}
-function radarBlipsHtml(pulseOk) {
- return pulseOk
- ? ``
- : '';
-}
+/* 延迟:真实值 + 变化时才闪(打样 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`;
-}
-function ageFromIso(iso) {
- if (!iso) return null;
- const t = Date.parse(iso.includes('T') ? iso : iso.replace(' ', 'T'));
- if (Number.isNaN(t)) return null;
- return Math.max(0, Math.floor((Date.now() - t) / 1000));
+ return `${Number(ms).toLocaleString('en-US')}ms`;
}
+/* 缓存年龄:记录真实时间戳,由 updateDynamics 每秒重算(真实增长)。 */
function ageHtml(iso) {
- const s = ageFromIso(iso);
- if (s == null) return '—';
- const txt = s < 90 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : `${Math.floor(s / 3600)}h`;
- return `${txt}`;
+ 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 '—';
@@ -167,13 +164,66 @@ function hm(iso) {
}
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, datasets: null, audit: null,
+ lineage: null, jobs: null, batches: null, audit: null,
};
async function fetchAll() {
- const [overview, sources, providers, catalog, lineage, jobs, batches, datasets, audit] = await Promise.all([
+ 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'),
@@ -181,31 +231,14 @@ async function fetchAll() {
api('/admin/api/lineage'),
api('/admin/api/jobs'),
api(`/admin/api/batches?date=${today()}`),
- api(`/admin/api/datasets?date=${today()}`),
api('/admin/api/audit'),
]);
RAW.overview = overview; RAW.sources = sources; RAW.providers = providers; RAW.catalog = catalog;
- RAW.lineage = lineage; RAW.jobs = jobs; RAW.batches = batches; RAW.datasets = datasets; RAW.audit = audit;
+ RAW.lineage = lineage; RAW.jobs = jobs; RAW.batches = batches; RAW.audit = audit;
+ buildLatHist();
}
function today() { return RAW.overview ? RAW.overview.trade_date : ''; }
-/* 按 provider(+interface) 索引 provider_health,供各页复用 */
-function healthIndex() {
- const idx = {};
- const rows = (RAW.providers && RAW.providers.health) || [];
- for (const row of rows) {
- idx[row.provider] = idx[row.provider] || [];
- idx[row.provider].push(row);
- }
- return idx;
-}
-function callsIndex() {
- const idx = {};
- const rows = (RAW.providers && RAW.providers.recent_calls) || [];
- for (const row of rows) { (idx[row.provider] = idx[row.provider] || []).push(row); }
- return idx;
-}
-
const OFFICIAL_DATASET_IDS = ['daily', 'valuation', 'moneyflow', 'auction', 'index_daily', 'limit_events', 'popularity', 'dragon_tiger', 'sector_daily', 'stocks'];
const DATASET_NAME = {
daily: '日K', valuation: '估值', moneyflow: '资金流', auction: '竞价', index_daily: '指数日K',
@@ -213,7 +246,7 @@ const DATASET_NAME = {
};
/* =====================================================================
- 总览页
+ 总览页(结构照抄 hub-kimi.html,数据全部真实)
===================================================================== */
function buildOverviewVM() {
const ov = RAW.overview || {};
@@ -223,7 +256,6 @@ function buildOverviewVM() {
(ov.publications || []).forEach((p) => { pubsByDs[p.dataset] = p; });
const batchByDs = {};
((RAW.batches && RAW.batches.batches) || []).forEach((b) => { if (!batchByDs[b.dataset] || b.started_at > batchByDs[b.dataset].started_at) batchByDs[b.dataset] = b; });
- const hidx = healthIndex();
const totalOfficial = OFFICIAL_DATASET_IDS.length;
const publishedCount = OFFICIAL_DATASET_IDS.filter((id) => pubsByDs[id] && pubsByDs[id].state === 'published').length;
const anomalyCount = (ov.anomalies || []).length;
@@ -231,7 +263,7 @@ function buildOverviewVM() {
const successRate = recentCalls.length ? (recentCalls.filter((c) => c.ok).length / recentCalls.length * 100) : null;
// 主站回退页面:主源异常且备源健康 → 判定为“回退中”的 provisional 数据集
- let fallbackPages = 0;
+ const fallbackPages = [];
(RAW.lineage && RAW.lineage.items || []).forEach((it) => {
if (it.tier !== 'provisional' || !it.backup_source) return;
const rows = it.live_provider_health || [];
@@ -239,7 +271,9 @@ function buildOverviewVM() {
const backupProv = (it.providers || [])[1];
const primaryState = worstBucket(rows.filter((r) => r.provider === primaryProv).map((r) => bucketOf(r.state)));
const backupState = worstBucket(rows.filter((r) => r.provider === backupProv).map((r) => bucketOf(r.state)));
- if ((primaryState === 'fail' || primaryState === 'degrade' || primaryState === 'slow') && backupState === 'ok') fallbackPages += 1;
+ if ((primaryState === 'fail' || primaryState === 'degrade' || primaryState === 'slow') && backupState === 'ok') {
+ (it.known_consumers || []).forEach((c) => fallbackPages.push(c));
+ }
});
const datasetCards = OFFICIAL_DATASET_IDS.map((id) => {
@@ -257,37 +291,31 @@ function buildOverviewVM() {
return { id, name: DATASET_NAME[id] || id, bucket, time: note && !batch?.error ? note : '', rows, err: batch && batch.error ? batch.error : '' };
});
- // 实时观察层:eastmoney / tencent 的真实 provider_health 行
+ // 实时观察层:eastmoney / tencent 目录接口的真实观测(observed=true)
const catalogByProv = {};
(RAW.catalog && RAW.catalog.items || []).forEach((it) => { catalogByProv[it.provider] = it; });
const observers = [];
['eastmoney', 'tencent'].forEach((prov) => {
- const rows = hidx[prov] || [];
const cat = catalogByProv[prov];
- rows.forEach((row) => {
- const iface = (cat && cat.interfaces.find((i) => i.interface === row.interface)) || {};
+ if (!cat) return;
+ cat.interfaces.forEach((iface) => {
+ if (!iface.observed) return;
observers.push({
- name: iface.capability || row.interface,
- provider: prov,
- interface: row.interface,
- lastOk: row.last_ok_at,
- lat: row.last_latency_ms,
- bucket: bucketOf(row.state),
- note: row.last_error || row.last_fallback_reason || '',
+ 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 展开
+ // 主站影响:lineage() 条目按 known_consumers 展开(接口级状态)
const siteImpact = [];
(RAW.lineage && RAW.lineage.items || []).forEach((it) => {
- const rows = it.live_provider_health || [];
- const bucket = it.tier === 'official'
- ? (it.publication ? (it.publication.state === 'published' ? 'ok' : (it.publication.state === 'degraded' ? 'degrade' : 'fail')) : (rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'plan'))
- : (rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'off');
- const note = rows.find((r) => r.last_error)?.last_error || rows.find((r) => r.last_fallback_reason)?.last_fallback_reason || '';
+ const bucket = lineageItemBucket(it);
(it.known_consumers || []).forEach((c) => {
- siteImpact.push({ page: c, dataset: it.dataset, bucket, note });
+ siteImpact.push({ page: c, short: pageNameOf(c), dataset: it.dataset, bucket, note: lineageItemNote(it) });
});
});
const okPages = siteImpact.filter((s) => s.bucket === 'ok').length;
@@ -304,14 +332,16 @@ function buildOverviewVM() {
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: `已试 ${eod.attempts || 0} 次${(eod.missing_datasets || []).length ? ' · 缺 ' + eod.missing_datasets.join(',') : ''}`, dataset: (eod.missing_datasets || [])[0] || '' });
+ 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 });
@@ -325,6 +355,7 @@ function buildOverviewVM() {
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'),
};
}
@@ -335,7 +366,7 @@ function heroHtml(vm) {
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(`下次重试 ${hm(vm.eod.next_retry_at)}`);
+ 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 `` +
@@ -345,23 +376,25 @@ function heroHtml(vm) {
(subParts.length ? `
${subParts.join('|')}
` : '') +
`
` +
`` +
- kpiHtml('已发布数据集', `${vm.publishedCount}/${vm.totalOfficial}`, null) +
+ 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} 页`, null, vm.fallbackPages ? 'amb' : 'txt') +
+ 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) note += `
${esc(d.err)}`;
+ 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('')}
`;
}
@@ -373,20 +406,30 @@ function observatoryHtml(vm) {
`${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}
`;
+ ``).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 = vm.siteImpact.length ? vm.siteImpact.map((s) => `` +
- `| ${esc(s.page)} | ` +
- `${pillHtml(s.bucket)} | ` +
+ const rows = bad.length ? bad.slice(0, 5).map((s) => `
` +
+ `| ${esc(s.page)} | ` +
+ `${pillHtml(s.bucket)} | ` +
`${s.note ? esc(s.note) : '—'} | ` +
` | ` +
- `
`).join('') : `| 尚无血缘记录 |
`;
- return panelHtml('主站影响', right, ``, false);
+ ``).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;
@@ -422,17 +465,21 @@ function timelineHtml(vm) {
s += `` +
`` +
`NOW`;
- const right = `现在 ${p2(now.getHours())}:${p2(now.getMinutes())} · SCHEDULER.PY`;
+ 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)}
` +
- `
${esc(it.meta)}
` +
+ `
${meta}
` +
`
` +
`
` +
`
`;
@@ -447,44 +494,62 @@ function overviewHtml() {
}
/* =====================================================================
- 数据源配置页
+ 数据源配置页(分组接口表照抄打样 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 hidx = healthIndex();
- const cidx = callsIndex();
+ 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);
- const usedIfaces = active.reduce((n, c) => n + c.interfaces.filter((i) => (hidx[c.provider] || []).some((h) => h.interface === i.interface)).length, 0);
+ // “在用”= 已登记且已观察到调用(接口名或数据集名匹配,后端 join 已标注)
+ const usedIfaces = active.reduce((n, c) => n + c.interfaces.filter((i) => i.observed).length, 0);
const cards = active.map((c) => {
- const leg = legacy[c.provider] || {};
- const legState = (leg.health && (leg.health.state || leg.health.status)) || null;
- const rows = hidx[c.provider] || [];
- const worst = rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : (legState ? bucketOf(legState) : 'off');
const cred = c.credential || {};
- const lastCall = (cidx[c.provider] || [])[0];
+ 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 ifaceRows = c.interfaces.map((i) => {
- const h = rows.find((r) => r.interface === i.interface);
- return {
- api: i.interface, use: i.capability,
- ok: h ? (h.last_ok_at ? hm(h.last_ok_at) : '—') : '—',
- lat: h ? h.last_latency_ms : null,
- st: h ? bucketOf(h.state) : 'off',
- err: h && h.state !== 'ok' ? (h.last_error || h.last_fallback_reason || '') : '',
- };
+ 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, role: c.role, tag: c.credential_key ? 'licensed' : 'free',
- bucket: worst, credential: cred, usedCount: ifaceRows.filter((r) => r.st !== 'off').length, totalCount: ifaceRows.length,
- lastProbe: lastCall ? `${hm(lastCall.created_at)} · ${lastCall.ok ? `✓ ${lastCall.latency_ms}ms` : `× ${lastCall.error || 'ERROR'}`}` : '—',
- latency: rows.length ? (rows.map((r) => r.last_latency_ms).filter((v) => v != null)[0] || null) : null,
- error: rows.find((r) => r.last_error)?.last_error || '',
- ifaceRows,
+ 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 };
@@ -501,36 +566,48 @@ function sourceCardHtml(card) {
// 靠 note 字段区分,这里必须原样展示 note,不能一律折叠成"已配置"。
const credBadge = card.credential.note ? card.credential.note :
card.credential.configured ? `已配置${card.credential.last4 ? ' · ' + card.credential.last4 : ''}` : '未配置';
- const badges = [credBadge, card.role];
+ 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.tag)}` +
- pillHtml(card.bucket) +
+ `${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} 在用` +
- `|最后探测 ${esc(card.lastProbe)}` +
+ `接口 ${card.usedCount}/${card.totalCount} 在用` +
+ `|最后探测 ${probeCell}` +
`|延迟 ${latHtml(card.latency)}` +
- (card.error ? `|最近错误 ${esc(card.error)}` : `|无最近错误`) +
+ (card.error ? `|最近错误 ${esc(card.error)}` : (card.bucket !== 'off' ? `|无最近错误` : '')) +
`
`;
- if (ui.open && card.ifaceRows.length) {
- h += `` +
+ if (ui.open && card.bucket !== 'off') {
+ h += `
` + card.groups.map((g) => `
` +
+ `
${esc(g.name)}
` +
`
| 接口 | 用途 | 最近成功 | 延迟 | 状态 |
` +
- card.ifaceRows.map((it) => `` +
+ g.rows.map((it) => `
` +
`| ${esc(it.api)} | ` +
`${esc(it.use)} | ` +
- `${esc(it.ok)} | ` +
+ `${esc(it.ok)} | ` +
`${latHtml(it.lat)} | ` +
- `${pillHtml(it.st)}${it.err ? `${esc(it.err)}` : ''} | ` +
- `
`).join('') + `
`;
- } else if (!card.ifaceRows.length) {
- h += `
`;
+ `
${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 + ``;
}
@@ -555,17 +632,57 @@ function sourcesHtml() {
function renderSourceCards() {
const el = $('srcCards');
if (!el) return;
- const vm = buildSourcesVM();
- el.innerHTML = vm.cards.map(sourceCardHtml).join('');
+ 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;
@@ -579,18 +696,12 @@ function buildLineageVM() {
const srcState = {};
const dsState = {};
const pageState = {};
- const pageDatasets = {};
const edgesSD = [];
const edgesDP = [];
const rows = [];
items.forEach((it) => {
- const rowsHealth = it.live_provider_health || [];
- let bucket;
- if (it.tier === 'official') {
- bucket = it.publication ? (it.publication.state === 'published' ? 'ok' : (it.publication.state === 'degraded' ? 'degrade' : 'fail')) : (rowsHealth.length ? worstBucket(rowsHealth.map((r) => bucketOf(r.state))) : 'plan');
- } else {
- bucket = rowsHealth.length ? worstBucket(rowsHealth.map((r) => bucketOf(r.state))) : 'off';
- }
+ 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))]);
@@ -601,16 +712,16 @@ function buildLineageVM() {
pages.forEach((pg) => {
pageState[pg] = worstBucket([pageState[pg] || 'ok', bucket]);
edgesDP.push([it.dataset, pg]);
- (pageDatasets[pg] = pageDatasets[pg] || []).push(it);
});
- const lat = rowsHealth.map((r) => r.last_latency_ms).find((v) => v != null);
- const ok = it.tier === 'official' && it.publication ? it.publication.published_at : (rowsHealth.find((r) => r.last_ok_at) || {}).last_ok_at;
rows.push({
page: pages.join(' / ') || '—', item: consumers.join(' / '), dataset: it.dataset,
via: `${it.primary_source}${it.backup_source ? ' → ' + it.backup_source : ''}`,
role: it.backup_source ? '主/备' : (it.tier === 'licensed' ? '主(授权)' : '主(无备)'),
- ok: ok ? hm(ok) : '—', lat, st: bucket,
- note: rowsHealth.find((r) => r.last_error)?.last_error || rowsHealth.find((r) => r.last_fallback_reason)?.last_fallback_reason || (it.publication && it.publication.state !== 'published' ? '未正式发布' : ''),
+ 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] }));
@@ -641,6 +752,7 @@ function lineageGraphHtml(vm) {
if (vm.datasets.length) h += `数据集 · ${vm.datasets.length}`;
if (vm.pages.length) h += `主站页面 · ${vm.pages.length}`;
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 += ``;
@@ -716,35 +828,45 @@ function renderLinRows(vm) {
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.ok)} | ` +
- `${latHtml(r.lat)} | ` +
- `${pillHtml(r.st)}${r.note ? `${esc(r.note)}` : ''} | ` +
- `
`).join('') || `| 无匹配记录 · 调整筛选条件 |
`;
+ `${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.ok)}${r.lat != null ? r.lat + 'ms' : '—'} | ` +
- `${pillHtml(r.st)}${r.note ? `${esc(r.note)}` : ''} | ` +
- `
`).join('') || `| 无匹配记录 · 调整筛选条件 |
`;
+ `${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 anomalyPages = new Set();
- vm.rows.forEach((r) => { if (r.st === 'fail') (r.page || '').split(' / ').forEach((p) => anomalyPages.add(p)); });
+ 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')}未配置` +
`` +
- (anomalyPages.size ? tagHtml('rd', `${[...anomalyPages][0]} 断链影响 ${anomalyPages.size} 页`) : tagHtml('mint', '暂无断链影响')) +
+ impactTag +
`
` +
`
` +
`
` +
- `` +
+ `` +
`` +
``;
- window.__linVM = vm;
return html;
}
@@ -775,16 +896,19 @@ function renderPage(p) {
state.page = p;
mainEl.dataset.page = p;
document.querySelectorAll('[data-nav]').forEach((b) => b.classList.toggle('act', b.dataset.nav === p));
- if (p === 'overview') mainEl.innerHTML = overviewHtml();
- else if (p === 'sources') mainEl.innerHTML = sourcesHtml();
+ if (p === 'overview') { mainEl.innerHTML = overviewHtml(); paintSparks(); flashChangedLatencies(); }
+ else if (p === 'sources') { mainEl.innerHTML = sourcesHtml(); paintSparks(); }
else {
mainEl.innerHTML = lineageHtml();
- renderLinRows(window.__linVM);
- applyLinHover(window.__linVM);
+ 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()));
@@ -795,11 +919,10 @@ window.addEventListener('resize', () => {
resizeT = setTimeout(() => { if (state.page === 'lineage') renderPage('lineage'); }, 180);
});
-/* HEL-529: 血缘详情表随真实数据集数量(当前 17 条,非打样假设的 13 条)
- 动态撑高;宽档(>1100px,覆盖 1920×920/1440×900 验收位)给表格容器一个
- 基于剩余可视高度计算的 max-height + 内部滚动,保证整页 scrollHeight
- 始终 <= clientHeight(硬性验收项),同时全部行仍可在容器内滚动查看。
- 窄档(<=1100px)保持原有整页纵向滚动,不设上限。 */
+/* 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');
@@ -816,6 +939,8 @@ function fitLineageTable() {
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 || '')}`;
}
@@ -824,6 +949,11 @@ mainEl.addEventListener('click', (e) => {
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]');
@@ -846,14 +976,35 @@ async function doProbe(id) {
if (!ui || ui.probing) return;
ui.probing = true;
renderSourceCards();
- try { await api(`/admin/api/sources/${id}/probe`, { method: 'POST', body: '{}' }); } catch (err) { toast(err.message, 'err'); }
+ 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, first: true };
+/* ---------- 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 }); });
@@ -867,16 +1018,25 @@ function tapeEventsFromRaw() {
tapeSeen.job = Math.max(tapeSeen.job, maxOf((RAW.jobs && RAW.jobs.runs) || [], 'id'));
return evs;
}
-let tapeBuf = [];
+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 (tapeSeen.first) { tapeSeen.first = false; return; } // 首次加载只建基线,不补播历史事件
if (!evs.length) return;
+ const fresh = new Set(evs.map((e) => e.sortId));
tapeBuf = tapeBuf.concat(evs).slice(-16);
- const html = tapeBuf.map((e) => `${esc(hm(e.ts))}${esc(e.src)}${esc(e.msg)}` +
- (e.ok ? `✓${e.ms ? ` ${e.ms}ms` : ''}` : '× 失败') +
- `///`).join('');
- $('tapeA').innerHTML = html; $('tapeB').innerHTML = html;
+ renderTape(fresh);
}
/* ---------- 抽屉:调度任务 / 盘后发布 / 审计 ---------- */
@@ -1011,18 +1171,22 @@ function toast(msg, kind) {
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();
- $('ckD').textContent = `${now.getFullYear()}-${p2(now.getMonth() + 1)}-${p2(now.getDate())}`;
- $('ckH').textContent = p2(now.getHours());
- $('ckM').textContent = p2(now.getMinutes());
- $('ckS').textContent = p2(now.getSeconds());
+ 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;
- // 挂在 #app(覆盖 appRoot 及其兄弟 drawer/modal/toast),不能只挂 #appRoot:
- // 否则 .reduce-motion * 与 .reduce-motion .drawer/.toast 选不中旁路层过渡。
+ // 挂在 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);
@@ -1037,7 +1201,62 @@ if (window.matchMedia) {
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;
@@ -1047,9 +1266,32 @@ const Poller = (() => {
try {
await fetchAll();
updateTape();
- if (state.page === 'overview') mainEl.innerHTML = overviewHtml();
- else if (state.page === 'sources') renderSourceCards();
- else if (state.page === 'lineage') { /* 血缘图交互态复杂,仅在用户操作或下次轮询后整体重绘一次 */ mainEl.innerHTML = lineageHtml(); renderLinRows(window.__linVM); applyLinHover(window.__linVM); fitLineageTable(); }
+ 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) {
@@ -1072,10 +1314,39 @@ const Poller = (() => {
};
})();
+/* 真实调用脉冲: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;
- tapeSeen.first = true;
}
document.addEventListener('visibilitychange', () => {
if (document.hidden) { Poller.stop(); }
@@ -1090,11 +1361,15 @@ async function refreshQuiet() {
/* ================= 启动 ================= */
async function startApp() {
- setInterval(updateClock, 1000);
- updateClock();
await fetchAll();
- tapeSeen.first = true;
+ 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();
}
diff --git a/xiaobai-datahub/admin/index.html b/xiaobai-datahub/admin/index.html
index a9493c2..1c9e5a7 100644
--- a/xiaobai-datahub/admin/index.html
+++ b/xiaobai-datahub/admin/index.html
@@ -46,8 +46,10 @@
+
+
- 小白复盘 · 数据中枢DATA-HUB · HEL-529
+ 小白复盘 · 数据中枢DATA-HUB