From 3bafd30aad18525d6b9cd8610307457920ac6811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=BD=E5=B7=A5=E5=91=982=E5=8F=B7?= Date: Tue, 15 Sep 2026 08:57:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(HEL-529):=20=E6=8C=89=E5=AE=9A=E7=A8=BF100?= =?UTF-8?q?%=E9=87=8D=E5=81=9A=E4=B8=89=E9=A1=B5=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E4=B8=AD=E6=9E=A2=20+=20=E6=95=B0=E6=8D=AE=E4=BF=AE=E6=AD=A31-?= =?UTF-8?q?5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 视觉: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 --- xiaobai-datahub/admin/app.js | 639 ++++++++++++++------ xiaobai-datahub/admin/index.html | 8 +- xiaobai-datahub/admin/styles.css | 8 + xiaobai-datahub/datahub/admin_api.py | 18 +- xiaobai-datahub/datahub/lineage.py | 17 + xiaobai-datahub/datahub/pipeline.py | 53 ++ xiaobai-datahub/datahub/source_catalog.py | 89 ++- xiaobai-datahub/tests/test_hel529_rework.py | 213 +++++++ 8 files changed, 826 insertions(+), 219 deletions(-) create mode 100644 xiaobai-datahub/tests/test_hel529_rework.py 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, `${rows}
`, 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 = `
正常 ${vm.okPages}` + + [...byPage.entries()].map(([name, bucket]) => bucket === 'ok' + ? `${esc(name)}·` + : `${esc(name)}(${HEALTH[bucket].label})·`).join('') + + `
`; + return panelHtml('主站影响', right, `${rows}
${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) => `` + `` + `` + - `` + + `` + `` + - `` + - ``).join('') + `
接口用途最近成功延迟状态
${esc(it.api)}${esc(it.use)}${esc(it.ok)}${esc(it.ok)}${latHtml(it.lat)}${pillHtml(it.st)}${it.err ? `${esc(it.err)}` : ''}
`; - } 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 + `
` + `
${lineageGraphHtml(vm)}
` + `
` + @@ -755,11 +877,10 @@ function lineageHtml() { `` + `` + `
` + - `
主站页面数据项数据集主/备最近成功延迟状态
` + - `
主站页面数据集最近成功状态
` + + `
主站页面数据项数据集源 : 接口主/备更新频率最近成功延迟状态
` + + `
主站页面数据集源 : 接口更新频率最近成功状态
` + `` + ``; - 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