'use strict'; /* ===================================================================== 小白复盘 · 数据中枢 admin/app.js — HEL-529 返工:按定稿 100% 还原 视觉与 DOM 结构逐行照抄已确认打样 hub-kimi.html(v12 血缘压缩增补), 但本文件不含任何随机/模拟数据——全部状态、数字、事件均来自真实接口: /admin/api/{overview,sources,jobs,batches,audit,...} 与 HEL-543 只读旁路 /admin/api/{providers/status,source-catalog,lineage,lineage/affected}。 动效遵循 SPEC-V11:marquee 46s 线性 / flowline 1.4s / radar 4.2s / blink 1.1s steps(2) / ledPing 1.6s / sparkBlink 1.4s / 时钟冒号 blink / 延迟仅在真实值变化时闪烁(禁止随机伪造数值)/ 缓存年龄每秒真实增长。 ===================================================================== */ /* ================= 基础:DOM helper / API / CSRF ================= */ function $(id) { return document.getElementById(id); } function esc(v) { return String(v ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } const state = { csrf: '', page: 'overview', calm: false, username: '', reviewUrl: '', theme: 'night', // 站点侧数据(模型池 / 会员 / 邀请码)由主站经桥接返回,只在本页缓存 models: null, members: null, invites: null, vendorDraft: {}, freshCodes: {}, modelTests: {}, }; async function api(path, options = {}) { const headers = Object.assign({ 'Content-Type': 'application/json' }, options.headers || {}); if (state.csrf && (options.method || 'GET') !== 'GET') headers['X-CSRF-Token'] = state.csrf; const res = await fetch(path, Object.assign({}, options, { headers, credentials: 'same-origin' })); const body = await res.json().catch(() => ({})); if (!res.ok) { const msg = (body.error && body.error.message) || body.error || res.statusText; const error = new Error(msg); error.status = res.status; error.detail = body; throw error; } return body; } /* ================= 认证:主站会话门禁(数据中枢无独立账号) ================= 浏览器在同一主机不同端口间共享主站的 xiaobai_session cookie,本页只把它 交给 /admin/api/session 由主站校验:未登录 401 → 引导去主站登录页, 非管理员 403 → 明确拒绝,管理员才渲染控制台外壳。 */ function showView(id) { $('gate-view').hidden = id !== 'gate'; $('appRoot').hidden = id !== 'shell'; } function showGate(title, desc, links) { $('gate-title').textContent = title; $('gate-desc').textContent = desc; const login = $('gate-login'); const site = $('gate-site'); login.hidden = !links.loginUrl; if (links.loginUrl) login.href = links.loginUrl; site.hidden = !links.reviewUrl; if (links.reviewUrl) site.href = links.reviewUrl; showView('gate'); } async function boot() { applyTheme(localStorage.getItem('datahub-theme') === 'day' ? 'day' : 'night'); try { const session = await api('/admin/api/session'); state.csrf = session.csrf; state.username = session.username; state.reviewUrl = session.review_url || ''; $('who').textContent = session.username; showView('shell'); await startApp(); } catch (err) { const detail = err.detail || {}; if (err.status === 403) { showGate('仅管理员可进入', '数据中枢只对管理员开放。当前登录账号「' + (detail.username || '未知') + '」没有管理员权限,请用管理员账号登录主站后再进入。', { reviewUrl: state.reviewUrl || '/' }); return; } const loginUrl = detail.login_url || ''; if (loginUrl) state.reviewUrl = loginUrl.replace(/\/login\/?$/, ''); showGate('请先登录小白复盘', '数据中枢使用主站账号,未检测到登录状态。到主站用管理员账号登录后回到本页即可。', { loginUrl: loginUrl }); } } $('gate-retry').addEventListener('click', () => { boot(); }); $('logout-btn').addEventListener('click', async () => { let loginUrl = ''; try { const result = await api('/admin/api/logout', { method: 'POST', body: '{}' }); loginUrl = result.login_url || ''; } catch {} Poller.stop(); showGate('已退出登录', '主站会话已结束。重新登录后可再次进入数据中枢。', { loginUrl: loginUrl }); }); /* ---------- 日间 / 夜间主题(同一组 token 换值,不叠加覆盖样式) ---------- */ function applyTheme(theme) { state.theme = theme === 'day' ? 'day' : 'night'; document.documentElement.dataset.theme = state.theme; localStorage.setItem('datahub-theme', state.theme); const btn = $('themeBtn'); if (btn) btn.textContent = state.theme === 'day' ? '夜间' : '日间'; } $('themeBtn').addEventListener('click', () => applyTheme(state.theme === 'day' ? 'night' : 'day')); /* ================= 状态归类(真实状态字符串 → 7 类视觉桶) ================= 桶: ok / slow / fail / plan / review / degrade / off 来源覆盖:observability.classify_error/classify_rows、CircuitBreaker、 eod/revision progress、batches/publications、job_runs 的全部真实取值。 */ const HEALTH = { ok: { label: '正常', led: 'ok', color: 'var(--mint)' }, slow: { label: '慢', led: 'slow', color: 'var(--amb)' }, fail: { label: '失败', led: 'fail', color: 'var(--rd)' }, plan: { label: '计划', led: 'plan', color: 'var(--mut)' }, review: { label: '复核中', led: 'rev', color: 'var(--cy)' }, degrade: { label: '降级', led: 'slow', color: 'var(--amb)' }, off: { label: '未配置', led: 'off', color: 'var(--dim)' }, }; const STATE_MAP = { ok: 'ok', closed: 'ok', published: 'ok', done: 'ok', aligned: 'ok', half_open: 'slow', waiting_upstream: 'slow', open: 'fail', timeout: 'fail', blocked: 'fail', empty: 'fail', missing_fields: 'fail', error: 'fail', failed: 'fail', cutoff_failed: 'fail', cutoff: 'fail', review_failed: 'fail', degraded: 'degrade', stale: 'degrade', pending_first_attempt: 'plan', pending_publish: 'plan', running: 'review', staged: 'review', waiting_review: 'review', unconfigured: 'off', reserved: 'off', unknown: 'off', closed_day: 'off', }; function bucketOf(rawState) { return STATE_MAP[String(rawState || '').toLowerCase()] || 'off'; } const SEV = { fail: 6, degrade: 5, slow: 4, review: 3, plan: 2, off: 1, ok: 0 }; function worstBucket(list) { let best = 'ok', bestSev = -1; for (const b of list) { const s = SEV[b] ?? 0; if (s > bestSev) { bestSev = s; best = b; } } return list.length ? best : 'ok'; } /* ================= 渲染小工具(结构照抄 hub-kimi.html) ================= */ function ledHtml(st, pulse) { return ``; } function pillHtml(bucket, label) { const h = HEALTH[bucket] || HEALTH.off; return `${ledHtml(h.led, bucket === 'fail' || bucket === 'review')}${label || h.label}`; } function tagHtml(tone, inner) { return `${inner}`; } function panelHtml(title, right, body, pad) { return `
//${title}${right ? `${right}` : ''}
${body}
`; } function kpiHtml(label, value, sub, tone, live) { const tc = { txt: '', mint: 'glow-mint', amb: 'glow-amb', rd: 'glow-rd', cy: 'glow-cy' }[tone || 'txt']; return `
${label}${value}${live ? '_' : ''}${sub ? `${sub}` : ''}
`; } /* 延迟:真实值 + 变化时才闪(打样 jit 的真实数据版:不随机抖动数字, 仅当新一轮真实调用改写该值时给 .flash 青色闪烁)。 */ function latHtml(ms, warn) { if (ms == null) return ''; const color = ms > 1000 ? 'var(--amb)' : (warn ? 'var(--amb)' : 'var(--txt)'); return `${Number(ms).toLocaleString('en-US')}ms`; } /* 缓存年龄:记录真实时间戳,由 updateDynamics 每秒重算(真实增长)。 */ function ageHtml(iso) { const ms = Date.parse(String(iso || '').includes('T') ? iso : String(iso || '').replace(' ', 'T')); if (!iso || Number.isNaN(ms)) return ''; return ``; } function fmtAgeSec(s) { if (s == null) return '—'; // 打样口径:缓存年龄一律按秒展示、每秒增长(>30s 变琥珀) return `${Number(s).toLocaleString('en-US')}s`; } function localDate(iso) { const at = new Date(String(iso || '').replace(' ', 'T')); return Number.isNaN(at.getTime()) ? null : at; } function ymd(iso) { const at = localDate(iso); if (!at) return String(iso || '').slice(0, 10); return `${at.getFullYear()}-${p2(at.getMonth() + 1)}-${p2(at.getDate())}`; } function stamp(iso) { const at = localDate(iso); if (!at) return String(iso || ''); return `${ymd(iso)} ${p2(at.getHours())}:${p2(at.getMinutes())}`; } function hm(iso) { if (!iso) return '—'; const s = String(iso); const idx = s.indexOf('T') >= 0 ? s.indexOf('T') : s.indexOf(' '); if (idx < 0 || s.length < idx + 6) return s.slice(0, 5) || '—'; return s.slice(idx + 1, idx + 6); } /* 大白话错误摘要:主表只放一行短句,完整原文放 title 悬停提示。 不猜业务结论,只做确定性文案映射 + 定长截断。 */ function plainError(raw, max = 18) { const s = String(raw || ''); if (!s) return ''; const rules = [ [/UNIQUE constraint failed/i, '重复数据写入冲突'], [/circuit open|breaker/i, '熔断保护中'], [/timeout|timed out|ETIMEDOUT/i, '请求超时'], [/row ratio/i, '行数校验未达标'], [/field gate/i, '字段完整率未达标'], [/empty official batch|no_rows_returned|empty_or_incomplete/i, '上游返回空数据'], [/release group not switched/i, '发布组未切换'], [/missing_fields/i, '返回缺字段'], [/data_age_exceeds/i, '数据过期超阈值'], [/blocked|captcha|安全验证|访问异常/i, '被上游拦截'], ]; for (const [re, text] of rules) if (re.test(s)) return text; return s.length > max ? s.slice(0, max) + '…' : s; } /* 单行截断单元格:class ellip(styles.css 生产补充段),title 放全文 */ function ellipHtml(text, color, fontSize, title) { return `${esc(text)}`; } const p2 = (x) => String(x).padStart(2, '0'); /* ================= sparkline(真实历史:来自 provider_call_log) ================= */ const SPARK_COLORS = { cy: 'var(--cy)', mint: 'var(--mint)', amb: 'var(--amb)', rd: 'var(--rd)' }; function sparkHtml(jid, tone, w, h) { return ``; } function sparkSeries(jid) { const hist = LatHist.get(jid) || []; if (hist.length < 2) return null; const max = Math.max(...hist, 1); return hist.map((v) => v / max); } function sparkPaint(svg) { const jid = svg.dataset.jid, w = +svg.dataset.w, h = +svg.dataset.h; const data = sparkSeries(jid); const poly = svg.querySelector('polyline'); const dot = svg.querySelector('circle'); if (!data) { const y = (h / 2).toFixed(2); poly.setAttribute('points', `0,${y} ${w},${y}`); poly.setAttribute('stroke-opacity', '.25'); dot.style.display = 'none'; return; } poly.setAttribute('stroke-opacity', '.8'); dot.style.display = ''; const pts = data.map((v, i) => `${((i / (data.length - 1)) * w).toFixed(2)},${(h - v * (h - 3) - 1).toFixed(2)}`).join(' '); poly.setAttribute('points', pts); dot.setAttribute('cx', (w - 1).toFixed(2)); dot.setAttribute('cy', (h - data[data.length - 1] * (h - 3) - 1).toFixed(2)); } /* 每个“观测对象”(接口或数据集)的真实延迟历史(旧→新),来自 provider_call_log 最近 200 条。 */ const LatHist = new Map(); const CALL_MATCHERS = {}; function buildLatHist() { const calls = (RAW.providers && RAW.providers.recent_calls) || []; const buckets = new Map(); [...calls].reverse().forEach((c) => { if (c.latency_ms == null) return; const key = `${c.provider}:${c.interface}`; (buckets.get(key) || buckets.set(key, []).get(key)).push(c.latency_ms); }); LatHist.clear(); buckets.forEach((arr, key) => LatHist.set(key, arr.slice(-24))); // 数据集维度的历史:tushare 观测行写的是数据集名,直接按数据集聚合 const dsBuckets = new Map(); [...calls].reverse().forEach((c) => { if (c.provider !== 'tushare' || c.latency_ms == null) return; (dsBuckets.get(c.interface) || dsBuckets.set(c.interface, []).get(c.interface)).push(c.latency_ms); }); dsBuckets.forEach((arr, ds) => LatHist.set(`ds:${ds}`, arr.slice(-24))); } /* ================= 数据层:拉取真实接口并汇总为一份快照 ================= */ const RAW = { overview: null, sources: null, providers: null, catalog: null, lineage: null, jobs: null, batches: null, audit: null, }; async function fetchAll() { const [overview, sources, providers, catalog, lineage, jobs, batches, audit] = await Promise.all([ api('/admin/api/overview'), api('/admin/api/sources'), api('/admin/api/providers/status?limit=200'), api('/admin/api/source-catalog'), api('/admin/api/lineage'), api('/admin/api/jobs'), api(`/admin/api/batches?date=${today()}`), api('/admin/api/audit'), ]); RAW.overview = overview; RAW.sources = sources; RAW.providers = providers; RAW.catalog = catalog; RAW.lineage = lineage; RAW.jobs = jobs; RAW.batches = batches; RAW.audit = audit; buildLatHist(); } function today() { return RAW.overview ? RAW.overview.trade_date : ''; } const OFFICIAL_DATASET_IDS = ['daily', 'valuation', 'moneyflow', 'auction', 'index_daily', 'limit_events', 'popularity', 'dragon_tiger', 'sector_daily', 'stocks']; const DATASET_NAME = { daily: '日K', valuation: '估值', moneyflow: '资金流', auction: '竞价', index_daily: '指数日K', limit_events: '涨停池', popularity: '人气榜', dragon_tiger: '龙虎榜', sector_daily: '题材板块', stocks: '股票主档', }; /* ===================================================================== 总览页(结构照抄 hub-kimi.html,数据全部真实) ===================================================================== */ function buildOverviewVM() { const ov = RAW.overview || {}; const eod = ov.eod_status || {}; const rev = ov.revision_status || {}; const pubsByDs = {}; (ov.publications || []).forEach((p) => { pubsByDs[p.dataset] = p; }); const batchByDs = {}; ((RAW.batches && RAW.batches.batches) || []).forEach((b) => { if (!batchByDs[b.dataset] || b.started_at > batchByDs[b.dataset].started_at) batchByDs[b.dataset] = b; }); const totalOfficial = OFFICIAL_DATASET_IDS.length; const publishedCount = OFFICIAL_DATASET_IDS.filter((id) => pubsByDs[id] && pubsByDs[id].state === 'published').length; const anomalyCount = (ov.anomalies || []).length; const recentCalls = ov.recent_calls || []; const successRate = recentCalls.length ? (recentCalls.filter((c) => c.ok).length / recentCalls.length * 100) : null; // 主站回退页面:主源异常且备源健康 → 判定为“回退中”的 provisional 数据集 const fallbackPages = []; (RAW.lineage && RAW.lineage.items || []).forEach((it) => { if (it.tier !== 'provisional' || !it.backup_source) return; const rows = it.live_provider_health || []; const primaryProv = (it.providers || [])[0]; const backupProv = (it.providers || [])[1]; const primaryState = worstBucket(rows.filter((r) => r.provider === primaryProv).map((r) => bucketOf(r.state))); const backupState = worstBucket(rows.filter((r) => r.provider === backupProv).map((r) => bucketOf(r.state))); if ((primaryState === 'fail' || primaryState === 'degrade' || primaryState === 'slow') && backupState === 'ok') { (it.known_consumers || []).forEach((c) => fallbackPages.push(c)); } }); const datasetCards = OFFICIAL_DATASET_IDS.map((id) => { const pub = pubsByDs[id]; const batch = batchByDs[id]; let bucket = 'plan', note = ''; if (pub) { bucket = pub.state === 'published' ? 'ok' : (pub.state === 'degraded' ? 'degrade' : 'fail'); note = pub.published_at ? hm(pub.published_at) : ''; } else if (batch) { bucket = batch.state === 'failed' ? 'fail' : (batch.state === 'staged' ? 'review' : 'plan'); note = batch.error ? esc(batch.error) : ''; } const rows = (batch && batch.rows_out) || (pub && pub.rows_out) || null; return { id, name: DATASET_NAME[id] || id, bucket, time: note && !batch?.error ? note : '', rows, err: batch && batch.error ? batch.error : '' }; }); // 实时观察层:eastmoney / tencent 目录接口的真实观测(observed=true) const catalogByProv = {}; (RAW.catalog && RAW.catalog.items || []).forEach((it) => { catalogByProv[it.provider] = it; }); const observers = []; ['eastmoney', 'tencent'].forEach((prov) => { const cat = catalogByProv[prov]; if (!cat) return; cat.interfaces.forEach((iface) => { if (!iface.observed) return; observers.push({ jid: `${prov}:${iface.interface}`, name: iface.capability, provider: prov, interface: iface.interface, lastOk: iface.observed_at, lat: iface.observed_latency_ms, bucket: bucketOf(iface.observed_state), note: iface.observed_note || '', }); }); }); // 主站影响:lineage() 条目按 known_consumers 展开(接口级状态) const siteImpact = []; (RAW.lineage && RAW.lineage.items || []).forEach((it) => { const bucket = lineageItemBucket(it); (it.known_consumers || []).forEach((c) => { siteImpact.push({ page: c, short: pageNameOf(c), dataset: it.dataset, bucket, note: lineageItemNote(it) }); }); }); const okPages = siteImpact.filter((s) => s.bucket === 'ok').length; // 今晚时间线:真实 jobs() 定义 + job_runs const jobDefs = (RAW.jobs && RAW.jobs.jobs || []).filter((j) => j.at !== 'manual'); const runsByJob = {}; ((RAW.jobs && RAW.jobs.runs) || []).forEach((r) => { (runsByJob[r.job_id] = runsByJob[r.job_id] || []).push(r); }); const timeline = jobDefs.map((j) => { const runs = (runsByJob[j.id] || []).slice().sort((a, b) => (a.id < b.id ? 1 : -1)); const latest = runs[0]; let st = 'wait'; if (latest) st = latest.state === 'ok' ? 'done' : (latest.state === 'failed' ? 'fail' : 'wait'); return { t: (j.at.split('-')[0] || j.at).slice(0, 5), label: j.title, state: st, jobId: j.id }; }); // 待处理异常:overview.anomalies 已在后端收敛为“仍未恢复的最新异常” const incidents = []; (ov.anomalies || []).forEach((b) => { incidents.push({ sev: 'fail', tag: b.state, title: `${DATASET_NAME[b.dataset] || b.dataset} ${b.state === 'failed' ? '失败' : '停滞'}`, meta: `${b.error ? esc(b.error) : '无错误详情'} · 批次 ${b.batch_id}`, dataset: b.dataset }); }); if (eod.state === 'waiting_upstream' || eod.state === 'cutoff_failed') { incidents.push({ sev: eod.state === 'cutoff_failed' ? 'fail' : 'slow', tag: eod.state, title: eod.state === 'cutoff_failed' ? '盘后批已截止失败' : '自动重试窗口', meta: `15:15–23:30 · 每 30 分钟 · 成功即停 · 已试 ${eod.attempts || 0} 次${(eod.missing_datasets || []).length ? ' · 缺 ' + eod.missing_datasets.join(',') : ''}`, dataset: (eod.missing_datasets || [])[0] || '', cd: eod.next_retry_at }); } const hidx = {}; ((RAW.providers && RAW.providers.health) || []).forEach((r) => { (hidx[r.provider] = hidx[r.provider] || []).push(r); }); Object.keys(hidx).forEach((prov) => { const bad = (hidx[prov] || []).filter((r) => (r.consec_failures || 0) > 0 && bucketOf(r.state) === 'fail'); if (bad.length) incidents.push({ sev: 'fail', tag: prov, title: `${prov} 部分接口连续失败`, meta: bad.map((r) => `${r.interface}×${r.consec_failures}`).join(' · '), provider: prov }); }); const ifindCat = catalogByProv.ifind; if (ifindCat && !(ifindCat.credential && ifindCat.credential.configured)) { incidents.push({ sev: 'off', tag: '未配置', title: 'iFinD · 授权源未启用', meta: '凭证未配置 · 功能隐藏 · 配置后启用', provider: 'ifind' }); } return { trade_date: ov.trade_date, session_phase: ov.session_phase, eod, rev, publishedCount, totalOfficial, anomalyCount, successRate, fallbackPages, datasetCards, observers, siteImpact, okPages, timeline, incidents, unpublished: OFFICIAL_DATASET_IDS.filter((id) => !pubsByDs[id] || pubsByDs[id].state !== 'published'), }; } function heroHtml(vm) { const anomalyTxt = vm.anomalyCount ? `${vm.anomalyCount} 项异常` : `无异常`; const tags = []; if (vm.eod.state === 'waiting_upstream') tags.push(tagHtml('amb', `等待上游 · 已试 ${vm.eod.attempts || 0} 次`)); if (vm.rev.state === 'waiting_review') tags.push(tagHtml('cy', '复核 · 待发布')); const subParts = []; if (vm.eod.attempts) subParts.push(`已试 ${vm.eod.attempts} 次`); if (vm.eod.next_retry_at) subParts.push(`下次重试 --:--`); if ((vm.eod.missing_datasets || []).length) subParts.push(`缺 ${esc(vm.eod.missing_datasets.join(','))}`); if (vm.rev.window) subParts.push(`复核窗口 ${esc(vm.rev.window)}`); return `
` + `
` + `
${vm.anomalyCount ? '部分异常' : '整体可用'}·${anomalyTxt}
` + `
${tags.join('')}
` + (subParts.length ? `
${subParts.join('|')}
` : '') + `
` + `
` + kpiHtml('已发布数据集', `${vm.publishedCount}/${vm.totalOfficial}`, vm.unpublished.length ? `${vm.unpublished.map((d) => DATASET_NAME[d] || d).join(' · ')} 未发布` : '全部已发布') + kpiHtml('今日调用成功率', vm.successRate == null ? '' : `${vm.successRate.toFixed(1)}%`, null, 'mint', vm.successRate != null) + kpiHtml('主站回退页面', `${vm.fallbackPages.length} 页`, vm.fallbackPages.length ? esc(vm.fallbackPages[0]) : '实时层主源正常', vm.fallbackPages.length ? 'amb' : 'txt') + kpiHtml('EOD 尝试', `${vm.eod.attempts || 0} 次`, '上限 5 · 23:30 止', vm.eod.attempts ? 'rd' : 'txt') + `
`; } function datasetStripHtml(vm) { return `
${vm.datasetCards.map((d) => { const ring = d.bucket === 'fail' ? 'fail' : d.bucket === 'degrade' ? 'slow' : d.bucket === 'review' ? 'review' : ''; const sparkTone = d.bucket === 'fail' ? 'rd' : d.bucket === 'degrade' ? 'amb' : d.bucket === 'review' ? 'cy' : 'mint'; let note = ''; if (d.time) note += `${esc(d.time)} · `; if (d.rows) note += `${Number(d.rows).toLocaleString('en-US')} 行`; if (d.err) note += `${esc(plainError(d.err, 16))}`; if (!d.time && !d.rows && !d.err) note = '今日尚未发布'; return `
` + `
${esc(d.name)}${pillHtml(d.bucket)}
` + `
${note}
` + sparkHtml(`ds:${d.id}`, sparkTone, 96, 16) + `
`; }).join('')}
`; } function observatoryHtml(vm) { const legendByProv = { eastmoney: 'EM 东财', tencent: 'TX 腾讯' }; const right = `${Object.keys(legendByProv).map((p) => `${ledHtml('ok')}${legendByProv[p]}`).join('')}`; const rows = vm.observers.length ? vm.observers.map((o) => `` + `${esc(o.name)}` + `${o.provider === 'eastmoney' ? '东方财富' : '腾讯行情'}` + `${ageHtml(o.lastOk)}` + `${latHtml(o.lat, o.bucket !== 'ok')}` + `${sparkHtml(o.jid, o.bucket === 'slow' ? 'amb' : 'mint', 64, 16)}` + `${pillHtml(o.bucket)}${o.note ? ellipHtml(plainError(o.note, 20), '', '10px', o.note) : ''}` + ``).join('') : `尚无真实观测记录(等待首次探测/调用)`; const body = `${rows}
观察项当前来源缓存年龄延迟趋势状态 · 备注
`; return panelHtml('实时观察层', right, body, false); } function siteImpactHtml(vm) { const bad = vm.siteImpact.filter((s) => s.bucket !== 'ok'); const right = `${vm.okPages} 项正常 · ${vm.siteImpact.length - vm.okPages} 项待关注`; const rows = bad.length ? bad.slice(0, 5).map((s) => `` + `${esc(s.page)}` + `${pillHtml(s.bucket)}` + `${s.note ? ellipHtml(plainError(s.note, 22), 'var(--dim)', '11px', 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); } function timelineHtml(vm) { const nodes = vm.timeline; if (!nodes.length) return panelHtml('今晚时间线', '', '
暂无可展示的调度任务
', true); const W = 560, H = 150, x0 = 34, x1 = W - 30, y = 75; const step = (x1 - x0) / Math.max(1, nodes.length - 1); const X = (i) => x0 + i * step; const now = new Date(); const nowMinutes = now.getHours() * 60 + now.getMinutes(); const toMin = (t) => { const [h, m] = t.split(':').map(Number); return h * 60 + (m || 0); }; let nowX = X(0); for (let i = 0; i < nodes.length - 1; i++) { const a = toMin(nodes[i].t), b = toMin(nodes[i + 1].t); if (nowMinutes >= a && nowMinutes <= b && b > a) { nowX = X(i) + (X(i + 1) - X(i)) * ((nowMinutes - a) / (b - a)); break; } if (nowMinutes < toMin(nodes[0].t)) { nowX = X(0); break; } if (nowMinutes > toMin(nodes[nodes.length - 1].t)) { nowX = X(nodes.length - 1); } } let s = `` + `` + `` + ``; nodes.forEach((n, i) => { const nx = X(i), up = i % 2 === 0; const ty = up ? y - 26 : y + 34, ly = up ? y - 12 : y + 20; const color = n.state === 'done' ? 'var(--mint)' : n.state === 'fail' ? 'var(--rd)' : 'var(--dim)'; const anchor = i === 0 ? 'start' : i === nodes.length - 1 ? 'end' : 'middle'; s += ``; if (n.state === 'fail') s += `×`; else s += ``; s += `${esc(n.t)}` + `${esc(n.label)}`; }); 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`; return panelHtml('今晚时间线', right, s, false); } function incidentsHtml(vm) { if (!vm.incidents.length) return panelHtml('待处理异常', tagHtml('mint', '0 OPEN'), '
当前无待处理异常
', true); const cards = vm.incidents.map((it) => { const tag = it.sev === 'fail' ? tagHtml('rd', esc(it.tag)) : it.sev === 'slow' ? tagHtml('amb', esc(it.tag)) : tagHtml('dashed', esc(it.tag)); const meta = it.cd ? `${it.meta} · 下次重试 --m--s` : it.meta; return `
${tag}` + `
` + `
${esc(it.title)}
` + `
${meta}
` + `
` + `` + `
`; }).join(''); return panelHtml('待处理异常', tagHtml('rd', vm.incidents.length + ' OPEN'), `
${cards}
`, true); } function overviewHtml() { const vm = buildOverviewVM(); return `
${heroHtml(vm)}${datasetStripHtml(vm)}` + `
${observatoryHtml(vm)}${siteImpactHtml(vm)}
` + `
${timelineHtml(vm)}${incidentsHtml(vm)}
`; } /* ===================================================================== 数据源配置页(分组接口表照抄打样 mx-grid 结构,数据全部真实) ===================================================================== */ const srcUiState = {}; const PROV_ROLE_TEXT = { tushare: 'official · 盘后正式数据唯一源', eastmoney: 'free · 盘中观察主源', tencent: 'free · 盘中观察备源', ifind: 'licensed · 图表 / 问财 / 实时快照', }; function buildSourcesVM() { const catalog = (RAW.catalog && RAW.catalog.items) || []; const legacy = {}; (RAW.sources && RAW.sources.items || []).forEach((it) => { legacy[it.provider] = it; }); const cidx = {}; ((RAW.providers && RAW.providers.recent_calls) || []).forEach((c) => { if (!cidx[c.provider]) cidx[c.provider] = c; }); const active = catalog.filter((c) => c.role !== 'reserved'); const reserved = catalog.filter((c) => c.role === 'reserved'); const totalIfaces = active.reduce((n, c) => n + c.interfaces.length, 0); // “在用”= 已登记且已观察到调用(接口名或数据集名匹配,后端 join 已标注) const usedIfaces = active.reduce((n, c) => n + c.interfaces.filter((i) => i.observed).length, 0); const cards = active.map((c) => { const cred = c.credential || {}; const observedRows = c.interfaces.filter((i) => i.observed); const worst = observedRows.length ? worstBucket(observedRows.map((i) => bucketOf(i.observed_state))) : null; const configured = cred.configured; let bucket; if (worst) bucket = worst; else if (configured) bucket = 'plan'; else bucket = 'off'; const lastCall = cidx[c.provider]; if (!(c.provider in srcUiState)) srcUiState[c.provider] = { open: true, probing: false, probeResult: null }; const groups = []; const groupMap = new Map(); c.interfaces.forEach((i) => { const g = i.group || '其他'; if (!groupMap.has(g)) groupMap.set(g, []); groupMap.get(g).push(i); }); groupMap.forEach((items, name) => groups.push({ name, items })); const ifaceRows = (group) => group.items.map((i) => ({ api: i.interface, use: i.capability, ok: i.observed ? (i.observed_at ? hm(i.observed_at) : '—') : '—', lat: i.observed ? i.observed_latency_ms : null, st: i.observed ? bucketOf(i.observed_state) : (configured ? 'plan' : 'off'), obs: i.observed, basis: i.observed_basis, err: i.observed && i.observed_state !== 'ok' ? (i.observed_note || '') : '', })); return { id: c.provider, name: c.label, roleText: PROV_ROLE_TEXT[c.provider] || c.role, tag: c.credential_key ? 'licensed' : 'free', bucket, credential: cred, usedCount: c.interfaces.filter((i) => i.observed).length, totalCount: c.interfaces.length, lastProbe: lastCall ? `${hm(lastCall.fetched_at)} ${lastCall.status === 'ok' ? `✓ ${lastCall.latency_ms ?? ''}ms` : `× ${lastCall.error || 'ERROR'}`}` : '—', latency: observedRows.length ? (observedRows.map((r) => r.observed_latency_ms).filter((v) => v != null)[0] || null) : null, error: observedRows.find((r) => r.observed_state !== 'ok' && r.observed_note)?.observed_note || '', groups: groups.map((g) => ({ name: g.name, rows: ifaceRows(g) })), }; }); return { cards, reserved, totalIfaces, usedIfaces }; } function sourceCardHtml(card) { const ui = srcUiState[card.id]; const ringStyle = card.bucket === 'fail' ? 'border-color:rgba(248,113,113,.35)' : card.bucket === 'degrade' ? 'border-color:rgba(251,191,36,.25)' : card.bucket === 'slow' ? 'border-color:rgba(251,191,36,.3)' : card.bucket === 'off' ? 'border-style:dashed;border-color:var(--line2)' : ''; const ledSt = HEALTH[card.bucket].led; // source_catalog.py:credential_key 为空的来源(eastmoney/tencent 等公开 // 接口)真实语义是"无需凭证"而非"已配置",两者都体现为 configured:true, // 靠 note 字段区分,这里必须原样展示 note,不能一律折叠成"已配置"。 const credBadge = card.credential.note ? card.credential.note : card.credential.configured ? `已配置${card.credential.last4 ? ' · ' + card.credential.last4 : ''}` : '未配置'; const badges = [credBadge]; if (card.credential.configured && card.credential.updated_at) badges.push(`${String(card.credential.updated_at).slice(5, 10).replace('-', '/')} 更新`); badges.push(card.id === 'tushare' ? '主(无备)' : (card.id === 'ifind' ? '图表主源(未启用)' : (card.id === 'tencent' ? '实时层备' : '实时层主'))); let h = `
`; h += `
` + ledHtml(ledSt, card.bucket === 'fail') + `${esc(card.name)}` + `${esc(card.roleText)}` + pillHtml(card.bucket, card.bucket === 'plan' ? '已配置 · 待观测' : '') + `${badges.map((b) => tagHtml(card.bucket === 'off' ? 'dashed' : 'dim', esc(b))).join('')}` + `` + `` + (card.bucket === 'off' ? `下方填入凭证即可启用` : ``) + `
`; const probeCell = ui.probeResult ? `${ui.probeResult.t} ${ui.probeResult.ok ? `✓ ${ui.probeResult.ms}ms` : '× TIMEOUT'}` : `${esc(card.lastProbe)}`; h += `
` + `接口 ${card.usedCount}/${card.totalCount} 在用` + `|最后探测 ${probeCell}` + `|延迟 ${latHtml(card.latency)}` + (card.error ? `|最近错误 ${ellipHtml(plainError(card.error, 26), 'var(--rd)', '11px', card.error)}` : (card.bucket !== 'off' ? `|无最近错误` : '')) + `
`; h += credBoxHtml(card); if (ui.open && card.bucket !== 'off') { h += `
` + card.groups.map((g) => `
` + `
${esc(g.name)}
` + `` + g.rows.map((it) => `` + `` + `` + `` + `` + `` + ``).join('') + `
接口用途最近成功延迟状态
${esc(it.api)}${esc(it.use)}${esc(it.ok)}${latHtml(it.lat)}${pillHtml(it.st, it.st === 'plan' && it.obs === false ? '已配置 · 待观测' : '')}${it.err ? ellipHtml(plainError(it.err, 16), 'var(--rd)', '10px', it.err) : ''}
`).join('') + `
`; } if (card.bucket === 'off') { h += `
凭证未配置 · ${card.totalCount} 个预留接口处于停用状态 · 配置后将成为图表 / 问财 / 实时快照主源` + `
${card.groups[0].rows.map((i) => tagHtml('dashed', esc(i.api))).join('')}
` + `
`; } return h + `
`; } /* ---------- 凭证可编辑区(样图 v2「新增可编辑区」,原卡内容一项不删) ---------- 公开源(东财/腾讯)无凭证可填,保持"无需凭证"徽标即可;Tushare 与 iFinD 的 Token 存在数据中枢的加密库里,这里只回显掩码,留空表示保持不变。 */ const CRED_FORMS = { tushare: { field: 'tushare_token', label: 'API TOKEN', placeholder: '粘贴 Tushare token', save: '保存凭证', test: true, hint: '掩码显示 · 粘贴新值即覆盖 · 留空保持不变', note: '', }, ifind: { field: 'ifind_refresh_token', label: 'REFRESH TOKEN', placeholder: '粘贴 iFinD refresh_token', save: '保存并启用', test: false, hint: '凭据服务端加密保存 · 任何界面不回显明文', note: '保存并通过最近一次探测后自动纳入数据血缘', }, }; function credBoxHtml(card) { const form = CRED_FORMS[card.id]; if (!form) return ''; const saved = card.credential.configured; const probe = card.credential.updated_at ? `上次保存 ${esc(stamp(card.credential.updated_at))}` : ''; return `
` + `${esc(form.label)} · 新增可编辑区` + `
` + `` + `` + (form.test ? `` : '') + `` + `
` + `
${esc(form.hint)}` + `${esc(form.note) || probe}
` + `
`; } async function saveCredential(provider) { const form = CRED_FORMS[provider]; const box = document.querySelector(`[data-cred-form="${provider}"]`); if (!form || !box) return; const input = box.querySelector(`[data-cred-input="${form.field}"]`); const value = input ? input.value.trim() : ''; if (!value) { toast('请先粘贴新的凭证,留空表示保持不变', 'err'); return; } const button = box.querySelector('[data-cred-save]'); if (button) button.disabled = true; try { await api(`/admin/api/credentials/${provider}`, { method: 'POST', body: JSON.stringify({ [form.field]: value }) }); toast('凭证已保存,已对下一次取数生效', 'ok'); await refreshQuiet(); renderSourceCards(); } catch (err) { toast(err.message, 'err'); if (button) button.disabled = false; } } function sourcesHtml() { const vm = buildSourcesVM(); const alerts = []; vm.cards.forEach((c) => { if (c.bucket === 'fail') alerts.push(`${ledHtml('fail', true)}${esc(c.name)} 故障${c.error ? ' · ' + esc(c.error) : ''}`); else if (c.bucket === 'degrade' || c.bucket === 'slow') alerts.push(`${ledHtml('slow')}${esc(c.name)} 降级/延迟`); }); return `
` + `
` + `数据源 ${vm.cards.length} 路 · 接口 ${vm.usedIfaces}/${vm.totalIfaces} 在用` + alerts.join('') + `` + `凭证安全存储 · 探测只读 · 不影响盘中链路` + `
` + `
${vm.cards.map(sourceCardHtml).join('')}
` + `
${vm.reserved.map((r) => `
${ledHtml('off')}${esc(r.label)}${tagHtml('dashed', '预留位 · 未接入')}
`).join('')}
` + `
`; } function renderSourceCards() { const el = $('srcCards'); if (!el) return; el.innerHTML = buildSourcesVM().cards.map(sourceCardHtml).join(''); paintSparks(); } /* ===================================================================== 数据血缘页(接口级状态 + 已配置/待观测 + 真实频率 + 事件脉冲) ===================================================================== */ function pageNameOf(consumer) { const idx = consumer.indexOf('('); return (idx > 0 ? consumer.slice(0, idx) : consumer).trim(); } /* 一条血缘项的服务接口集合(primary/backup 冒号后的真实接口名) */ function servingInterfaces(item) { const out = new Set([item.dataset]); [item.primary_source, item.backup_source].forEach((src) => { if (!src) return; src.split(':').slice(1).join(':').split('+').forEach((api) => { const name = api.trim(); if (name) out.add(name); }); }); return out; } /* 接口级状态:只看本数据集真正走的那几条接口,不用提供商级汇总顶替。 */ function lineageItemRowsHealth(item) { const apis = servingInterfaces(item); return (item.live_provider_health || []).filter((r) => apis.has(r.interface)); } function lineageItemBucket(item) { const rows = lineageItemRowsHealth(item); if (item.tier === 'official') { if (item.publication) return item.publication.state === 'published' ? 'ok' : (item.publication.state === 'degraded' ? 'degrade' : 'fail'); return rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'plan'; } if (item.tier === 'licensed') { const cred = ((RAW.catalog && RAW.catalog.items) || []).find((c) => c.provider === 'ifind'); if (cred && !(cred.credential && cred.credential.configured)) return 'off'; return rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'off'; } // provisional:已有数据来源;有观测行看接口级状态,暂无观测行=已配置/待观测 return rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'plan'; } function lineageItemNote(item) { const rows = lineageItemRowsHealth(item); const note = rows.find((r) => r.last_error)?.last_error || rows.find((r) => r.last_fallback_reason)?.last_fallback_reason || ''; if (note) return note; if (item.tier === 'licensed') return 'iFinD 未配置 · 功能隐藏'; if (item.tier === 'provisional' && !rows.length) return '已配置 · 暂无观测记录'; return ''; } const linFilter = { onlyBad: false, src: '全部', q: '' }; let linHover = null; let GW = 1500, GH = 286; const gpos = {}; const adj = { sd: new Map(), ds: new Map(), dp: new Map(), pd: new Map() }; function adjAdd(m, k, v) { if (!m.has(k)) m.set(k, new Set()); m.get(k).add(v); } function buildLineageVM() { const items = (RAW.lineage && RAW.lineage.items) || []; const PROV_LABEL = { tushare: 'Tushare', eastmoney: '东方财富', tencent: '腾讯行情', ifind: 'iFinD' }; const srcState = {}; const dsState = {}; const pageState = {}; const edgesSD = []; const edgesDP = []; const rows = []; items.forEach((it) => { const rowsHealth = lineageItemRowsHealth(it); const bucket = lineageItemBucket(it); dsState[it.dataset] = bucket; (it.providers || []).forEach((p) => { srcState[p] = worstBucket([srcState[p] || 'ok', ...rowsHealth.filter((r) => r.provider === p).map((r) => bucketOf(r.state))]); edgesSD.push([p, it.dataset]); }); const consumers = it.known_consumers || []; const pages = [...new Set(consumers.map(pageNameOf))]; pages.forEach((pg) => { pageState[pg] = worstBucket([pageState[pg] || 'ok', bucket]); edgesDP.push([it.dataset, pg]); }); rows.push({ page: pages.join(' / ') || '—', item: consumers.join(' / '), dataset: it.dataset, via: `${it.primary_source}${it.backup_source ? ' → ' + it.backup_source : ''}`, role: it.backup_source ? '主/备' : (it.tier === 'licensed' ? '主(授权)' : '主(无备)'), freq: it.update_freq || '—', ok: it.tier === 'official' && it.publication ? it.publication.published_at : (rowsHealth.find((r) => r.last_ok_at) || {}).last_ok_at, lat: rowsHealth.map((r) => r.last_latency_ms).find((v) => v != null), st: bucket, note: lineageItemNote(it), }); }); const sources = Object.keys(srcState).map((id) => ({ id, name: PROV_LABEL[id] || id, st: srcState[id] })); const datasets = Object.keys(dsState).map((id) => ({ id, st: dsState[id] })); const pages = Object.keys(pageState).map((id) => ({ id, name: id, st: pageState[id] })); return { sources, datasets, pages, edgesSD, edgesDP, rows }; } function spread(n, y0, y1) { return Array.from({ length: n }, (_, i) => (n === 1 ? (y0 + y1) / 2 : y0 + (i / (n - 1)) * (y1 - y0))); } function layoutGraph(vm) { const wide = (window.innerWidth || 1440) > 1100; GW = wide ? 1500 : 1000; GH = wide ? 286 : 560; vm.sources.forEach((s, i) => { gpos['s:' + s.id] = { x: wide ? 80 : 60, y: spread(vm.sources.length, wide ? 60 : 90, wide ? 232 : 500)[i] }; }); vm.datasets.forEach((d, i) => { gpos['d:' + d.id] = { x: wide ? 700 : 480, y: spread(vm.datasets.length, wide ? 30 : 45, wide ? 264 : 525)[i] }; }); vm.pages.forEach((p, i) => { gpos['p:' + p.id] = { x: wide ? 1400 : 930, y: spread(vm.pages.length, wide ? 26 : 30, wide ? 276 : 540)[i] }; }); } function linkPath(p1, p2) { const mx = (p1.x + p2.x) / 2; return `M ${p1.x} ${p1.y} C ${mx} ${p1.y}, ${mx} ${p2.y}, ${p2.x} ${p2.y}`; } const stColorOf = (b) => (HEALTH[b] || HEALTH.off).color; function lineageGraphHtml(vm) { layoutGraph(vm); adj.sd.clear(); adj.ds.clear(); adj.dp.clear(); adj.pd.clear(); vm.edgesSD.forEach(([s, d]) => { adjAdd(adj.sd, s, d); adjAdd(adj.ds, d, s); }); vm.edgesDP.forEach(([d, p]) => { adjAdd(adj.dp, d, p); adjAdd(adj.pd, p, d); }); let h = ``; const colStyle = 'font-size="10" letter-spacing="2" style="fill:var(--dim)"'; if (vm.sources.length) h += `来源 · ${vm.sources.length}`; 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 += ``; }); vm.edgesDP.forEach(([d, p]) => { if (!gpos['d:' + d] || !gpos['p:' + p]) return; h += ``; }); vm.sources.forEach((s) => { const p = gpos['s:' + s.id]; if (!p) return; h += `` + `` + `` + `${esc(s.name)}`; }); vm.datasets.forEach((d) => { const p = gpos['d:' + d.id]; if (!p) return; h += `` + `` + `${esc(d.id)}`; }); vm.pages.forEach((pg) => { const p = gpos['p:' + pg.id]; if (!p) return; h += `` + `` + `${esc(pg.name)}`; }); h += `
悬停节点 · 追踪上下游链路
`; return h; } function activeSetCalc(hover, vm) { if (!hover) return null; const set = new Set([hover]); const isSrc = vm.sources.some((s) => s.id === hover); const isDs = vm.datasets.some((d) => d.id === hover); if (isSrc) { (adj.sd.get(hover) || new Set()).forEach((d) => { set.add(d); (adj.dp.get(d) || new Set()).forEach((p) => set.add(p)); }); } else if (isDs) { (adj.ds.get(hover) || new Set()).forEach((s) => set.add(s)); (adj.dp.get(hover) || new Set()).forEach((p) => set.add(p)); } else { (adj.pd.get(hover) || new Set()).forEach((d) => { set.add(d); (adj.ds.get(d) || new Set()).forEach((s) => set.add(s)); }); } return set; } function applyLinHover(vm) { const act = activeSetCalc(linHover, vm); const isSrcHov = vm.sources.some((s) => s.id === linHover); document.querySelectorAll('#linSvg path[data-link]').forEach((p) => { const a = p.dataset.a, b = p.dataset.b; const shown = !act || (act.has(a) && act.has(b)); let hot = false; if (act && act.has(a) && act.has(b)) hot = isSrcHov ? (a === linHover ? true : (adj.sd.get(linHover) || new Set()).has(a)) : true; p.setAttribute('stroke-width', hot ? 1.8 : 1); p.setAttribute('stroke-opacity', shown ? (act ? 0.85 : (p.dataset.kind === 'sd' ? 0.28 : 0.22)) : 0.05); }); document.querySelectorAll('#linSvg g[data-nid]').forEach((g) => { const id = g.dataset.nid, kind = g.dataset.gkind; const dimmed = act && !act.has(id); g.setAttribute('opacity', dimmed ? (kind === 'src' ? 0.3 : 0.25) : 1); if (kind !== 'src') { const t = g.querySelector('text'); if (t) t.style.fill = act && act.has(id) ? 'var(--txt-strong)' : 'var(--mut)'; } }); const hint = $('linHint'); if (hint) hint.textContent = linHover ? `FOCUS: ${linHover} · 高亮上下游链路` : '悬停节点 · 追踪上下游链路'; } function setLinHover(id, vm) { linHover = id; applyLinHover(vm); } function linRowsData(vm) { return vm.rows.filter((r) => { if (linFilter.onlyBad && !['fail', 'slow', 'degrade', 'off', 'review', 'plan'].includes(r.st)) return false; if (linFilter.src !== '全部' && !r.via.includes({ Tushare: 'tushare', 东方财富: 'eastmoney', 腾讯: 'tencent', iFinD: 'ifind' }[linFilter.src] || '')) return false; if (linFilter.q && !(r.page + r.item + r.dataset + r.via).toLowerCase().includes(linFilter.q.toLowerCase())) return false; return true; }); } function renderLinRows(vm) { const body = $('linBody'), cnt = $('linCount'), bodyN = $('linBodyN'); if (!body) return; const rows = linRowsData(vm); if (cnt) cnt.textContent = `${rows.length}/${vm.rows.length} 行`; const cls = (st) => st === 'fail' ? 'row-fail' : st === 'degrade' || st === 'slow' ? 'row-slow' : st === 'off' ? 'row-off' : ''; const stLabel = (r) => (r.st === 'plan' && r.note.includes('待观测')) ? '已配置 · 待观测' : ''; body.innerHTML = rows.map((r) => `` + `${esc(r.page)}` + `${esc(r.item)}` + `${esc(r.dataset)}` + `${esc(r.via)}` + `${esc(r.role)}` + `${esc(r.freq)}` + `${r.ok ? esc(hm(r.ok)) : '—'}` + `${r.lat != null ? Number(r.lat).toLocaleString('en-US') + 'ms' : '—'}` + `${pillHtml(r.st, stLabel(r))}${r.note && !stLabel(r) ? ellipHtml(plainError(r.note, 20), 'var(--dim)', '10px', 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(plainError(r.note, 20))}` : ''}` + ``).join('') || `无匹配记录 · 调整筛选条件`; } function lineageHtml() { const vm = buildLineageVM(); const failDs = vm.datasets.filter((d) => d.st === 'fail'); let impactTag; if (failDs.length) { const pages = new Set(); failDs.forEach((d) => { (adj.dp.get(d.id) || new Set()).forEach((p) => pages.add(p)); }); impactTag = tagHtml('rd', `${failDs.map((d) => d.id).join('/')} 断链影响 ${pages.size} 页`); } else { impactTag = tagHtml('mint', '暂无断链影响'); } const html = `
` + `
` + `血缘地图` + `${vm.sources.length} 源 → ${vm.datasets.length} 数据集 → ${vm.pages.length} 页面` + `${ledHtml('ok')}健康${ledHtml('slow')}慢/降级${ledHtml('fail')}失败${ledHtml('plan')}计划${ledHtml('off')}未配置` + `` + impactTag + `
` + `
${lineageGraphHtml(vm)}
` + `
` + `
` + `` + `` + `` + `` + `` + `
` + `
主站页面数据项数据集源 : 接口主/备更新频率最近成功延迟状态
` + `
主站页面数据集源 : 接口更新频率最近成功状态
` + `
` + `
`; return html; } /* ===================================================================== 模型池(供应商制,样图 v2):一个供应商一张卡,卡内填 API 地址与 Key, 自动拉取模型列表勾选纳入;拉取失败退回卡内「手动录入(兜底)」。 数据仍归主站所有,本页经桥接读写。 ===================================================================== */ function vendorDraft(key) { if (!state.vendorDraft[key]) { state.vendorDraft[key] = { fetching: false, fetched: null, fetchedAt: '', error: '', manual: false, url: '', key: '' }; } return state.vendorDraft[key]; } function modelRoleTag(id) { const data = state.models || {}; if (id && id === data.primary_model_id) return tagHtml('cy', '主模型'); if (id && id === data.fallback_model_id) return tagHtml('amb', '备用'); return tagHtml('dashed', '备用'); } function modelRowHtml(model, vendorLabel) { const test = state.modelTests[model.id]; const result = test ? (test.pending ? '测试中…' : `${test.ok ? `已连通 · ${test.ms}ms` : `未连通 · ${esc(test.error || '失败')}`}`) : '未测试'; return `
` + `
${esc(model.name || model.id)} ${modelRoleTag(model.id)}
` + `
${esc(vendorLabel)} · ${esc(model.model || '')}
` + `${result}` + `` + `
`; } function vendorStateTag(group, draft) { if (draft.error) return tagHtml('rd', '拉取失败'); if (draft.fetching) return tagHtml('cy', '拉取中'); if (group.configured) return tagHtml('mint', '密钥已保存'); return tagHtml('amb', '待配置'); } function vendorFetchHtml(group, draft) { if (draft.fetching) { return `
${ledHtml('rev', true)}正在请求 /models … 通常 1~3 秒,期间不可重复点击
`; } if (draft.error) { return `
${ledHtml('fail')}拉取失败 · ${esc(draft.error)} · 也可改用手动录入模型标识
` + manualEntryHtml(group); } if (!draft.fetched) return group.models.length ? '' : manualEntryHtml(group); const existing = new Set(group.models.map((model) => String(model.model || ''))); const pending = draft.fetched.filter((name) => !existing.has(name)); const picks = draft.fetched.map((name) => { const added = existing.has(name); return ``; }).join(''); return `
${ledHtml('ok')}已拉取 ${draft.fetched.length} 个模型` + `${draft.fetchedAt ? ' · ' + esc(draft.fetchedAt) : ''} · 来自 GET /models
` + `
${picks}
` + `
` + `${pending.length ? '勾选后一键添加,免手敲模型标识' : '全部已添加'}
` + manualEntryHtml(group); } function manualEntryHtml(group) { const draft = vendorDraft(group.base_url); if (!draft.error && !draft.manual) { return `
` + `拉取失败或供应商不支持 /models 时使用
`; } return `
手动录入(兜底)` + `
模型标识 *` + `
` + `
`; } function vendorCardHtml(group) { const draft = vendorDraft(group.base_url); const required = '*'; // 样图:地址与 Key 并排一行,两个操作按钮贴在同一行右端,提示挂在 Key 下面 const body = `
` + `
API BASE URL ${required}` + `
` + `
API KEY ${required}` + `` + `${group.configured ? '掩码显示 · 留空保持不变' : '密钥仅服务端加密保存,任何界面不回显明文'}
` + `` + `` + `` + `
` + vendorFetchHtml(group, draft) + group.models.map((model) => modelRowHtml(model, group.label)).join(''); const head = `${ledHtml(group.configured ? 'ok' : 'off')}${esc(group.label)}` + vendorStateTag(group, draft) + tagHtml('cy', `${group.models.length} 个模型`); return `
${head}
` + `
${body}
`; } function modelsHtml() { const data = state.models; if (!data) return panelHtml('模型池', '', '

正在从主站读取模型池…

'); const named = (id) => (data.models.find((model) => model.id === id) || {}).name || ''; const option = (model, want) => ``; const strip = `
` + `供应商 ${data.groups.length}` + ` · 模型 ${data.models.length} 个` + ` · 主模型 ${esc(named(data.primary_model_id) || '未指定')}` + ` · 辅助模型 ${esc(named(data.fallback_model_id) || '未启用')}` + `` + `密钥加密存储 · 保存后全站即时生效 · 自主站系统管理迁入` + `
`; const routing = panelHtml('调用编排 ROUTING', `主模型故障时自动回落辅助模型`, `
` + `
主模型 PRIMARY
` + `
辅助模型 FALLBACK
` + `` + `主 / 辅不可指向同一模型
`); const vendors = data.groups.map(vendorCardHtml).join(''); const adder = `
` + `` + `` + `` + `同一供应商(同一 API 地址)可挂多个模型 · 主 / 辅正在引用的模型不可删除,需先在上方切换` + `
`; return `
${strip}${routing}${vendors}${adder}
`; } async function loadModels() { state.models = await api('/admin/api/models'); } async function saveModels(models, extra) { const payload = Object.assign({ models: models }, extra || {}); state.models = await api('/admin/api/models/save', { method: 'POST', body: JSON.stringify(payload) }); renderPage('models'); } /* ===================================================================== 会员管理 + 邀请码:账号仍归主站,本页只调会员状态/时长/额度; 注册必须凭一次性邀请码,这里负责生成 / 看状态 / 作废。 ===================================================================== */ const MEMBER_DURATIONS = [ ['', '选择时长'], ['1_month', '1个月'], ['3_months', '3个月'], ['12_months', '12个月'], ['3_years', '3年'], ['permanent', '永久'], ]; function memberExpiryText(u) { if (u.membership_subscribed) return u.membership_expires_at ? '有效至 ' + ymd(u.membership_expires_at) : '永久有效'; if (u.membership_status === 'suspended') return '已于 ' + (ymd(u.membership_expires_at) || '—') + ' 停用'; if (u.membership_status === 'active' && u.membership_expires_at) return '已于 ' + ymd(u.membership_expires_at) + ' 到期'; return '尚未开通'; } function memberStatusValue(u) { if (u.role === 'admin') return 'active'; return u.membership_status || 'inactive'; } function memberIdentityHtml(u) { const parts = []; if (u.role === 'admin') parts.push(tagHtml('cy', '管理员')); if (u.membership_subscribed) parts.push(tagHtml('mint', '会员有效')); else if (u.membership_status === 'suspended') parts.push(tagHtml('rd', '会员已停用')); else parts.push(tagHtml('dashed', '普通用户')); return parts.join(''); } function membersHtml() { const data = state.members; const invites = state.invites; if (!data || !invites) return panelHtml('会员管理', '', '

正在从主站读取会员与邀请码…

'); const users = data.users || []; const memberCount = users.filter((u) => u.membership_subscribed).length; const usage = users.reduce((sum, u) => sum + (Number(u.used_today) || 0), 0); const quota = (data.membership || {}).member_daily_limit || 50; const strip = `
` + `注册用户 ${users.length}` + ` · 会员 ${memberCount}` + ` · 今日调用 ${usage}` + `` + `会员每日调用额度` + `` + `
`; const rows = users.map((u) => { const id = String(u.id); const canEdit = u.role !== 'admin'; return `` + `${esc(u.username)}` + `${memberIdentityHtml(u)}` + `${esc(String(u.used_today ?? 0))}` + `` + `${canEdit ? `` : ''}` + `${esc(memberExpiryText(u))}` + `${canEdit ? `` : ''}` + ``; }).join(''); const memberPanel = panelHtml('会员管理 MEMBERS', `自主站系统管理迁入`, `
` + `` + `${rows || ''}
用户名身份今日调用会员状态开通 / 续期当前到期操作
暂无注册用户
`, false); const codeRows = (invites.codes || []).map((c) => { // 明文只在"还能用"的时候有意义;用掉或作废后立刻退回掩码并忘掉它。 if (c.status !== 'unused') delete state.freshCodes[c.code_id]; const fresh = c.status === 'unused' ? state.freshCodes[c.code_id] : ''; const tone = c.status === 'unused' ? tagHtml('mint', ledHtml('ok') + ' 未使用') : (c.status === 'used' ? tagHtml('dashed', '已使用') : tagHtml('rd', '作废')); const use = c.status === 'used' ? `${esc(stamp(c.used_at))} · ${esc(c.used_by_username || '未知')} 注册成功` : (c.status === 'revoked' ? `${esc(stamp(c.revoked_at))} 管理员作废` : '—'); return `` + `${esc(fresh || c.code_masked)}` + `${tone}` + `${esc(stamp(c.created_at) || '—')}` + `${use}` + `` + (c.status === 'unused' ? `` + `` : '') + ``; }).join(''); const counts = [1, 2, 3, 5, 10].map((n) => ``).join(''); const invitePanel = panelHtml('一次性邀请码 INVITE CODES', `生成` + `` + ``, `
${ledHtml('slow')}每个邀请码仅可成功注册一次 · 注册必须提交有效邀请码 · 并发重复使用只会成功一次,其余请求立即失效
` + `
` + `` + `${codeRows || ''}
邀请码状态生成时间使用情况操作
还没有邀请码
` + `
邀请码仅掩码显示 · 完整码仅在生成瞬间与「复制」动作中可得 · 已使用 / 已作废不可恢复
`, false); return `${strip}
${memberPanel}${invitePanel}
`; } async function loadMembers() { const [members, invites] = await Promise.all([api('/admin/api/members'), api('/admin/api/invites')]); state.members = members; state.invites = invites; } /* ===================================================================== 页面路由 / 顶栏 / EVENT TAPE / 抽屉 / 危险操作弹层 ===================================================================== */ const mainEl = $('mainEl'); function pageFromHash() { const h = location.hash.replace('#', ''); return ['overview', 'sources', 'models', 'members', 'lineage'].includes(h) ? h : 'overview'; } function renderPage(p) { state.page = p; mainEl.dataset.page = p; document.querySelectorAll('[data-nav]').forEach((b) => b.classList.toggle('act', b.dataset.nav === p)); if (p === 'overview') { mainEl.innerHTML = overviewHtml(); paintSparks(); flashChangedLatencies(); } else if (p === 'sources') { mainEl.innerHTML = sourcesHtml(); paintSparks(); } else if (p === 'models') { mainEl.innerHTML = modelsHtml(); restoreVendorDrafts(); if (!state.models) loadModels().then(() => { if (state.page === 'models') renderPage('models'); }).catch((err) => toast(err.message, 'err')); } else if (p === 'members') { mainEl.innerHTML = membersHtml(); if (!state.members) loadMembers().then(() => { if (state.page === 'members') renderPage('members'); }).catch((err) => toast(err.message, 'err')); } else { mainEl.innerHTML = lineageHtml(); const vm = buildLineageVM(); window.__linVM = vm; renderLinRows(vm); applyLinHover(vm); const svg = $('linSvg'); if (svg) svg.addEventListener('mouseleave', () => setLinHover(null, window.__linVM)); fitLineageTable(); } window.scrollTo(0, 0); updatePhaseTag(); } window.addEventListener('hashchange', () => renderPage(pageFromHash())); document.querySelectorAll('[data-nav]').forEach((b) => b.addEventListener('click', () => { location.hash = b.dataset.nav; })); let resizeT = null; window.addEventListener('resize', () => { clearTimeout(resizeT); resizeT = setTimeout(() => { if (state.page === 'lineage') renderPage('lineage'); }, 180); }); /* HEL-529: 血缘详情表随真实数据集数量动态撑高;宽档(>1100px,覆盖 1920×920/1440×900 验收位)给表格容器一个基于剩余可视高度计算的 max-height + 内部滚动,保证整页 scrollHeight 始终 <= clientHeight, 同时全部行仍可在容器内滚动查看。窄档(<=1100px)整页纵滚。 */ function fitLineageTable() { const wrap = document.querySelector('.lin-scroll'); const tape = document.querySelector('.tape'); if (!wrap || !tape) return; wrap.style.maxHeight = ''; if (window.innerWidth <= 1100) return; const tapeTop = tape.getBoundingClientRect().top; const wrapTop = wrap.getBoundingClientRect().top; const mainPadBottom = parseFloat(getComputedStyle(mainEl).paddingBottom) || 0; const avail = tapeTop - wrapTop - mainPadBottom - 4; if (avail > 0) wrap.style.maxHeight = Math.floor(avail) + 'px'; } function updatePhaseTag() { const ov = RAW.overview; if (!ov) return; const bad = (ov.anomalies || []).length > 0; $('radarBlipBad').style.display = bad ? '' : 'none'; $('phaseTag').innerHTML = `${ledHtml('rev', true)} ${esc(ov.session_phase || '')}`; } /* ---------- 模型池 / 会员页的事件(沿用 mainEl 的委托,不另加监听) ---------- */ function handleModelClick(e) { const fetchBtn = e.target.closest('[data-vend-fetch]'); if (fetchBtn) { fetchVendorModels(fetchBtn.dataset.vendFetch); return true; } const saveVendor = e.target.closest('[data-vend-save]'); if (saveVendor) { saveVendorProfile(saveVendor.dataset.vendSave); return true; } const manual = e.target.closest('[data-vend-manual]'); if (manual) { vendorDraft(manual.dataset.vendManual).manual = true; renderPage('models'); return true; } const manualAdd = e.target.closest('[data-vend-manual-add]'); if (manualAdd) { addManualModel(manualAdd.dataset.vendManualAdd); return true; } const add = e.target.closest('[data-vend-add]'); if (add) { addPickedModels(add.dataset.vendAdd); return true; } const test = e.target.closest('[data-model-test]'); if (test) { testModel(test.dataset.modelTest); return true; } const remove = e.target.closest('[data-model-remove]'); if (remove) { removeModel(remove.dataset.modelRemove); return true; } if (e.target.closest('#saveRoles')) { saveRoles(); return true; } if (e.target.closest('#addVendor')) { addVendor(); return true; } return false; } function vendorInputs(baseUrl) { const draft = vendorDraft(baseUrl); const urlEl = document.querySelector(`[data-vend-url="${CSS.escape(baseUrl)}"]`); const keyEl = document.querySelector(`[data-vend-key="${CSS.escape(baseUrl)}"]`); draft.url = (urlEl ? urlEl.value : draft.url || baseUrl).trim(); draft.key = (keyEl ? keyEl.value : draft.key).trim(); return { url: draft.url || baseUrl, key: draft.key }; } function restoreVendorDrafts() { Object.keys(state.vendorDraft).forEach((baseUrl) => { const draft = state.vendorDraft[baseUrl]; const urlEl = document.querySelector(`[data-vend-url="${CSS.escape(baseUrl)}"]`); const keyEl = document.querySelector(`[data-vend-key="${CSS.escape(baseUrl)}"]`); if (urlEl && draft.url) urlEl.value = draft.url; if (keyEl && draft.key) keyEl.value = draft.key; }); } function vendorGroup(baseUrl) { return (state.models.groups || []).find((group) => group.base_url === baseUrl) || { models: [], configured: false }; } async function fetchVendorModels(baseUrl) { const { url, key } = vendorInputs(baseUrl); const draft = vendorDraft(baseUrl); if (!url) { toast('请先填写 API BASE URL', 'err'); return; } if (!key && !vendorGroup(baseUrl).configured) { toast('请先填写 API Key,再拉取模型列表', 'err'); return; } draft.fetching = true; draft.error = ''; renderPage('models'); try { const result = await api('/admin/api/models/fetch', { method: 'POST', body: JSON.stringify({ base_url: url, api_key: key }) }); draft.fetched = result.models || []; draft.fetchedAt = `今天 ${p2(new Date().getHours())}:${p2(new Date().getMinutes())}`; if (!draft.fetched.length) draft.error = '供应商未返回可用模型'; } catch (err) { draft.fetched = null; draft.error = err.message; } draft.fetching = false; renderPage('models'); } function nextModelId(model) { // 主站要求模型 ID 匹配 [A-Za-z0-9_-]{3,80},这里把模型名压成合法且唯一的 ID。 const used = new Set(state.models.models.map((m) => m.id)); let base = model.replace(/[^A-Za-z0-9]+/g, '-').toLowerCase().replace(/^-+|-+$/g, '').slice(0, 70); if (base.length < 3) base = `model-${base}`.slice(0, 70); let id = base; let i = 2; while (used.has(id)) { id = `${base}-${i}`; i += 1; } return id; } function rebasedModels(baseUrl, url, key) { // 改地址等于整组搬迁;Key 留空表示沿用主站已存的那把。 return state.models.models.map((model) => { if (model.base_url !== baseUrl) return Object.assign({}, model); const moved = Object.assign({}, model, { base_url: url }); if (key) moved.api_key = key; return moved; }); } async function saveVendorProfile(baseUrl) { const { url, key } = vendorInputs(baseUrl); if (!url) { toast('请先填写 API BASE URL', 'err'); return; } const group = vendorGroup(baseUrl); if (!group.models.length) { if (!key) { toast('新供应商需要先填写 API Key,再拉取或手动录入模型', 'err'); return; } toast('该供应商还没有模型,请先拉取或手动录入', 'err'); return; } try { await saveModels(rebasedModels(baseUrl, url, key)); vendorDraft(baseUrl).key = ''; toast('供应商已保存', 'ok'); } catch (err) { toast(err.message, 'err'); } } async function addModels(baseUrl, names) { const { url, key } = vendorInputs(baseUrl); if (!key && !vendorGroup(baseUrl).configured) { toast('新供应商需要先填写 API Key', 'err'); return; } const models = rebasedModels(baseUrl, url, key); const existingNames = new Set(models.map((model) => String(model.name || ''))); const added = []; names.forEach((name) => { // 主站要求模型名称全局唯一,同名模型接在供应商域名后区分。 let label = name; if (existingNames.has(label)) label = `${name} @ ${url.split('//').pop().split('/')[0]}`.slice(0, 50); if (existingNames.has(label)) return; existingNames.add(label); const entry = { id: nextModelId(name), name: label, model: name, base_url: url }; if (key) entry.api_key = key; models.push(entry); added.push(entry); }); if (!added.length) { toast('这些模型已在池中', 'err'); return; } const draft = vendorDraft(baseUrl); draft.fetched = null; draft.manual = false; draft.key = ''; const extra = {}; // 池子非空时主站强制要求主模型在池内,首个模型自动兼任主模型。 if (!state.models.primary_model_id) extra.primary_model_id = added[0].id; await saveModels(models, extra); toast(`已加入 ${added.length} 个模型`, 'ok'); } function addPickedModels(baseUrl) { const picks = Array.from(document.querySelectorAll(`[data-vend-pick="${CSS.escape(baseUrl)}"]`)) .filter((box) => box.checked && !box.disabled).map((box) => box.value); if (!picks.length) { toast('请先勾选要纳入的模型', 'err'); return; } addModels(baseUrl, picks).catch((err) => toast(err.message, 'err')); } function addManualModel(baseUrl) { const input = document.querySelector(`[data-vend-manual-input="${CSS.escape(baseUrl)}"]`); const name = input ? input.value.trim() : ''; if (!name) { toast('请填写模型标识', 'err'); return; } addModels(baseUrl, [name]).catch((err) => toast(err.message, 'err')); } async function testModel(modelId) { state.modelTests[modelId] = { pending: true }; renderPage('models'); try { const response = await api('/admin/api/models/test', { method: 'POST', body: JSON.stringify({ model_id: modelId }) }); const detail = response.result || {}; state.modelTests[modelId] = { ok: Boolean(detail.ok), ms: detail.latency_ms || 0, error: plainError(String(detail.error || '失败'), 18), }; toast(detail.ok ? '模型连通' : '模型不可用:' + (detail.error || '未知错误'), detail.ok ? 'ok' : 'err'); } catch (err) { state.modelTests[modelId] = { ok: false, ms: 0, error: plainError(err.message, 18) }; toast(err.message, 'err'); } renderPage('models'); } async function removeModel(modelId) { const data = state.models; if (modelId === data.primary_model_id || modelId === data.fallback_model_id) { toast('该模型正被主/辅分工引用,请先在「调用编排」里换掉再删除', 'err'); return; } if (!window.confirm('确定从模型池移除该模型?')) return; try { await saveModels(data.models.filter((model) => model.id !== modelId)); toast('模型已移除', 'ok'); } catch (err) { toast(err.message, 'err'); } } async function saveRoles() { const primary = $('primaryModel').value; const fallback = $('fallbackModel').value; if (primary && fallback && primary === fallback) { toast('主模型与辅助模型不能相同', 'err'); return; } try { await saveModels(state.models.models, { primary_model_id: primary, fallback_model_id: fallback }); toast('模型分工已保存', 'ok'); } catch (err) { toast(err.message, 'err'); } } function addVendor() { const url = ($('newVendorUrl').value || $('newVendorPreset').value).trim(); if (!url) { toast('请先选择或填写供应商接口地址', 'err'); return; } if (state.models.groups.some((group) => group.base_url === url)) { toast('该供应商已在列表中', 'err'); return; } const preset = state.models.vendors.find((vendor) => vendor.base_url === url); state.models.groups.push({ base_url: url, label: (preset || {}).label || url.split('//').pop().split('/')[0], configured: false, key_last4: '', models: [], }); renderPage('models'); } function handleMemberClick(e) { const save = e.target.closest('[data-member-save]'); if (save) { saveMember(save.dataset.memberSave); return true; } if (e.target.closest('#saveQuota')) { saveQuota(); return true; } if (e.target.closest('#createInvites')) { createInvites(); return true; } const copy = e.target.closest('[data-invite-copy]'); if (copy) { copyInvite(copy.dataset.inviteCopy); return true; } const revoke = e.target.closest('[data-invite-revoke]'); if (revoke) { revokeInvite(revoke.dataset.inviteRevoke); return true; } return false; } async function saveMember(userId) { const status = document.querySelector(`[data-member-status="${CSS.escape(userId)}"]`); const duration = document.querySelector(`[data-member-duration="${CSS.escape(userId)}"]`); const payload = { user_id: Number(userId), status: status ? status.value : 'inactive' }; if (payload.status === 'active') { payload.duration = duration ? duration.value : ''; if (!payload.duration) { toast('开通或续期需要先选择时长', 'err'); return; } } try { state.members = await api('/admin/api/members/save', { method: 'POST', body: JSON.stringify(payload) }); renderPage('members'); toast('会员设置已保存', 'ok'); } catch (err) { toast(err.message, 'err'); } } async function saveQuota() { const value = Number($('memberQuota').value || 0); if (!Number.isFinite(value) || value < 1) { toast('每日调用额度至少为 1', 'err'); return; } try { state.members = await api('/admin/api/members/quota', { method: 'POST', body: JSON.stringify({ member_daily_limit: value }) }); renderPage('members'); toast('会员调用额度已保存', 'ok'); } catch (err) { toast(err.message, 'err'); } } async function createInvites() { const count = Number($('inviteCount').value || 1); try { const result = await api('/admin/api/invites/create', { method: 'POST', body: JSON.stringify({ count: count }) }); // 完整邀请码只在本次响应里出现,之后列表只给掩码,所以先缓存在内存里供复制。 (result.created || []).forEach((item) => { state.freshCodes[item.code_id] = item.code; }); state.invites = { summary: result.summary, codes: result.codes }; renderPage('members'); toast(`已生成 ${(result.created || []).length} 个邀请码,请立即复制`, 'ok'); } catch (err) { toast(err.message, 'err'); } } async function copyInvite(codeId) { const code = state.freshCodes[codeId]; if (!code) { toast('完整邀请码仅在生成后当次可见,请重新生成', 'err'); return; } try { await navigator.clipboard.writeText(code); toast('邀请码已复制:' + code, 'ok'); } catch { toast('复制失败,请手动记录:' + code, 'err'); } } async function revokeInvite(codeId) { if (!window.confirm('作废后该邀请码将无法用于注册,确定继续?')) return; try { state.invites = await api('/admin/api/invites/revoke', { method: 'POST', body: JSON.stringify({ code_id: codeId }) }); renderPage('members'); toast('邀请码已作废', 'ok'); } catch (err) { toast(err.message, 'err'); } } mainEl.addEventListener('click', (e) => { const pb = e.target.closest('[data-probe]'); if (pb) { doProbe(pb.dataset.probe); return; } const tg = e.target.closest('[data-toggle]'); if (tg) { const ui = srcUiState[tg.dataset.toggle]; if (ui) { ui.open = !ui.open; renderSourceCards(); } return; } const cs = e.target.closest('[data-cred-save]'); if (cs) { saveCredential(cs.dataset.credSave); return; } const ct = e.target.closest('[data-cred-test]'); if (ct) { doProbe(ct.dataset.credTest); return; } if (handleModelClick(e) || handleMemberClick(e)) return; const goto = e.target.closest('[data-goto-lineage]'); if (goto) { linFilter.q = ''; location.hash = 'lineage'; return; } const inc = e.target.closest('[data-incident-action]'); if (inc) { openOpsDrawer('release', { dataset: inc.dataset.dataset, provider: inc.dataset.provider }); return; } if (e.target.closest('#linBad')) { linFilter.onlyBad = !linFilter.onlyBad; renderLinRows(window.__linVM); const btn = $('linBad'); btn.classList.toggle('on', linFilter.onlyBad); btn.innerHTML = `${ledHtml(linFilter.onlyBad ? 'fail' : 'plan')} 只看异常`; return; } }); mainEl.addEventListener('change', (e) => { if (e.target.id === 'newVendorPreset' && e.target.value) { $('newVendorUrl').value = e.target.value; return; } if (e.target.id === 'linSel') { linFilter.src = e.target.value.split('· ')[1] || '全部'; renderLinRows(window.__linVM); } }); mainEl.addEventListener('input', (e) => { if (e.target.id === 'linQ') { linFilter.q = e.target.value; renderLinRows(window.__linVM); } }); mainEl.addEventListener('mouseover', (e) => { const g = e.target.closest('g[data-nid]'); if (g) setLinHover(g.dataset.nid, window.__linVM); }); async function doProbe(id) { const ui = srcUiState[id]; if (!ui || ui.probing) return; ui.probing = true; renderSourceCards(); try { await api(`/admin/api/sources/${id}/probe`, { method: 'POST', body: '{}' }); ui.probeResult = { t: `${p2(new Date().getHours())}:${p2(new Date().getMinutes())}`, ok: true, ms: null }; } catch (err) { ui.probeResult = { t: `${p2(new Date().getHours())}:${p2(new Date().getMinutes())}`, ok: false, ms: null }; toast(err.message, 'err'); } ui.probing = false; await refreshQuiet(); renderSourceCards(); } /* ---------- EVENT TAPE:预装最近真实事件 + 持续滚动 + 新事件闪动 ---------- */ const tapeSeen = { call: 0, provCall: 0, audit: 0, job: 0 }; let tapeBuf = []; let tapeSeeded = false; function tapeEventHtml(e, fresh) { return `${esc(hm(e.ts))}${esc(e.src)}${esc(e.msg)}` + (e.ok ? `✓${e.ms ? ` ${e.ms}ms` : ''}` : '× 失败') + `///`; } function renderTape(freshIds) { const items = tapeBuf.slice(-14); const html = items.length ? items.map((e) => tapeEventHtml(e, freshIds && freshIds.has(e.sortId))).join('') : `暂无真实事件 · 等待首次调用/任务///`; $('tapeA').innerHTML = html; $('tapeB').innerHTML = html; } function tapeEventsFromRaw() { const evs = []; ((RAW.overview && RAW.overview.recent_calls) || []).forEach((c) => { if (c.id > tapeSeen.call) evs.push({ ts: c.created_at, src: 'tushare', msg: c.endpoint, ok: c.ok, ms: c.latency_ms, sortId: 'c' + c.id }); }); ((RAW.providers && RAW.providers.recent_calls) || []).forEach((c) => { if (c.id > tapeSeen.provCall) evs.push({ ts: c.fetched_at, src: c.provider, msg: c.interface, ok: c.status === 'ok', ms: c.latency_ms, sortId: 'p' + c.id }); }); ((RAW.audit && RAW.audit.items) || []).forEach((a) => { if (a.id > tapeSeen.audit) evs.push({ ts: a.created_at, src: 'hub_admin', msg: `${a.action} ${a.target || ''}`.trim(), ok: true, sortId: 'a' + a.id }); }); ((RAW.jobs && RAW.jobs.runs) || []).forEach((r) => { if (r.id > tapeSeen.job) evs.push({ ts: r.finished_at || r.started_at, src: 'scheduler', msg: `${r.job_id} ${r.state}`, ok: r.state !== 'failed', sortId: 'j' + r.id }); }); const maxOf = (arr, key) => arr.reduce((m, x) => Math.max(m, x[key] || 0), 0); tapeSeen.call = Math.max(tapeSeen.call, maxOf((RAW.overview && RAW.overview.recent_calls) || [], 'id')); tapeSeen.provCall = Math.max(tapeSeen.provCall, maxOf((RAW.providers && RAW.providers.recent_calls) || [], 'id')); tapeSeen.audit = Math.max(tapeSeen.audit, maxOf((RAW.audit && RAW.audit.items) || [], 'id')); tapeSeen.job = Math.max(tapeSeen.job, maxOf((RAW.jobs && RAW.jobs.runs) || [], 'id')); return evs; } function seedTape() { // 首次加载:预装最近的真实事件(打样 EVENT TAPE 开场即有内容滚动) const evs = []; ((RAW.providers && RAW.providers.recent_calls) || []).forEach((c) => evs.push({ ts: c.fetched_at, src: c.provider, msg: c.interface, ok: c.status === 'ok', ms: c.latency_ms, sortId: 'p' + c.id })); ((RAW.overview && RAW.overview.recent_calls) || []).forEach((c) => evs.push({ ts: c.created_at, src: 'tushare', msg: c.endpoint, ok: c.ok, ms: c.latency_ms, sortId: 'c' + c.id })); ((RAW.jobs && RAW.jobs.runs) || []).forEach((r) => evs.push({ ts: r.finished_at || r.started_at, src: 'scheduler', msg: `${r.job_id} ${r.state}`, ok: r.state !== 'failed', sortId: 'j' + r.id })); ((RAW.audit && RAW.audit.items) || []).forEach((a) => evs.push({ ts: a.created_at, src: 'hub_admin', msg: `${a.action} ${a.target || ''}`.trim(), ok: true, sortId: 'a' + a.id })); evs.sort((x, y) => String(x.ts).localeCompare(String(y.ts))); tapeBuf = evs.slice(-14); tapeSeeded = true; renderTape(null); } function updateTape() { if (!tapeSeeded) { seedTape(); return; } const evs = tapeEventsFromRaw(); if (!evs.length) return; const fresh = new Set(evs.map((e) => e.sortId)); tapeBuf = tapeBuf.concat(evs).slice(-16); renderTape(fresh); } /* ---------- 抽屉:调度任务 / 盘后发布 / 审计 ---------- */ let drawerTab = 'jobs'; function openOpsDrawer(tab, ctx) { drawerTab = tab || 'jobs'; drawerCtx = ctx || {}; $('drawerMask').classList.add('open'); renderDrawer(); } function closeOpsDrawer() { $('drawerMask').classList.remove('open'); } let drawerCtx = {}; $('drawerClose').addEventListener('click', closeOpsDrawer); $('drawerMask').addEventListener('click', (e) => { if (e.target.id === 'drawerMask') closeOpsDrawer(); }); $('opsBtn').addEventListener('click', () => openOpsDrawer('jobs', {})); $('drawerTabs').addEventListener('click', (e) => { const b = e.target.closest('[data-dtab]'); if (!b) return; drawerTab = b.dataset.dtab; renderDrawer(); }); function renderDrawer() { $('drawerTabs').querySelectorAll('[data-dtab]').forEach((b) => b.classList.toggle('act', b.dataset.dtab === drawerTab)); $('drawerSub').textContent = { jobs: '既有调度手动触发', release: '盘后发布 / 回滚 / 补数', audit: '操作审计日志' }[drawerTab] || ''; if (drawerTab === 'jobs') renderDrawerJobs(); else if (drawerTab === 'release') renderDrawerRelease(); else renderDrawerAudit(); } function renderDrawerJobs() { const jobs = (RAW.jobs && RAW.jobs.jobs) || []; const runs = (RAW.jobs && RAW.jobs.runs) || []; const runsByJob = {}; runs.forEach((r) => { (runsByJob[r.job_id] = runsByJob[r.job_id] || []).push(r); }); $('drawerBody').innerHTML = `
调度任务定义
${jobs.map((j) => ``).join('')}
任务时刻操作
${esc(j.id)} · ${esc(j.title)}${esc(j.at)}
最近运行(最新 20 条)
${runs.slice(0, 20).map((r) => ``).join('') || ''}
ID任务状态开始结束错误
${r.id}${esc(r.job_id)}${pillHtml(bucketOf(r.state))}${esc(r.started_at || '')}${esc(r.finished_at || '')}${esc(r.error || '')}
暂无运行记录
`; $('drawerBody').querySelectorAll('[data-run]').forEach((btn) => { btn.addEventListener('click', async () => { const dateInput = $('drawerBody').querySelector(`[data-run-date="${btn.dataset.run}"]`); try { await api(`/admin/api/jobs/${btn.dataset.run}/run`, { method: 'POST', body: JSON.stringify({ trade_date: (dateInput && dateInput.value) || '' }) }); toast('已触发:' + btn.dataset.run, 'ok'); await refreshQuiet(); renderDrawer(); } catch (err) { toast(err.message, 'err'); } }); }); } function renderDrawerRelease() { const date = drawerCtx.date || today(); $('drawerBody').innerHTML = `
`; $('relLoad').addEventListener('click', async () => { drawerCtx.date = $('relDate').value; await loadRelease(); }); $('relBackfill').addEventListener('click', () => openDangerModal('backfill', { dataset: drawerCtx.dataset || '', date: drawerCtx.date || date })); loadRelease(); } async function loadRelease() { const date = drawerCtx.date || today(); let data; try { data = await api(`/admin/api/batches?date=${encodeURIComponent(date)}`); } catch (err) { toast(err.message, 'err'); return; } $('relBody').innerHTML = `
发布映射
${data.publications.map((row) => ``).join('') || ''}
数据集活跃批次上一批次状态发布时间操作
${esc(row.dataset)}${esc(row.active_batch)}${esc(row.prev_batch || '')}${pillHtml(bucketOf(row.state))}${esc(row.published_at || '')} ${row.prev_batch ? `` : '—'}
当日暂无发布记录
批次
${data.batches.map((row) => ``).join('') || ''}
batch_id数据集状态行数错误
${esc(row.batch_id)}${esc(row.dataset)}${pillHtml(bucketOf(row.state))}${row.rows_out ?? ''}${esc(row.error || '')}
当日暂无批次
`; $('relBody').querySelectorAll('[data-rollback]').forEach((btn) => btn.addEventListener('click', () => openDangerModal('rollback', { dataset: btn.dataset.rollback, date }))); } function renderDrawerAudit() { const items = (RAW.audit && RAW.audit.items) || []; $('drawerBody').innerHTML = ` ${items.map((row) => ``).join('') || ''}
时间操作者动作对象详情
${esc(row.created_at)}${esc(row.actor)}${esc(row.action)}${esc(row.target || '')}${esc(row.detail || '')}
暂无审计记录
`; } /* ---------- 危险操作确认弹层(回滚 / 补数) ---------- */ let dangerCtx = null; function openDangerModal(kind, ctx) { dangerCtx = { kind, ...ctx }; const word = `${ctx.dataset}:${ctx.date}`; $('modalTitle').textContent = kind === 'rollback' ? '确认回滚' : '确认补数'; $('modalDesc').textContent = kind === 'rollback' ? `将把 ${ctx.dataset} 在 ${ctx.date} 的发布回退到上一批次,需管理密码与确认词。` : `将对 ${ctx.dataset || '(请先在发布列表选择数据集)'} 于 ${ctx.date} 重新补数并发布,需管理密码与确认词。`; $('modalConfirmWord').textContent = word; $('modalPassword').value = ''; $('modalConfirm').value = ''; $('modalErr').textContent = ''; $('modalMask').classList.add('open'); } $('modalCancel').addEventListener('click', () => $('modalMask').classList.remove('open')); $('modalMask').addEventListener('click', (e) => { if (e.target.id === 'modalMask') $('modalMask').classList.remove('open'); }); $('modalOk').addEventListener('click', async () => { if (!dangerCtx) return; const password = $('modalPassword').value; const confirm = $('modalConfirm').value; const path = dangerCtx.kind === 'rollback' ? '/admin/api/rollback' : '/admin/api/backfill'; try { await api(path, { method: 'POST', body: JSON.stringify({ dataset: dangerCtx.dataset, trade_date: dangerCtx.date, password, confirm }) }); $('modalMask').classList.remove('open'); toast('操作成功', 'ok'); await refreshQuiet(); if (state.page === 'overview') renderPage('overview'); if (drawerTab === 'release') loadRelease(); } catch (err) { $('modalErr').textContent = err.message; } }); /* ---------- toast ---------- */ let toastT = null; function toast(msg, kind) { const el = $('toastEl'); el.textContent = msg; el.className = 'toast show' + (kind ? ' ' + kind : ''); clearTimeout(toastT); toastT = setTimeout(() => { el.classList.remove('show'); }, 3200); } /* ================= 顶栏:时钟 / 减少动态 ================= */ let clockEls = null; function updateClock() { if (!clockEls) clockEls = { d: $('ckD'), h: $('ckH'), m: $('ckM'), s: $('ckS') }; const now = new Date(); const ds = `${now.getFullYear()}-${p2(now.getMonth() + 1)}-${p2(now.getDate())}`; if (clockEls.d.textContent !== ds) clockEls.d.textContent = ds; const hs = p2(now.getHours()), ms = p2(now.getMinutes()), ss = p2(now.getSeconds()); if (clockEls.h.textContent !== hs) clockEls.h.textContent = hs; if (clockEls.m.textContent !== ms) clockEls.m.textContent = ms; if (clockEls.s.textContent !== ss) clockEls.s.textContent = ss; } function applyCalm(on) { state.calm = on; // 挂在 body + #app(覆盖 appRoot 及其兄弟 drawer/modal/toast),否则 // .reduce-motion * 选不中旁路层过渡。 document.body.classList.toggle('reduce-motion', on); const app = $('app'); if (app) app.classList.toggle('reduce-motion', on); $('calmBtn').textContent = on ? '恢复动态' : '减少动态'; $('calmBtn').classList.toggle('on', on); } $('calmBtn').addEventListener('click', () => applyCalm(!state.calm)); if (window.matchMedia) { const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); const syncMq = () => { if (mq.matches && !state.calm) applyCalm(true); }; syncMq(); mq.addEventListener ? mq.addEventListener('change', syncMq) : mq.addListener(syncMq); } /* ================= 动态更新:200ms tick(打样节奏) ================= - 时钟每秒翻字(冒号 blink 由 CSS 承担) - 缓存年龄依据真实时间戳每秒增长(>30s 变琥珀) - 倒计时按真实 next_retry_at 每秒递减 - sparkline 端点闪烁由 CSS 承担,折线只在真实历史变化时重画 - 延迟数字只在真实值变化时闪一下(flash),绝不随机伪造数值 */ function paintSparks() { document.querySelectorAll('svg.spark').forEach((svg) => sparkPaint(svg)); } const prevLat = new Map(); function flashChangedLatencies() { document.querySelectorAll('[data-rate]').forEach((el) => { const v = el.textContent; 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); }); 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 ? 'var(--amb)' : 'var(--mut)'; if (el.style.color !== color) el.style.color = color; }); document.querySelectorAll('[data-cd-epoch]').forEach((el) => { const target = +el.dataset.cdEpoch; if (!target || Number.isNaN(target)) return; const left = Math.floor((target - now) / 1000); if (left > 0) { const m = Math.floor(left / 60), s = left % 60; const txt = el.dataset.cdfmt === 'ms' ? `${m}m${p2(s)}s` : `${p2(m)}:${p2(s)}`; if (el.textContent !== txt) el.textContent = txt; } else if (el.textContent !== '已到点') { el.textContent = '已到点'; } }); } /* ================= 轮询:拉取真实数据、驱动渲染、可见性/网络门禁 ================= */ const dataVersion = { overview: '', sources: '', lineage: '' }; function snapshotVersion(part) { if (part === 'overview') return JSON.stringify([RAW.overview, RAW.batches && RAW.batches.batches, RAW.providers && RAW.providers.health, RAW.catalog]); if (part === 'sources') return JSON.stringify([RAW.catalog, RAW.providers && RAW.providers.recent_calls, RAW.sources]); return JSON.stringify(RAW.lineage); } const Poller = (() => { let timer = null; let running = false; const INTERVAL = 4000; async function tick() { if (!running) return; try { await fetchAll(); updateTape(); const vOv = snapshotVersion('overview'), vSrc = snapshotVersion('sources'), vLin = snapshotVersion('lineage'); if (state.page === 'overview' && vOv !== dataVersion.overview) { dataVersion.overview = vOv; mainEl.innerHTML = overviewHtml(); paintSparks(); flashChangedLatencies(); } else if (state.page === 'overview') { flashChangedLatencies(); } if (state.page === 'sources' && vSrc !== dataVersion.sources) { dataVersion.sources = vSrc; renderSourceCards(); } if (state.page === 'lineage' && vLin !== dataVersion.lineage) { dataVersion.lineage = vLin; mainEl.innerHTML = lineageHtml(); const vm = buildLineageVM(); window.__linVM = vm; renderLinRows(vm); applyLinHover(vm); const svg = $('linSvg'); if (svg) svg.addEventListener('mouseleave', () => setLinHover(null, window.__linVM)); fitLineageTable(); } // 真实调用事件脉冲:血缘页上对应来源节点亮一圈 ledPing 节奏的光环 pulseSourcesOnNewCalls(); updatePhaseTag(); setNetOk(true); } catch (err) { setNetOk(false); } } function setNetOk(ok) { $('liveSpan').style.opacity = ok ? '1' : '.35'; } function scheduleNext() { timer = setTimeout(async () => { await tick(); scheduleNext(); }, INTERVAL); } return { async start() { if (running) return; running = true; await tick(); scheduleNext(); }, stop() { running = false; if (timer) clearTimeout(timer); timer = null; }, isRunning() { return running; }, }; })(); /* 真实调用脉冲:provider_call_log 出现新调用时,给血缘图对应来源节点 叠一圈与 ledPing 同节奏(1.6s ease-out)的扩散光环;这是事件动效, 与 gnode-pulse 的静态呼吸不同,一次即消散。 */ let pulseSeenCall = 0; function pulseSourcesOnNewCalls() { if (state.page !== 'lineage') { pulseSeenCall = ((RAW.providers && RAW.providers.recent_calls) || []).reduce((m, c) => Math.max(m, c.id || 0), pulseSeenCall); return; } const calls = (RAW.providers && RAW.providers.recent_calls) || []; const fresh = calls.filter((c) => (c.id || 0) > pulseSeenCall); pulseSeenCall = calls.reduce((m, c) => Math.max(m, c.id || 0), pulseSeenCall); fresh.forEach((c) => { const g = document.querySelector(`#linSvg g[data-nid="${CSS.escape(c.provider)}"]`); if (!g) return; const circle = g.querySelector('circle'); if (!circle) return; const ring = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); ring.setAttribute('cx', circle.getAttribute('cx')); ring.setAttribute('cy', circle.getAttribute('cy')); ring.setAttribute('r', '5'); ring.setAttribute('fill', 'none'); ring.setAttribute('stroke-width', '1.2'); ring.style.stroke = 'var(--cy)'; ring.setAttribute('class', 'node-ping'); g.appendChild(ring); setTimeout(() => ring.remove(), 1700); }); } function resetTapeBaselines() { // 恢复轮询时静默重建基线,不补播暂停期间错过的事件(但保留已上带内容)。 tapeSeen.call = tapeSeen.provCall = tapeSeen.audit = tapeSeen.job = Number.MAX_SAFE_INTEGER; } document.addEventListener('visibilitychange', () => { if (document.hidden) { Poller.stop(); } else if (!$('appRoot').hidden) { resetTapeBaselines(); Poller.start(); } }); window.addEventListener('offline', () => { Poller.stop(); $('liveSpan').style.opacity = '.35'; }); window.addEventListener('online', () => { if (!$('appRoot').hidden && !document.hidden) { resetTapeBaselines(); Poller.start(); } }); async function refreshQuiet() { try { await fetchAll(); } catch {} } /* ================= 启动 ================= */ async function startApp() { await fetchAll(); dataVersion.overview = snapshotVersion('overview'); dataVersion.sources = snapshotVersion('sources'); dataVersion.lineage = snapshotVersion('lineage'); pulseSeenCall = ((RAW.providers && RAW.providers.recent_calls) || []).reduce((m, c) => Math.max(m, c.id || 0), 0); renderPage(pageFromHash()); updateTape(); // 预装最近真实事件并开始滚动 updateClock(); setInterval(() => { if (document.hidden) return; updateClock(); updateDynamics(); }, 200); // 打样 tick 节奏;页面隐藏时不做任何工作 await Poller.start(); } boot();