diff --git a/xiaobai-datahub/admin/app.js b/xiaobai-datahub/admin/app.js index 0f07ed9..24066d9 100644 --- a/xiaobai-datahub/admin/app.js +++ b/xiaobai-datahub/admin/app.js @@ -1,13 +1,12 @@ 'use strict'; /* ===================================================================== - 小白复盘 · 数据中枢 admin/app.js — HEL-529 返工:按定稿 100% 还原 - 视觉与 DOM 结构逐行照抄已确认打样 hub-kimi.html(v12 血缘压缩增补), + 小白复盘 · 数据中枢 admin/app.js — HEL-529 第十二版 + 三页真实数据中枢:运行总览 / 数据源配置 / 数据血缘。 + 视觉系统逐行照抄已通过打样 hub-kimi.html + SPEC-V11.md(v12 血缘压缩增补), 但本文件不含任何随机/模拟数据——全部状态、数字、事件均来自真实接口: - /admin/api/{overview,sources,jobs,batches,audit,...} 与 HEL-543 只读旁路 - /admin/api/{providers/status,source-catalog,lineage,lineage/affected}。 - 动效遵循 SPEC-V11:marquee 46s 线性 / flowline 1.4s / radar 4.2s / - blink 1.1s steps(2) / ledPing 1.6s / sparkBlink 1.4s / 时钟冒号 blink / - 延迟仅在真实值变化时闪烁(禁止随机伪造数值)/ 缓存年龄每秒真实增长。 + 既有 /admin/api/{overview,sources,jobs,batches,datasets,audit,...} + 与 HEL-543 新增只读旁路 /admin/api/{providers/status,source-catalog, + lineage,lineage/affected}。 ===================================================================== */ /* ================= 基础:DOM helper / API / CSRF ================= */ @@ -137,23 +136,27 @@ function kpiHtml(label, value, sub, tone, live) { const tc = { txt: '', mint: 'glow-mint', amb: 'glow-amb', rd: 'glow-rd', cy: 'glow-cy' }[tone || 'txt']; return `
${label}${value}${live ? '_' : ''}${sub ? `${sub}` : ''}
`; } -/* 延迟:真实值 + 变化时才闪(打样 jit 的真实数据版:不随机抖动数字, - 仅当新一轮真实调用改写该值时给 .flash 青色闪烁)。 */ +function radarBlipsHtml(pulseOk) { + return pulseOk + ? `
` + : ''; +} function latHtml(ms, warn) { if (ms == null) return ''; const color = ms > 1000 ? '#fbbf24' : (warn ? '#fbbf24' : '#d7e1f0'); - return `${Number(ms).toLocaleString('en-US')}ms`; + 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)); } -/* 缓存年龄:记录真实时间戳,由 updateDynamics 每秒重算(真实增长)。 */ function ageHtml(iso) { - const ms = Date.parse(String(iso || '').includes('T') ? iso : String(iso || '').replace(' ', 'T')); - if (!iso || Number.isNaN(ms)) return ''; - return ``; -} -function fmtAgeSec(s) { - if (s == null) return '—'; - // 打样口径:缓存年龄一律按秒展示、每秒增长(>30s 变琥珀) - return `${Number(s).toLocaleString('en-US')}s`; + 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}`; } function hm(iso) { if (!iso) return '—'; @@ -164,66 +167,13 @@ 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, audit: null, + lineage: null, jobs: null, batches: null, datasets: null, audit: null, }; async function fetchAll() { - const [overview, sources, providers, catalog, lineage, jobs, batches, audit] = await Promise.all([ + const [overview, sources, providers, catalog, lineage, jobs, batches, datasets, audit] = await Promise.all([ api('/admin/api/overview'), api('/admin/api/sources'), api('/admin/api/providers/status?limit=200'), @@ -231,14 +181,31 @@ 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.audit = audit; - buildLatHist(); + RAW.lineage = lineage; RAW.jobs = jobs; RAW.batches = batches; RAW.datasets = datasets; RAW.audit = audit; } function today() { return RAW.overview ? RAW.overview.trade_date : ''; } +/* 按 provider(+interface) 索引 provider_health,供各页复用 */ +function healthIndex() { + const idx = {}; + const rows = (RAW.providers && RAW.providers.health) || []; + for (const row of rows) { + idx[row.provider] = idx[row.provider] || []; + idx[row.provider].push(row); + } + return idx; +} +function callsIndex() { + const idx = {}; + const rows = (RAW.providers && RAW.providers.recent_calls) || []; + for (const row of rows) { (idx[row.provider] = idx[row.provider] || []).push(row); } + return idx; +} + const OFFICIAL_DATASET_IDS = ['daily', 'valuation', 'moneyflow', 'auction', 'index_daily', 'limit_events', 'popularity', 'dragon_tiger', 'sector_daily', 'stocks']; const DATASET_NAME = { daily: '日K', valuation: '估值', moneyflow: '资金流', auction: '竞价', index_daily: '指数日K', @@ -246,7 +213,7 @@ const DATASET_NAME = { }; /* ===================================================================== - 总览页(结构照抄 hub-kimi.html,数据全部真实) + 总览页 ===================================================================== */ function buildOverviewVM() { const ov = RAW.overview || {}; @@ -256,6 +223,7 @@ 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; @@ -263,7 +231,7 @@ function buildOverviewVM() { const successRate = recentCalls.length ? (recentCalls.filter((c) => c.ok).length / recentCalls.length * 100) : null; // 主站回退页面:主源异常且备源健康 → 判定为“回退中”的 provisional 数据集 - const fallbackPages = []; + let fallbackPages = 0; (RAW.lineage && RAW.lineage.items || []).forEach((it) => { if (it.tier !== 'provisional' || !it.backup_source) return; const rows = it.live_provider_health || []; @@ -271,9 +239,7 @@ 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') { - (it.known_consumers || []).forEach((c) => fallbackPages.push(c)); - } + if ((primaryState === 'fail' || primaryState === 'degrade' || primaryState === 'slow') && backupState === 'ok') fallbackPages += 1; }); const datasetCards = OFFICIAL_DATASET_IDS.map((id) => { @@ -291,31 +257,37 @@ function buildOverviewVM() { return { id, name: DATASET_NAME[id] || id, bucket, time: note && !batch?.error ? note : '', rows, err: batch && batch.error ? batch.error : '' }; }); - // 实时观察层:eastmoney / tencent 目录接口的真实观测(observed=true) + // 实时观察层:eastmoney / tencent 的真实 provider_health 行 const catalogByProv = {}; (RAW.catalog && RAW.catalog.items || []).forEach((it) => { catalogByProv[it.provider] = it; }); const observers = []; ['eastmoney', 'tencent'].forEach((prov) => { + const rows = hidx[prov] || []; const cat = catalogByProv[prov]; - if (!cat) return; - cat.interfaces.forEach((iface) => { - if (!iface.observed) return; + rows.forEach((row) => { + const iface = (cat && cat.interfaces.find((i) => i.interface === row.interface)) || {}; observers.push({ - jid: `${prov}:${iface.interface}`, - name: iface.capability, provider: prov, interface: iface.interface, - lastOk: iface.observed_at, lat: iface.observed_latency_ms, - bucket: bucketOf(iface.observed_state), - note: iface.observed_note || '', + name: iface.capability || row.interface, + provider: prov, + interface: row.interface, + lastOk: row.last_ok_at, + lat: row.last_latency_ms, + bucket: bucketOf(row.state), + note: row.last_error || row.last_fallback_reason || '', }); }); }); - // 主站影响:lineage() 条目按 known_consumers 展开(接口级状态) + // 主站影响:lineage() 条目按 known_consumers 展开 const siteImpact = []; (RAW.lineage && RAW.lineage.items || []).forEach((it) => { - const bucket = lineageItemBucket(it); + const rows = it.live_provider_health || []; + const bucket = it.tier === 'official' + ? (it.publication ? (it.publication.state === 'published' ? 'ok' : (it.publication.state === 'degraded' ? 'degrade' : 'fail')) : (rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'plan')) + : (rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'off'); + const note = rows.find((r) => r.last_error)?.last_error || rows.find((r) => r.last_fallback_reason)?.last_fallback_reason || ''; (it.known_consumers || []).forEach((c) => { - siteImpact.push({ page: c, short: pageNameOf(c), dataset: it.dataset, bucket, note: lineageItemNote(it) }); + siteImpact.push({ page: c, dataset: it.dataset, bucket, note }); }); }); const okPages = siteImpact.filter((s) => s.bucket === 'ok').length; @@ -332,16 +304,14 @@ 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: `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 }); + 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] || '' }); } - 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 }); @@ -355,7 +325,6 @@ 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'), }; } @@ -366,7 +335,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(`下次重试 --:--`); + if (vm.eod.next_retry_at) subParts.push(`下次重试 ${hm(vm.eod.next_retry_at)}`); 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 `
` + @@ -376,25 +345,23 @@ function heroHtml(vm) { (subParts.length ? `
${subParts.join('|')}
` : '') + `
` + `
` + - kpiHtml('已发布数据集', `${vm.publishedCount}/${vm.totalOfficial}`, vm.unpublished.length ? `${vm.unpublished.map((d) => DATASET_NAME[d] || d).join(' · ')} 未发布` : '全部已发布') + + kpiHtml('已发布数据集', `${vm.publishedCount}/${vm.totalOfficial}`, null) + kpiHtml('今日调用成功率', vm.successRate == null ? '' : `${vm.successRate.toFixed(1)}%`, null, 'mint', vm.successRate != null) + - kpiHtml('主站回退页面', `${vm.fallbackPages.length} 页`, vm.fallbackPages.length ? esc(vm.fallbackPages[0]) : '实时层主源正常', vm.fallbackPages.length ? 'amb' : 'txt') + + kpiHtml('主站回退页面', `${vm.fallbackPages} 页`, null, vm.fallbackPages ? 'amb' : 'txt') + kpiHtml('EOD 尝试', `${vm.eod.attempts || 0} 次`, '上限 5 · 23:30 止', vm.eod.attempts ? 'rd' : 'txt') + `
`; } function datasetStripHtml(vm) { return `
${vm.datasetCards.map((d) => { const ring = d.bucket === 'fail' ? 'fail' : d.bucket === 'degrade' ? 'slow' : d.bucket === 'review' ? 'review' : ''; - const sparkTone = d.bucket === 'fail' ? 'rd' : d.bucket === 'degrade' ? 'amb' : d.bucket === 'review' ? 'cy' : 'mint'; let note = ''; if (d.time) note += `${esc(d.time)} · `; if (d.rows) note += `${Number(d.rows).toLocaleString('en-US')} 行`; - if (d.err) { const brief = String(d.err).length > 22 ? String(d.err).slice(0, 22) + '…' : d.err; note += `${esc(brief)}`; } + if (d.err) note += `${esc(d.err)}`; if (!d.time && !d.rows && !d.err) note = '今日尚未发布'; return `
` + `
${esc(d.name)}${pillHtml(d.bucket)}
` + `
${note}
` + - sparkHtml(`ds:${d.id}`, sparkTone, 96, 16) + `
`; }).join('')}
`; } @@ -406,30 +373,20 @@ 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 = bad.length ? bad.slice(0, 5).map((s) => `` + - `${esc(s.page)}` + - `${pillHtml(s.bucket)}` + + const rows = vm.siteImpact.length ? vm.siteImpact.map((s) => `` + + `${esc(s.page)}` + + `${pillHtml(s.bucket)}` + `${s.note ? esc(s.note) : '—'}` + `` + - ``).join('') : `全部页面正常${pillHtml('ok')}—`; - const footPages = vm.siteImpact.map((s) => ({ name: s.short, bucket: s.bucket })); - const byPage = new Map(); - footPages.forEach((p) => { const cur = byPage.get(p.name); if (!cur || SEV[p.bucket] > SEV[cur]) byPage.set(p.name, p.bucket); }); - const foot = `
正常 ${vm.okPages}` + - [...byPage.entries()].map(([name, bucket]) => bucket === 'ok' - ? `${esc(name)}·` - : `${esc(name)}(${HEALTH[bucket].label})·`).join('') + - `
`; - return panelHtml('主站影响', right, `${rows}
${foot}`, false); + ``).join('') : `尚无血缘记录`; + return panelHtml('主站影响', right, `${rows}
`, false); } function timelineHtml(vm) { const nodes = vm.timeline; @@ -465,21 +422,17 @@ function timelineHtml(vm) { s += `` + `` + `NOW`; - const range = nodes.length ? `${nodes[0].t} → ${nodes[nodes.length - 1].t}` : ''; - const right = `现在 ${p2(now.getHours())}:${p2(now.getMinutes())} · ${range} · SCHEDULER.PY`; + const right = `现在 ${p2(now.getHours())}:${p2(now.getMinutes())} · SCHEDULER.PY`; return panelHtml('今晚时间线', right, s, false); } function incidentsHtml(vm) { if (!vm.incidents.length) return panelHtml('待处理异常', tagHtml('mint', '0 OPEN'), '
当前无待处理异常
', true); const cards = vm.incidents.map((it) => { const tag = it.sev === 'fail' ? tagHtml('rd', esc(it.tag)) : it.sev === 'slow' ? tagHtml('amb', esc(it.tag)) : tagHtml('dashed', esc(it.tag)); - const meta = it.cd - ? `${it.meta} · 下次重试 --m--s` - : it.meta; return `
${tag}` + `
` + `
${esc(it.title)}
` + - `
${meta}
` + + `
${esc(it.meta)}
` + `
` + `` + `
`; @@ -494,62 +447,44 @@ 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 cidx = {}; - ((RAW.providers && RAW.providers.recent_calls) || []).forEach((c) => { if (!cidx[c.provider]) cidx[c.provider] = c; }); + const hidx = healthIndex(); + const cidx = callsIndex(); const active = catalog.filter((c) => c.role !== 'reserved'); const reserved = catalog.filter((c) => c.role === 'reserved'); const totalIfaces = active.reduce((n, c) => n + c.interfaces.length, 0); - // “在用”= 已登记且已观察到调用(接口名或数据集名匹配,后端 join 已标注) - const usedIfaces = active.reduce((n, c) => n + c.interfaces.filter((i) => i.observed).length, 0); + const usedIfaces = active.reduce((n, c) => n + c.interfaces.filter((i) => (hidx[c.provider] || []).some((h) => h.interface === i.interface)).length, 0); const cards = active.map((c) => { + const leg = legacy[c.provider] || {}; + const legState = (leg.health && (leg.health.state || leg.health.status)) || null; + const rows = hidx[c.provider] || []; + const worst = rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : (legState ? bucketOf(legState) : 'off'); const cred = c.credential || {}; - const 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]; + const lastCall = (cidx[c.provider] || [])[0]; if (!(c.provider in srcUiState)) srcUiState[c.provider] = { open: true, probing: false, probeResult: null }; - const groups = []; - const groupMap = new Map(); - c.interfaces.forEach((i) => { - const g = i.group || '其他'; - if (!groupMap.has(g)) groupMap.set(g, []); - groupMap.get(g).push(i); + 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 || '') : '', + }; }); - groupMap.forEach((items, name) => groups.push({ name, items })); - const ifaceRows = (group) => group.items.map((i) => ({ - api: i.interface, use: i.capability, - ok: i.observed ? (i.observed_at ? hm(i.observed_at) : '—') : '—', - lat: i.observed ? i.observed_latency_ms : null, - st: i.observed ? bucketOf(i.observed_state) : (configured ? 'plan' : 'off'), - obs: i.observed, basis: i.observed_basis, - err: i.observed && i.observed_state !== 'ok' ? (i.observed_note || '') : '', - })); return { - id: c.provider, name: c.label, roleText: PROV_ROLE_TEXT[c.provider] || c.role, - tag: c.credential_key ? 'licensed' : 'free', - bucket, credential: cred, - usedCount: c.interfaces.filter((i) => i.observed).length, totalCount: c.interfaces.length, - lastProbe: lastCall ? `${hm(lastCall.fetched_at)} ${lastCall.status === 'ok' ? `✓ ${lastCall.latency_ms ?? ''}ms` : `× ${lastCall.error || 'ERROR'}`}` : '—', - latency: observedRows.length ? (observedRows.map((r) => r.observed_latency_ms).filter((v) => v != null)[0] || null) : null, - error: observedRows.find((r) => r.observed_state !== 'ok' && r.observed_note)?.observed_note || '', - groups: groups.map((g) => ({ name: g.name, rows: ifaceRows(g) })), + id: c.provider, name: c.label, role: c.role, tag: c.credential_key ? 'licensed' : 'free', + bucket: worst, credential: cred, usedCount: ifaceRows.filter((r) => r.st !== 'off').length, totalCount: ifaceRows.length, + lastProbe: lastCall ? `${hm(lastCall.created_at)} · ${lastCall.ok ? `✓ ${lastCall.latency_ms}ms` : `× ${lastCall.error || 'ERROR'}`}` : '—', + latency: rows.length ? (rows.map((r) => r.last_latency_ms).filter((v) => v != null)[0] || null) : null, + error: rows.find((r) => r.last_error)?.last_error || '', + ifaceRows, }; }); return { cards, reserved, totalIfaces, usedIfaces }; @@ -566,48 +501,36 @@ 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]; - 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' ? '实时层备' : '实时层主'))); + const badges = [credBadge, card.role]; let h = `
`; h += `
` + ledHtml(ledSt, card.bucket === 'fail') + `${esc(card.name)}` + - `${esc(card.roleText)}` + - pillHtml(card.bucket, card.bucket === 'plan' ? '已配置 · 待观测' : '') + + `${esc(card.tag)}` + + pillHtml(card.bucket) + `${badges.map((b) => tagHtml(card.bucket === 'off' ? 'dashed' : 'dim', esc(b))).join('')}` + `` + - `` + - (card.bucket === 'off' - ? `` - : ``) + + `` + + `` + `
`; - const probeCell = ui.probeResult - ? `${ui.probeResult.t} ${ui.probeResult.ok ? `✓ ${ui.probeResult.ms}ms` : '× TIMEOUT'}` - : `${esc(card.lastProbe)}`; h += `
` + - `接口 ${card.usedCount}/${card.totalCount} 在用` + - `|最后探测 ${probeCell}` + + `接口 ${card.usedCount}/${card.totalCount} 在用` + + `|最后探测 ${esc(card.lastProbe)}` + `|延迟 ${latHtml(card.latency)}` + - (card.error ? `|最近错误 ${esc(card.error)}` : (card.bucket !== 'off' ? `|无最近错误` : '')) + + (card.error ? `|最近错误 ${esc(card.error)}` : `|无最近错误`) + `
`; - if (ui.open && card.bucket !== 'off') { - h += `
` + card.groups.map((g) => `
` + - `
${esc(g.name)}
` + + if (ui.open && card.ifaceRows.length) { + h += `
` + `` + - g.rows.map((it) => `` + + card.ifaceRows.map((it) => `` + `` + `` + - `` + + `` + `` + - `` + - ``).join('') + - `
接口用途最近成功延迟状态
${esc(it.api)}${esc(it.use)}${esc(it.ok)}${esc(it.ok)}${latHtml(it.lat)}${pillHtml(it.st, it.st === 'plan' && it.obs === false ? '已配置 · 待观测' : '')}${it.err ? `${esc(it.err)}` : ''}
`).join('') + `
`; - } - if (card.bucket === 'off') { - h += `
凭证未配置 · ${card.totalCount} 个预留接口处于停用状态 · 配置后将成为图表 / 问财 / 实时快照主源` + - `
${card.groups[0].rows.map((i) => tagHtml('dashed', esc(i.api))).join('')}
` + - `
`; + `${pillHtml(it.st)}${it.err ? `${esc(it.err)}` : ''}` + + ``).join('') + `
`; + } else if (!card.ifaceRows.length) { + h += `
该来源尚无已登记接口
`; } return h + `
`; } @@ -632,57 +555,17 @@ function sourcesHtml() { function renderSourceCards() { const el = $('srcCards'); if (!el) return; - el.innerHTML = buildSourcesVM().cards.map(sourceCardHtml).join(''); - paintSparks(); + const vm = buildSourcesVM(); + el.innerHTML = vm.cards.map(sourceCardHtml).join(''); } /* ===================================================================== - 数据血缘页(接口级状态 + 已配置/待观测 + 真实频率 + 事件脉冲) + 数据血缘页 ===================================================================== */ function pageNameOf(consumer) { const idx = consumer.indexOf('('); return (idx > 0 ? consumer.slice(0, idx) : consumer).trim(); } -/* 一条血缘项的服务接口集合(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; @@ -696,12 +579,18 @@ function buildLineageVM() { const srcState = {}; const dsState = {}; const pageState = {}; + const pageDatasets = {}; const edgesSD = []; const edgesDP = []; const rows = []; items.forEach((it) => { - const rowsHealth = lineageItemRowsHealth(it); - const bucket = lineageItemBucket(it); + const rowsHealth = it.live_provider_health || []; + let bucket; + if (it.tier === 'official') { + bucket = it.publication ? (it.publication.state === 'published' ? 'ok' : (it.publication.state === 'degraded' ? 'degrade' : 'fail')) : (rowsHealth.length ? worstBucket(rowsHealth.map((r) => bucketOf(r.state))) : 'plan'); + } else { + bucket = rowsHealth.length ? worstBucket(rowsHealth.map((r) => bucketOf(r.state))) : 'off'; + } dsState[it.dataset] = bucket; (it.providers || []).forEach((p) => { srcState[p] = worstBucket([srcState[p] || 'ok', ...rowsHealth.filter((r) => r.provider === p).map((r) => bucketOf(r.state))]); @@ -712,16 +601,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' ? '主(授权)' : '主(无备)'), - 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), + ok: ok ? hm(ok) : '—', lat, st: bucket, + note: rowsHealth.find((r) => r.last_error)?.last_error || rowsHealth.find((r) => r.last_fallback_reason)?.last_fallback_reason || (it.publication && it.publication.state !== 'published' ? '未正式发布' : ''), }); }); const sources = Object.keys(srcState).map((id) => ({ id, name: PROV_LABEL[id] || id, st: srcState[id] })); @@ -752,7 +641,6 @@ 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 += ``; @@ -828,45 +716,35 @@ 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.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('') || `无匹配记录 · 调整筛选条件`; + `${esc(r.ok)}` + + `${latHtml(r.lat)}` + + `${pillHtml(r.st)}${r.note ? `${esc(r.note)}` : ''}` + + ``).join('') || `无匹配记录 · 调整筛选条件`; if (bodyN) bodyN.innerHTML = rows.map((r) => `` + `${esc(r.page)}${esc(r.item)}` + `${esc(r.dataset)}` + `${esc(r.via)}` + - `${esc(r.freq)}${esc(r.role)}` + - `${r.ok ? esc(hm(r.ok)) : '—'}${r.lat != null ? r.lat + 'ms' : '—'}` + - `${pillHtml(r.st, stLabel(r))}${r.note && !stLabel(r) ? `${esc(r.note)}` : ''}` + - ``).join('') || `无匹配记录 · 调整筛选条件`; + `${esc(r.ok)}${r.lat != null ? r.lat + 'ms' : '—'}` + + `${pillHtml(r.st)}${r.note ? `${esc(r.note)}` : ''}` + + ``).join('') || `无匹配记录 · 调整筛选条件`; } function lineageHtml() { const vm = buildLineageVM(); - const failDs = vm.datasets.filter((d) => d.st === 'fail'); - let impactTag; - if (failDs.length) { - const pages = new Set(); - failDs.forEach((d) => { (adj.dp.get(d.id) || new Set()).forEach((p) => pages.add(p)); }); - impactTag = tagHtml('rd', `${failDs.map((d) => d.id).join('/')} 断链影响 ${pages.size} 页`); - } else { - impactTag = tagHtml('mint', '暂无断链影响'); - } + const anomalyPages = new Set(); + vm.rows.forEach((r) => { if (r.st === 'fail') (r.page || '').split(' / ').forEach((p) => anomalyPages.add(p)); }); const html = `
` + `
` + `血缘地图` + `${vm.sources.length} 源 → ${vm.datasets.length} 数据集 → ${vm.pages.length} 页面` + `${ledHtml('ok')}健康${ledHtml('slow')}慢/降级${ledHtml('fail')}失败${ledHtml('plan')}计划${ledHtml('off')}未配置` + `` + - impactTag + + (anomalyPages.size ? tagHtml('rd', `${[...anomalyPages][0]} 断链影响 ${anomalyPages.size} 页`) : tagHtml('mint', '暂无断链影响')) + `
` + `
${lineageGraphHtml(vm)}
` + `
` + @@ -877,10 +755,11 @@ function lineageHtml() { `` + `` + `
` + - `
主站页面数据项数据集源 : 接口主/备更新频率最近成功延迟状态
` + - `
主站页面数据集源 : 接口更新频率最近成功状态
` + + `
主站页面数据项数据集主/备最近成功延迟状态
` + + `
主站页面数据集最近成功状态
` + `` + ``; + window.__linVM = vm; return html; } @@ -896,19 +775,16 @@ function renderPage(p) { state.page = p; mainEl.dataset.page = p; document.querySelectorAll('[data-nav]').forEach((b) => b.classList.toggle('act', b.dataset.nav === p)); - if (p === 'overview') { mainEl.innerHTML = overviewHtml(); paintSparks(); flashChangedLatencies(); } - else if (p === 'sources') { mainEl.innerHTML = sourcesHtml(); paintSparks(); } + if (p === 'overview') mainEl.innerHTML = overviewHtml(); + else if (p === 'sources') mainEl.innerHTML = sourcesHtml(); else { mainEl.innerHTML = lineageHtml(); - const vm = buildLineageVM(); - window.__linVM = vm; - renderLinRows(vm); - applyLinHover(vm); + renderLinRows(window.__linVM); + applyLinHover(window.__linVM); const svg = $('linSvg'); if (svg) svg.addEventListener('mouseleave', () => setLinHover(null, window.__linVM)); fitLineageTable(); } - window.scrollTo(0, 0); updatePhaseTag(); } window.addEventListener('hashchange', () => renderPage(pageFromHash())); @@ -919,10 +795,11 @@ window.addEventListener('resize', () => { resizeT = setTimeout(() => { if (state.page === 'lineage') renderPage('lineage'); }, 180); }); -/* HEL-529: 血缘详情表随真实数据集数量动态撑高;宽档(>1100px,覆盖 - 1920×920/1440×900 验收位)给表格容器一个基于剩余可视高度计算的 - max-height + 内部滚动,保证整页 scrollHeight 始终 <= clientHeight, - 同时全部行仍可在容器内滚动查看。窄档(<=1100px)整页纵滚。 */ +/* HEL-529: 血缘详情表随真实数据集数量(当前 17 条,非打样假设的 13 条) + 动态撑高;宽档(>1100px,覆盖 1920×920/1440×900 验收位)给表格容器一个 + 基于剩余可视高度计算的 max-height + 内部滚动,保证整页 scrollHeight + 始终 <= clientHeight(硬性验收项),同时全部行仍可在容器内滚动查看。 + 窄档(<=1100px)保持原有整页纵向滚动,不设上限。 */ function fitLineageTable() { const wrap = document.querySelector('.lin-scroll'); const tape = document.querySelector('.tape'); @@ -939,8 +816,6 @@ 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 || '')}`; } @@ -949,11 +824,6 @@ 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]'); @@ -976,35 +846,14 @@ async function doProbe(id) { if (!ui || ui.probing) return; ui.probing = true; renderSourceCards(); - try { - await api(`/admin/api/sources/${id}/probe`, { method: 'POST', body: '{}' }); - ui.probeResult = { t: `${p2(new Date().getHours())}:${p2(new Date().getMinutes())}`, ok: true, ms: null }; - } catch (err) { - ui.probeResult = { t: `${p2(new Date().getHours())}:${p2(new Date().getMinutes())}`, ok: false, ms: null }; - toast(err.message, 'err'); - } + try { await api(`/admin/api/sources/${id}/probe`, { method: 'POST', body: '{}' }); } catch (err) { toast(err.message, 'err'); } ui.probing = false; await refreshQuiet(); renderSourceCards(); } -/* ---------- EVENT TAPE:预装最近真实事件 + 持续滚动 + 新事件闪动 ---------- */ -const tapeSeen = { call: 0, provCall: 0, audit: 0, job: 0 }; -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; -} +/* ---------- EVENT TAPE:真实事件差量渲染 ---------- */ +const tapeSeen = { call: 0, provCall: 0, audit: 0, job: 0, first: true }; function tapeEventsFromRaw() { const evs = []; ((RAW.overview && RAW.overview.recent_calls) || []).forEach((c) => { if (c.id > tapeSeen.call) evs.push({ ts: c.created_at, src: 'tushare', msg: c.endpoint, ok: c.ok, ms: c.latency_ms, sortId: 'c' + c.id }); }); @@ -1018,25 +867,16 @@ function tapeEventsFromRaw() { tapeSeen.job = Math.max(tapeSeen.job, maxOf((RAW.jobs && RAW.jobs.runs) || [], 'id')); return evs; } -function seedTape() { - // 首次加载:预装最近的真实事件(打样 EVENT TAPE 开场即有内容滚动) - const evs = []; - ((RAW.providers && RAW.providers.recent_calls) || []).forEach((c) => evs.push({ ts: c.fetched_at, src: c.provider, msg: c.interface, ok: c.status === 'ok', ms: c.latency_ms, sortId: 'p' + c.id })); - ((RAW.overview && RAW.overview.recent_calls) || []).forEach((c) => evs.push({ ts: c.created_at, src: 'tushare', msg: c.endpoint, ok: c.ok, ms: c.latency_ms, sortId: 'c' + c.id })); - ((RAW.jobs && RAW.jobs.runs) || []).forEach((r) => evs.push({ ts: r.finished_at || r.started_at, src: 'scheduler', msg: `${r.job_id} ${r.state}`, ok: r.state !== 'failed', sortId: 'j' + r.id })); - ((RAW.audit && RAW.audit.items) || []).forEach((a) => evs.push({ ts: a.created_at, src: 'hub_admin', msg: `${a.action} ${a.target || ''}`.trim(), ok: true, sortId: 'a' + a.id })); - evs.sort((x, y) => String(x.ts).localeCompare(String(y.ts))); - tapeBuf = evs.slice(-14); - tapeSeeded = true; - renderTape(null); -} +let tapeBuf = []; 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); - renderTape(fresh); + 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; } /* ---------- 抽屉:调度任务 / 盘后发布 / 审计 ---------- */ @@ -1171,22 +1011,18 @@ 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(); - 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; + $('ckD').textContent = `${now.getFullYear()}-${p2(now.getMonth() + 1)}-${p2(now.getDate())}`; + $('ckH').textContent = p2(now.getHours()); + $('ckM').textContent = p2(now.getMinutes()); + $('ckS').textContent = p2(now.getSeconds()); } function applyCalm(on) { state.calm = on; - // 挂在 body + #app(覆盖 appRoot 及其兄弟 drawer/modal/toast),否则 - // .reduce-motion * 选不中旁路层过渡。 + // 挂在 #app(覆盖 appRoot 及其兄弟 drawer/modal/toast),不能只挂 #appRoot: + // 否则 .reduce-motion * 与 .reduce-motion .drawer/.toast 选不中旁路层过渡。 document.body.classList.toggle('reduce-motion', on); const app = $('app'); if (app) app.classList.toggle('reduce-motion', on); @@ -1201,62 +1037,7 @@ 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; @@ -1266,32 +1047,9 @@ const Poller = (() => { try { await fetchAll(); updateTape(); - const vOv = snapshotVersion('overview'), vSrc = snapshotVersion('sources'), vLin = snapshotVersion('lineage'); - if (state.page === 'overview' && vOv !== dataVersion.overview) { - dataVersion.overview = vOv; - mainEl.innerHTML = overviewHtml(); - paintSparks(); - flashChangedLatencies(); - } else if (state.page === 'overview') { - flashChangedLatencies(); - } - if (state.page === 'sources' && vSrc !== dataVersion.sources) { - dataVersion.sources = vSrc; - renderSourceCards(); - } - if (state.page === 'lineage' && vLin !== dataVersion.lineage) { - dataVersion.lineage = vLin; - mainEl.innerHTML = lineageHtml(); - const vm = buildLineageVM(); - window.__linVM = vm; - renderLinRows(vm); - applyLinHover(vm); - const svg = $('linSvg'); - if (svg) svg.addEventListener('mouseleave', () => setLinHover(null, window.__linVM)); - fitLineageTable(); - } - // 真实调用事件脉冲:血缘页上对应来源节点亮一圈 ledPing 节奏的光环 - pulseSourcesOnNewCalls(); + if (state.page === 'overview') mainEl.innerHTML = overviewHtml(); + else if (state.page === 'sources') renderSourceCards(); + else if (state.page === 'lineage') { /* 血缘图交互态复杂,仅在用户操作或下次轮询后整体重绘一次 */ mainEl.innerHTML = lineageHtml(); renderLinRows(window.__linVM); applyLinHover(window.__linVM); fitLineageTable(); } updatePhaseTag(); setNetOk(true); } catch (err) { @@ -1314,39 +1072,10 @@ 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(); } @@ -1361,15 +1090,11 @@ async function refreshQuiet() { /* ================= 启动 ================= */ async function startApp() { - await fetchAll(); - dataVersion.overview = snapshotVersion('overview'); - dataVersion.sources = snapshotVersion('sources'); - dataVersion.lineage = snapshotVersion('lineage'); - pulseSeenCall = ((RAW.providers && RAW.providers.recent_calls) || []).reduce((m, c) => Math.max(m, c.id || 0), 0); - renderPage(pageFromHash()); - updateTape(); // 预装最近真实事件并开始滚动 + setInterval(updateClock, 1000); updateClock(); - setInterval(() => { updateClock(); updateDynamics(); }, 200); // 打样 tick 节奏 + await fetchAll(); + tapeSeen.first = true; + renderPage(pageFromHash()); await Poller.start(); } diff --git a/xiaobai-datahub/admin/index.html b/xiaobai-datahub/admin/index.html index 1c9e5a7..a9493c2 100644 --- a/xiaobai-datahub/admin/index.html +++ b/xiaobai-datahub/admin/index.html @@ -46,10 +46,8 @@
-
- -
小白复盘 · 数据中枢DATA-HUB
+
小白复盘 · 数据中枢DATA-HUB · HEL-529