Files
xiaobai-review/xiaobai-datahub/admin/app.js
T
施工员andmultica-agent 3eaa36a8d5 feat(HEL-560): 数据中枢接管数据源/模型池/会员,注册改一次性邀请码
主站
- 新增 m0006 invite_codes 迁移;注册强制邀请码(首个管理员除外),消码与建号
  同一事务,并发提交只有一个能成功
- 新增 /api/hub-admin/* 服务端点(共享 HUB_ADMIN_TOKEN,先于鉴权校验),供数据
  中枢桥接读写会话/密码/模型池/会员/邀请码,并提供供应商模型列表拉取
- 前端:注册表单加邀请码(桌面 login、index.html、移动端);「系统管理」改为
  「数据中枢」入口指向 8766,原模型池与会员管理分区移除,仅留「行情管理」;
  随之清理陈旧 CSS

数据中枢
- 取消独立账号:删除 hub_admin/hub_sessions 与登录、改密、锁定逻辑,改为校验
  主站 xiaobai_session,仅管理员可进,CSRF 由会话派生,危险操作二次确认走主站
- 控制台新增数据源凭证可编辑区(原有内容一项不删)、供应商制模型池(自动拉取
  /models,失败退回卡内手动录入)、会员管理与邀请码页
- 日夜双主题:颜色收敛为同名 token 换值,SVG 改用 inline style 以吃到变量

自测
- 主站 verify_baseline 通过(498 项);数据中枢 235 项通过
- tools/verify_datahub_console.py 端到端跑通两服务真实对话;
  tools/verify_datahub_console_ui.py 浏览器跑通门禁/凭证/模型池/会员/主题/1030 窄屏

Co-authored-by: multica-agent <github@multica.ai>
2026-09-16 11:44:09 +08:00

2036 lines
116 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* =====================================================================
小白复盘 · 数据中枢 admin/app.js — HEL-529 返工:按定稿 100% 还原
视觉与 DOM 结构逐行照抄已确认打样 hub-kimi.htmlv12 血缘压缩增补),
但本文件不含任何随机/模拟数据——全部状态、数字、事件均来自真实接口:
/admin/api/{overview,sources,jobs,batches,audit,...} 与 HEL-543 只读旁路
/admin/api/{providers/status,source-catalog,lineage,lineage/affected}。
动效遵循 SPEC-V11marquee 46s 线性 / flowline 1.4s / radar 4.2s /
blink 1.1s steps(2) / ledPing 1.6s / sparkBlink 1.4s / 时钟冒号 blink /
延迟仅在真实值变化时闪烁(禁止随机伪造数值)/ 缓存年龄每秒真实增长。
===================================================================== */
/* ================= 基础:DOM helper / API / CSRF ================= */
function $(id) { return document.getElementById(id); }
function esc(v) { return String(v ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
const state = {
csrf: '', page: 'overview', calm: false, username: '',
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 `<span class="led ${st}${pulse ? ' pulse' : ''}"></span>`; }
function pillHtml(bucket, label) { const h = HEALTH[bucket] || HEALTH.off; return `<span class="spill" style="color:${h.color}">${ledHtml(h.led, bucket === 'fail' || bucket === 'review')}${label || h.label}</span>`; }
function tagHtml(tone, inner) { return `<span class="tag ${tone}">${inner}</span>`; }
function panelHtml(title, right, body, pad) {
return `<section class="panel"><header class="panel-hd"><span><span style="color:var(--cy);margin-right:6px">//</span><b>${title}</b></span>${right ? `<span style="display:flex;align-items:center;gap:8px">${right}</span>` : ''}</header><div class="${pad === false ? '' : 'panel-b'}">${body}</div></section>`;
}
function kpiHtml(label, value, sub, tone, live) {
const tc = { txt: '', mint: 'glow-mint', amb: 'glow-amb', rd: 'glow-rd', cy: 'glow-cy' }[tone || 'txt'];
return `<div class="kpi"><span class="lab">${label}</span><span class="num kv ${tc}">${value}${live ? '<span class="blink" style="color:var(--cy);margin-left:2px">_</span>' : ''}</span>${sub ? `<span class="ks">${sub}</span>` : ''}</div>`;
}
/* 延迟:真实值 + 变化时才闪(打样 jit 的真实数据版:不随机抖动数字,
仅当新一轮真实调用改写该值时给 .flash 青色闪烁)。 */
function latHtml(ms, warn) {
if (ms == null) return '<span style="color:var(--dim)">—</span>';
const color = ms > 1000 ? 'var(--amb)' : (warn ? 'var(--amb)' : 'var(--txt)');
return `<span class="num jit" data-lat="${Number(ms)}" style="color:${color}">${Number(ms).toLocaleString('en-US')}ms</span>`;
}
/* 缓存年龄:记录真实时间戳,由 updateDynamics 每秒重算(真实增长)。 */
function ageHtml(iso) {
const ms = Date.parse(String(iso || '').includes('T') ? iso : String(iso || '').replace(' ', 'T'));
if (!iso || Number.isNaN(ms)) return '<span style="color:var(--dim)">—</span>';
return `<span class="num" data-age-epoch="${ms}" style="color:var(--mut)">—</span>`;
}
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 ellipstyles.css 生产补充段),title 放全文 */
function ellipHtml(text, color, fontSize, title) {
return `<span class="ellip" style="color:${color};font-size:${fontSize || ''}" title="${esc(title ?? text)}">${esc(text)}</span>`;
}
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 `<svg width="${w}" height="${h}" class="spark" data-jid="${esc(jid)}" data-w="${w}" data-h="${h}" style="display:block;opacity:.9"><polyline points="" fill="none" style="stroke:${SPARK_COLORS[tone]}" stroke-width="1.2" stroke-opacity=".8"/><circle class="spark-end" r="2" style="fill:${SPARK_COLORS[tone]}" cx="0" cy="0"/></svg>`;
}
function sparkSeries(jid) {
const hist = LatHist.get(jid) || [];
if (hist.length < 2) return null;
const max = Math.max(...hist, 1);
return hist.map((v) => v / max);
}
function sparkPaint(svg) {
const jid = svg.dataset.jid, w = +svg.dataset.w, h = +svg.dataset.h;
const data = sparkSeries(jid);
const poly = svg.querySelector('polyline');
const dot = svg.querySelector('circle');
if (!data) {
const y = (h / 2).toFixed(2);
poly.setAttribute('points', `0,${y} ${w},${y}`);
poly.setAttribute('stroke-opacity', '.25');
dot.style.display = 'none';
return;
}
poly.setAttribute('stroke-opacity', '.8');
dot.style.display = '';
const pts = data.map((v, i) => `${((i / (data.length - 1)) * w).toFixed(2)},${(h - v * (h - 3) - 1).toFixed(2)}`).join(' ');
poly.setAttribute('points', pts);
dot.setAttribute('cx', (w - 1).toFixed(2));
dot.setAttribute('cy', (h - data[data.length - 1] * (h - 3) - 1).toFixed(2));
}
/* 每个“观测对象”(接口或数据集)的真实延迟历史(旧→新),来自
provider_call_log 最近 200 条。 */
const LatHist = new Map();
const CALL_MATCHERS = {};
function buildLatHist() {
const calls = (RAW.providers && RAW.providers.recent_calls) || [];
const buckets = new Map();
[...calls].reverse().forEach((c) => {
if (c.latency_ms == null) return;
const key = `${c.provider}:${c.interface}`;
(buckets.get(key) || buckets.set(key, []).get(key)).push(c.latency_ms);
});
LatHist.clear();
buckets.forEach((arr, key) => LatHist.set(key, arr.slice(-24)));
// 数据集维度的历史:tushare 观测行写的是数据集名,直接按数据集聚合
const dsBuckets = new Map();
[...calls].reverse().forEach((c) => {
if (c.provider !== 'tushare' || c.latency_ms == null) return;
(dsBuckets.get(c.interface) || dsBuckets.set(c.interface, []).get(c.interface)).push(c.latency_ms);
});
dsBuckets.forEach((arr, ds) => LatHist.set(`ds:${ds}`, arr.slice(-24)));
}
/* ================= 数据层:拉取真实接口并汇总为一份快照 ================= */
const RAW = {
overview: null, sources: null, providers: null, catalog: null,
lineage: null, jobs: null, batches: null, audit: null,
};
async function fetchAll() {
const [overview, sources, providers, catalog, lineage, jobs, batches, audit] = await Promise.all([
api('/admin/api/overview'),
api('/admin/api/sources'),
api('/admin/api/providers/status?limit=200'),
api('/admin/api/source-catalog'),
api('/admin/api/lineage'),
api('/admin/api/jobs'),
api(`/admin/api/batches?date=${today()}`),
api('/admin/api/audit'),
]);
RAW.overview = overview; RAW.sources = sources; RAW.providers = providers; RAW.catalog = catalog;
RAW.lineage = lineage; RAW.jobs = jobs; RAW.batches = batches; RAW.audit = audit;
buildLatHist();
}
function today() { return RAW.overview ? RAW.overview.trade_date : ''; }
const OFFICIAL_DATASET_IDS = ['daily', 'valuation', 'moneyflow', 'auction', 'index_daily', 'limit_events', 'popularity', 'dragon_tiger', 'sector_daily', 'stocks'];
const DATASET_NAME = {
daily: '日K', valuation: '估值', moneyflow: '资金流', auction: '竞价', index_daily: '指数日K',
limit_events: '涨停池', popularity: '人气榜', dragon_tiger: '龙虎榜', sector_daily: '题材板块', stocks: '股票主档',
};
/* =====================================================================
总览页(结构照抄 hub-kimi.html,数据全部真实)
===================================================================== */
function buildOverviewVM() {
const ov = RAW.overview || {};
const eod = ov.eod_status || {};
const rev = ov.revision_status || {};
const pubsByDs = {};
(ov.publications || []).forEach((p) => { pubsByDs[p.dataset] = p; });
const batchByDs = {};
((RAW.batches && RAW.batches.batches) || []).forEach((b) => { if (!batchByDs[b.dataset] || b.started_at > batchByDs[b.dataset].started_at) batchByDs[b.dataset] = b; });
const totalOfficial = OFFICIAL_DATASET_IDS.length;
const publishedCount = OFFICIAL_DATASET_IDS.filter((id) => pubsByDs[id] && pubsByDs[id].state === 'published').length;
const anomalyCount = (ov.anomalies || []).length;
const recentCalls = ov.recent_calls || [];
const successRate = recentCalls.length ? (recentCalls.filter((c) => c.ok).length / recentCalls.length * 100) : null;
// 主站回退页面:主源异常且备源健康 → 判定为“回退中”的 provisional 数据集
const fallbackPages = [];
(RAW.lineage && RAW.lineage.items || []).forEach((it) => {
if (it.tier !== 'provisional' || !it.backup_source) return;
const rows = it.live_provider_health || [];
const primaryProv = (it.providers || [])[0];
const backupProv = (it.providers || [])[1];
const primaryState = worstBucket(rows.filter((r) => r.provider === primaryProv).map((r) => bucketOf(r.state)));
const backupState = worstBucket(rows.filter((r) => r.provider === backupProv).map((r) => bucketOf(r.state)));
if ((primaryState === 'fail' || primaryState === 'degrade' || primaryState === 'slow') && backupState === 'ok') {
(it.known_consumers || []).forEach((c) => fallbackPages.push(c));
}
});
const datasetCards = OFFICIAL_DATASET_IDS.map((id) => {
const pub = pubsByDs[id];
const batch = batchByDs[id];
let bucket = 'plan', note = '';
if (pub) {
bucket = pub.state === 'published' ? 'ok' : (pub.state === 'degraded' ? 'degrade' : 'fail');
note = pub.published_at ? hm(pub.published_at) : '';
} else if (batch) {
bucket = batch.state === 'failed' ? 'fail' : (batch.state === 'staged' ? 'review' : 'plan');
note = batch.error ? esc(batch.error) : '';
}
const rows = (batch && batch.rows_out) || (pub && pub.rows_out) || null;
return { id, name: DATASET_NAME[id] || id, bucket, time: note && !batch?.error ? note : '', rows, err: batch && batch.error ? batch.error : '' };
});
// 实时观察层:eastmoney / tencent 目录接口的真实观测(observed=true
const catalogByProv = {};
(RAW.catalog && RAW.catalog.items || []).forEach((it) => { catalogByProv[it.provider] = it; });
const observers = [];
['eastmoney', 'tencent'].forEach((prov) => {
const cat = catalogByProv[prov];
if (!cat) return;
cat.interfaces.forEach((iface) => {
if (!iface.observed) return;
observers.push({
jid: `${prov}:${iface.interface}`,
name: iface.capability, provider: prov, interface: iface.interface,
lastOk: iface.observed_at, lat: iface.observed_latency_ms,
bucket: bucketOf(iface.observed_state),
note: iface.observed_note || '',
});
});
});
// 主站影响:lineage() 条目按 known_consumers 展开(接口级状态)
const siteImpact = [];
(RAW.lineage && RAW.lineage.items || []).forEach((it) => {
const bucket = lineageItemBucket(it);
(it.known_consumers || []).forEach((c) => {
siteImpact.push({ page: c, short: pageNameOf(c), dataset: it.dataset, bucket, note: lineageItemNote(it) });
});
});
const okPages = siteImpact.filter((s) => s.bucket === 'ok').length;
// 今晚时间线:真实 jobs() 定义 + job_runs
const jobDefs = (RAW.jobs && RAW.jobs.jobs || []).filter((j) => j.at !== 'manual');
const runsByJob = {};
((RAW.jobs && RAW.jobs.runs) || []).forEach((r) => { (runsByJob[r.job_id] = runsByJob[r.job_id] || []).push(r); });
const timeline = jobDefs.map((j) => {
const runs = (runsByJob[j.id] || []).slice().sort((a, b) => (a.id < b.id ? 1 : -1));
const latest = runs[0];
let st = 'wait';
if (latest) st = latest.state === 'ok' ? 'done' : (latest.state === 'failed' ? 'fail' : 'wait');
return { t: (j.at.split('-')[0] || j.at).slice(0, 5), label: j.title, state: st, jobId: j.id };
});
// 待处理异常:overview.anomalies 已在后端收敛为“仍未恢复的最新异常”
const incidents = [];
(ov.anomalies || []).forEach((b) => {
incidents.push({ sev: 'fail', tag: b.state, title: `${DATASET_NAME[b.dataset] || b.dataset} ${b.state === 'failed' ? '失败' : '停滞'}`, meta: `${b.error ? esc(b.error) : '无错误详情'} · 批次 ${b.batch_id}`, dataset: b.dataset });
});
if (eod.state === 'waiting_upstream' || eod.state === 'cutoff_failed') {
incidents.push({ sev: eod.state === 'cutoff_failed' ? 'fail' : 'slow', tag: eod.state, title: eod.state === 'cutoff_failed' ? '盘后批已截止失败' : '自动重试窗口', meta: `15:1523:30 · 每 30 分钟 · 成功即停 · 已试 ${eod.attempts || 0}${(eod.missing_datasets || []).length ? ' · 缺 ' + eod.missing_datasets.join(',') : ''}`, dataset: (eod.missing_datasets || [])[0] || '', cd: eod.next_retry_at });
}
const hidx = {};
((RAW.providers && RAW.providers.health) || []).forEach((r) => { (hidx[r.provider] = hidx[r.provider] || []).push(r); });
Object.keys(hidx).forEach((prov) => {
const bad = (hidx[prov] || []).filter((r) => (r.consec_failures || 0) > 0 && bucketOf(r.state) === 'fail');
if (bad.length) incidents.push({ sev: 'fail', tag: prov, title: `${prov} 部分接口连续失败`, meta: bad.map((r) => `${r.interface}×${r.consec_failures}`).join(' · '), provider: prov });
});
const ifindCat = catalogByProv.ifind;
if (ifindCat && !(ifindCat.credential && ifindCat.credential.configured)) {
incidents.push({ sev: 'off', tag: '未配置', title: 'iFinD · 授权源未启用', meta: '凭证未配置 · 功能隐藏 · 配置后启用', provider: 'ifind' });
}
return {
trade_date: ov.trade_date, session_phase: ov.session_phase, eod, rev,
publishedCount, totalOfficial, anomalyCount, successRate,
fallbackPages, datasetCards, observers, siteImpact, okPages, timeline, incidents,
unpublished: OFFICIAL_DATASET_IDS.filter((id) => !pubsByDs[id] || pubsByDs[id].state !== 'published'),
};
}
function heroHtml(vm) {
const anomalyTxt = vm.anomalyCount ? `<span class="glow-rd">${vm.anomalyCount} 项异常</span>` : `<span style="color:var(--mint)">无异常</span>`;
const tags = [];
if (vm.eod.state === 'waiting_upstream') tags.push(tagHtml('amb', `<span class="minidot pulse-dot" style="background:var(--amb)"></span>等待上游 · 已试 ${vm.eod.attempts || 0} 次`));
if (vm.rev.state === 'waiting_review') tags.push(tagHtml('cy', '复核 · 待发布'));
const subParts = [];
if (vm.eod.attempts) subParts.push(`已试 <span style="color:var(--amb)">${vm.eod.attempts}</span> 次`);
if (vm.eod.next_retry_at) subParts.push(`下次重试 <span class="num" data-cd-epoch="${Date.parse(String(vm.eod.next_retry_at).replace(' ', 'T'))}" data-cdfmt="mmss" style="color:var(--amb)">--:--</span>`);
if ((vm.eod.missing_datasets || []).length) subParts.push(`缺 <span style="color:var(--rd)">${esc(vm.eod.missing_datasets.join(','))}</span>`);
if (vm.rev.window) subParts.push(`复核窗口 <span style="color:var(--cy)">${esc(vm.rev.window)}</span>`);
return `<div class="panel hero">` +
`<div class="hero-l">` +
`<div class="hero-title"><span style="color:var(--txt-strong)">${vm.anomalyCount ? '部分异常' : '整体可用'}</span><span class="dot">·</span>${anomalyTxt}</div>` +
`<div class="hero-tags">${tags.join('')}</div>` +
(subParts.length ? `<div class="hero-sub">${subParts.join('<span class="sep">|</span>')}</div>` : '') +
`</div><span class="flex1"></span>` +
`<div class="kpis">` +
kpiHtml('已发布数据集', `<span class="glow-mint">${vm.publishedCount}</span><span style="color:var(--dim);font-size:18px">/${vm.totalOfficial}</span>`, vm.unpublished.length ? `${vm.unpublished.map((d) => DATASET_NAME[d] || d).join(' · ')} 未发布` : '全部已发布') +
kpiHtml('今日调用成功率', vm.successRate == null ? '<span style="color:var(--dim)">—</span>' : `<span data-rate>${vm.successRate.toFixed(1)}%</span>`, null, 'mint', vm.successRate != null) +
kpiHtml('主站回退页面', `${vm.fallbackPages.length} 页`, vm.fallbackPages.length ? esc(vm.fallbackPages[0]) : '实时层主源正常', vm.fallbackPages.length ? 'amb' : 'txt') +
kpiHtml('EOD 尝试', `${vm.eod.attempts || 0} 次`, '上限 5 · 23:30 止', vm.eod.attempts ? 'rd' : 'txt') +
`</div></div>`;
}
function datasetStripHtml(vm) {
return `<div class="ds-grid">${vm.datasetCards.map((d) => {
const ring = d.bucket === 'fail' ? 'fail' : d.bucket === 'degrade' ? 'slow' : d.bucket === 'review' ? 'review' : '';
const sparkTone = d.bucket === 'fail' ? 'rd' : d.bucket === 'degrade' ? 'amb' : d.bucket === 'review' ? 'cy' : 'mint';
let note = '';
if (d.time) note += `<span class="num" style="color:var(--mut)">${esc(d.time)} · </span>`;
if (d.rows) note += `<span class="num">${Number(d.rows).toLocaleString('en-US')} 行</span>`;
if (d.err) note += `<span class="ellip" style="color:var(--rd)" title="${esc(d.err)}">${esc(plainError(d.err, 16))}</span>`;
if (!d.time && !d.rows && !d.err) note = '<span style="color:var(--dim)">今日尚未发布</span>';
return `<div class="panel dsc ${ring}" data-dataset="${d.id}">` +
`<div class="dsc-top"><span class="dsc-name">${esc(d.name)}</span>${pillHtml(d.bucket)}</div>` +
`<div class="dsc-note">${note}</div>` +
sparkHtml(`ds:${d.id}`, sparkTone, 96, 16) +
`</div>`;
}).join('')}</div>`;
}
function observatoryHtml(vm) {
const legendByProv = { eastmoney: 'EM 东财', tencent: 'TX 腾讯' };
const right = `<span class="legend">${Object.keys(legendByProv).map((p) => `<span>${ledHtml('ok')}${legendByProv[p]}</span>`).join('')}</span>`;
const rows = vm.observers.length ? vm.observers.map((o) => `<tr>` +
`<td style="color:var(--txt-strong)">${esc(o.name)}</td>` +
`<td style="color:var(--mut);white-space:nowrap">${o.provider === 'eastmoney' ? '东方财富' : '腾讯行情'}</td>` +
`<td>${ageHtml(o.lastOk)}</td>` +
`<td>${latHtml(o.lat, o.bucket !== 'ok')}</td>` +
`<td>${sparkHtml(o.jid, o.bucket === 'slow' ? 'amb' : 'mint', 64, 16)}</td>` +
`<td style="max-width:260px"><span style="display:flex;align-items:center;gap:8px;min-width:0">${pillHtml(o.bucket)}${o.note ? ellipHtml(plainError(o.note, 20), '', '10px', o.note) : ''}</span></td>` +
`</tr>`).join('') : `<tr><td colspan="6" class="empty-hint">尚无真实观测记录(等待首次探测/调用)</td></tr>`;
const body = `<table class="dtable"><thead><tr><th>观察项</th><th>当前来源</th><th>缓存年龄</th><th>延迟</th><th>趋势</th><th>状态 · 备注</th></tr></thead><tbody>${rows}</tbody></table>`;
return panelHtml('实时观察层', right, body, false);
}
function siteImpactHtml(vm) {
const bad = vm.siteImpact.filter((s) => s.bucket !== 'ok');
const right = `<span style="font-size:10px"><span style="color:var(--mint)">${vm.okPages} 项正常</span><span style="color:var(--dim)"> · </span><span style="color:var(--amb)">${vm.siteImpact.length - vm.okPages} 项待关注</span></span>`;
const rows = bad.length ? bad.slice(0, 5).map((s) => `<tr>` +
`<td style="color:var(--txt-strong);width:40%">${esc(s.page)}</td>` +
`<td style="width:20%">${pillHtml(s.bucket)}</td>` +
`<td style="font-size:11px;color:var(--dim);max-width:220px">${s.note ? ellipHtml(plainError(s.note, 22), 'var(--dim)', '11px', s.note) : '—'}</td>` +
`<td style="text-align:right"><button class="tbtn mini" data-goto-lineage="${esc(s.dataset)}">查看血缘</button></td>` +
`</tr>`).join('') : `<tr><td style="color:var(--txt-strong);width:40%">全部页面正常</td><td>${pillHtml('ok')}</td><td style="font-size:11px;color:var(--dim)">—</td><td></td></tr>`;
const footPages = vm.siteImpact.map((s) => ({ name: s.short, bucket: s.bucket }));
const byPage = new Map();
footPages.forEach((p) => { const cur = byPage.get(p.name); if (!cur || SEV[p.bucket] > SEV[cur]) byPage.set(p.name, p.bucket); });
const foot = `<div class="site-foot"><span style="color:var(--mint);margin-right:4px">正常 ${vm.okPages}</span>` +
[...byPage.entries()].map(([name, bucket]) => bucket === 'ok'
? `<span style="display:inline-flex;align-items:center;gap:6px"><span>${esc(name)}</span><span style="color:var(--line2)">·</span></span>`
: `<span style="display:inline-flex;align-items:center;gap:6px;color:${HEALTH[bucket].color}">${esc(name)}(${HEALTH[bucket].label})</span><span style="color:var(--line2)">·</span></span>`).join('') +
`</div>`;
return panelHtml('主站影响', right, `<table class="dtable"><tbody>${rows}</tbody></table>${foot}`, false);
}
function timelineHtml(vm) {
const nodes = vm.timeline;
if (!nodes.length) return panelHtml('今晚时间线', '', '<div class="empty-hint">暂无可展示的调度任务</div>', true);
const W = 560, H = 150, x0 = 34, x1 = W - 30, y = 75;
const step = (x1 - x0) / Math.max(1, nodes.length - 1);
const X = (i) => x0 + i * step;
const now = new Date();
const nowMinutes = now.getHours() * 60 + now.getMinutes();
const toMin = (t) => { const [h, m] = t.split(':').map(Number); return h * 60 + (m || 0); };
let nowX = X(0);
for (let i = 0; i < nodes.length - 1; i++) {
const a = toMin(nodes[i].t), b = toMin(nodes[i + 1].t);
if (nowMinutes >= a && nowMinutes <= b && b > a) { nowX = X(i) + (X(i + 1) - X(i)) * ((nowMinutes - a) / (b - a)); break; }
if (nowMinutes < toMin(nodes[0].t)) { nowX = X(0); break; }
if (nowMinutes > toMin(nodes[nodes.length - 1].t)) { nowX = X(nodes.length - 1); }
}
let s = `<svg viewBox="0 0 ${W} ${H}" style="width:100%;display:block">` +
`<defs><linearGradient id="tlprog" x1="0" y1="0" x2="1" y2="0"><stop offset="0" style="stop-color:var(--mint)"/><stop offset="1" style="stop-color:var(--cy)"/></linearGradient></defs>` +
`<line x1="${x0 - 8}" y1="${y}" x2="${x1 + 8}" y2="${y}" style="stroke:var(--line2)" stroke-width="1"/>` +
`<line x1="${x0 - 8}" y1="${y}" x2="${nowX}" y2="${y}" stroke="url(#tlprog)" stroke-width="1.6" class="flowline"/>`;
nodes.forEach((n, i) => {
const nx = X(i), up = i % 2 === 0;
const ty = up ? y - 26 : y + 34, ly = up ? y - 12 : y + 20;
const color = n.state === 'done' ? 'var(--mint)' : n.state === 'fail' ? 'var(--rd)' : 'var(--dim)';
const anchor = i === 0 ? 'start' : i === nodes.length - 1 ? 'end' : 'middle';
s += `<g><line x1="${nx}" y1="${up ? y - 6 : y + 6}" x2="${nx}" y2="${up ? ly - 3 : ly + 3}" style="stroke:var(--line2)" stroke-width="1"/>`;
if (n.state === 'fail') s += `<circle cx="${nx}" cy="${y}" r="6" style="fill:rgba(248,113,113,.15);stroke:var(--rd)" stroke-width="1.2"/><text x="${nx}" y="${y + 3.2}" text-anchor="middle" font-size="9" style="fill:var(--rd)">×</text>`;
else s += `<circle cx="${nx}" cy="${y}" r="${n.state === 'done' ? 4.2 : 3.4}" style="fill:${n.state === 'done' ? color : 'var(--panel)'};stroke:${color}" stroke-width="1.2"/>`;
s += `<text x="${nx}" y="${ly + (up ? -1 : 1)}" text-anchor="${anchor}" font-size="9.5" style="fill:${n.state === 'fail' ? 'var(--rd)' : 'var(--txt-soft)'}" font-weight="600" class="num">${esc(n.t)}</text>` +
`<text x="${nx}" y="${ty + (up ? -1 : 1)}" text-anchor="${anchor}" font-size="9" style="fill:${n.state === 'fail' ? 'rgba(248,113,113,.75)' : 'var(--dim)'}">${esc(n.label)}</text></g>`;
});
s += `<g><line x1="${nowX}" y1="${y - 44}" x2="${nowX}" y2="${y + 44}" style="stroke:var(--cy)" stroke-width="1" stroke-dasharray="2 3" opacity=".7"/>` +
`<circle cx="${nowX}" cy="${y}" r="3.6" style="fill:var(--cy)" class="now-dot now-r"/>` +
`<text x="${nowX}" y="${y - 50}" text-anchor="middle" font-size="9" style="fill:var(--cy)" font-weight="600" class="num">NOW</text></g></svg>`;
const range = nodes.length ? `${nodes[0].t}${nodes[nodes.length - 1].t}` : '';
const right = `<span style="font-size:10px;color:var(--dim)">现在 <span class="num" style="color:var(--cy)">${p2(now.getHours())}:${p2(now.getMinutes())}</span> · ${range} · <span style="color:var(--mut)">SCHEDULER.PY</span></span>`;
return panelHtml('今晚时间线', right, s, false);
}
function incidentsHtml(vm) {
if (!vm.incidents.length) return panelHtml('待处理异常', tagHtml('mint', '0 OPEN'), '<div class="empty-hint">当前无待处理异常</div>', true);
const cards = vm.incidents.map((it) => {
const tag = it.sev === 'fail' ? tagHtml('rd', esc(it.tag)) : it.sev === 'slow' ? tagHtml('amb', esc(it.tag)) : tagHtml('dashed', esc(it.tag));
const meta = it.cd
? `${it.meta}<span class="num" style="color:var(--amb)"> · 下次重试 <span data-cd-epoch="${Date.parse(String(it.cd).replace(' ', 'T'))}" data-cdfmt="ms">--m--s</span></span>`
: it.meta;
return `<div class="inc ${it.sev}">${tag}` +
`<div style="flex:1;min-width:0">` +
`<div class="inc-title"${it.sev === 'off' ? ' style="color:var(--mut)"' : ''}>${esc(it.title)}</div>` +
`<div class="inc-meta ellip" title="${esc(String(it.meta).replace(/<[^>]+>/g, ''))}">${meta}</div>` +
`</div>` +
`<button class="tbtn" style="flex:none" data-incident-action data-dataset="${esc(it.dataset || '')}" data-provider="${esc(it.provider || '')}">去处理 →</button>` +
`</div>`;
}).join('');
return panelHtml('待处理异常', tagHtml('rd', vm.incidents.length + ' OPEN'), `<div class="col" style="gap:10px">${cards}</div>`, true);
}
function overviewHtml() {
const vm = buildOverviewVM();
return `<div class="col">${heroHtml(vm)}${datasetStripHtml(vm)}` +
`<div class="ov-grid"><div class="ov-l col">${observatoryHtml(vm)}${siteImpactHtml(vm)}</div>` +
`<div class="ov-r col">${timelineHtml(vm)}${incidentsHtml(vm)}</div></div></div>`;
}
/* =====================================================================
数据源配置页(分组接口表照抄打样 mx-grid 结构,数据全部真实)
===================================================================== */
const srcUiState = {};
const PROV_ROLE_TEXT = {
tushare: 'official · 盘后正式数据唯一源',
eastmoney: 'free · 盘中观察主源',
tencent: 'free · 盘中观察备源',
ifind: 'licensed · 图表 / 问财 / 实时快照',
};
function buildSourcesVM() {
const catalog = (RAW.catalog && RAW.catalog.items) || [];
const legacy = {};
(RAW.sources && RAW.sources.items || []).forEach((it) => { legacy[it.provider] = it; });
const cidx = {};
((RAW.providers && RAW.providers.recent_calls) || []).forEach((c) => { if (!cidx[c.provider]) cidx[c.provider] = c; });
const active = catalog.filter((c) => c.role !== 'reserved');
const reserved = catalog.filter((c) => c.role === 'reserved');
const totalIfaces = active.reduce((n, c) => n + c.interfaces.length, 0);
// “在用”= 已登记且已观察到调用(接口名或数据集名匹配,后端 join 已标注)
const usedIfaces = active.reduce((n, c) => n + c.interfaces.filter((i) => i.observed).length, 0);
const cards = active.map((c) => {
const cred = c.credential || {};
const observedRows = c.interfaces.filter((i) => i.observed);
const worst = observedRows.length ? worstBucket(observedRows.map((i) => bucketOf(i.observed_state))) : null;
const configured = cred.configured;
let bucket;
if (worst) bucket = worst;
else if (configured) bucket = 'plan';
else bucket = 'off';
const lastCall = cidx[c.provider];
if (!(c.provider in srcUiState)) srcUiState[c.provider] = { open: true, probing: false, probeResult: null };
const groups = [];
const groupMap = new Map();
c.interfaces.forEach((i) => {
const g = i.group || '其他';
if (!groupMap.has(g)) groupMap.set(g, []);
groupMap.get(g).push(i);
});
groupMap.forEach((items, name) => groups.push({ name, items }));
const ifaceRows = (group) => group.items.map((i) => ({
api: i.interface, use: i.capability,
ok: i.observed ? (i.observed_at ? hm(i.observed_at) : '—') : '—',
lat: i.observed ? i.observed_latency_ms : null,
st: i.observed ? bucketOf(i.observed_state) : (configured ? 'plan' : 'off'),
obs: i.observed, basis: i.observed_basis,
err: i.observed && i.observed_state !== 'ok' ? (i.observed_note || '') : '',
}));
return {
id: c.provider, name: c.label, roleText: PROV_ROLE_TEXT[c.provider] || c.role,
tag: c.credential_key ? 'licensed' : 'free',
bucket, credential: cred,
usedCount: c.interfaces.filter((i) => i.observed).length, totalCount: c.interfaces.length,
lastProbe: lastCall ? `${hm(lastCall.fetched_at)} ${lastCall.status === 'ok' ? `✓ ${lastCall.latency_ms ?? ''}ms` : `× ${lastCall.error || 'ERROR'}`}` : '—',
latency: observedRows.length ? (observedRows.map((r) => r.observed_latency_ms).filter((v) => v != null)[0] || null) : null,
error: observedRows.find((r) => r.observed_state !== 'ok' && r.observed_note)?.observed_note || '',
groups: groups.map((g) => ({ name: g.name, rows: ifaceRows(g) })),
};
});
return { cards, reserved, totalIfaces, usedIfaces };
}
function sourceCardHtml(card) {
const ui = srcUiState[card.id];
const ringStyle = card.bucket === 'fail' ? 'border-color:rgba(248,113,113,.35)' :
card.bucket === 'degrade' ? 'border-color:rgba(251,191,36,.25)' :
card.bucket === 'slow' ? 'border-color:rgba(251,191,36,.3)' :
card.bucket === 'off' ? 'border-style:dashed;border-color:var(--line2)' : '';
const ledSt = HEALTH[card.bucket].led;
// source_catalog.pycredential_key 为空的来源(eastmoney/tencent 等公开
// 接口)真实语义是"无需凭证"而非"已配置",两者都体现为 configured:true
// 靠 note 字段区分,这里必须原样展示 note,不能一律折叠成"已配置"。
const credBadge = card.credential.note ? card.credential.note :
card.credential.configured ? `已配置${card.credential.last4 ? ' · ' + card.credential.last4 : ''}` : '未配置';
const badges = [credBadge];
if (card.credential.configured && card.credential.updated_at) badges.push(`${String(card.credential.updated_at).slice(5, 10).replace('-', '/')} 更新`);
badges.push(card.id === 'tushare' ? '主(无备)' : (card.id === 'ifind' ? '图表主源(未启用)' : (card.id === 'tencent' ? '实时层备' : '实时层主')));
let h = `<section class="panel" style="${ringStyle}">`;
h += `<header class="src-head">` +
ledHtml(ledSt, card.bucket === 'fail') +
`<span class="src-name">${esc(card.name)}</span>` +
`<span class="src-role">${esc(card.roleText)}</span>` +
pillHtml(card.bucket, card.bucket === 'plan' ? '已配置 · 待观测' : '') +
`<span style="display:flex;gap:6px">${badges.map((b) => tagHtml(card.bucket === 'off' ? 'dashed' : 'dim', esc(b))).join('')}</span>` +
`<span class="flex1"></span>` +
`<button class="tbtn" data-probe="${card.id}"${ui.probing || card.bucket === 'off' ? ' disabled' : ''}>${ui.probing ? '<span class="animate-spin" style="margin-right:4px">◌</span>探测中…' : '探测一次'}</button>` +
(card.bucket === 'off'
? `<span class="tag dashed">下方填入凭证即可启用</span>`
: `<button class="tbtn" data-toggle="${card.id}">${ui.open ? '收起接口清单' : '展开接口清单'}</button>`) +
`</header>`;
const probeCell = ui.probeResult
? `<span class="num" style="color:${ui.probeResult.ok ? 'var(--mint)' : 'var(--rd)'}">${ui.probeResult.t} ${ui.probeResult.ok ? `✓ ${ui.probeResult.ms}ms` : '× TIMEOUT'}</span>`
: `<span class="num"${card.lastProbe.includes('✓') ? ' style="color:var(--mint)"' : card.lastProbe.includes('×') ? ' style="color:var(--rd)"' : ''}>${esc(card.lastProbe)}</span>`;
h += `<div class="src-meta">` +
`<span>接口 <span class="num" style="color:${card.bucket === 'off' ? 'var(--dim)' : 'var(--cy)'}">${card.usedCount}/${card.totalCount}</span> 在用</span>` +
`<span class="msep">|</span><span>最后探测 ${probeCell}</span>` +
`<span class="msep">|</span><span>延迟 ${latHtml(card.latency)}</span>` +
(card.error ? `<span class="msep">|</span><span style="color:var(--rd);display:inline-flex;align-items:center;gap:4px;min-width:0">最近错误 ${ellipHtml(plainError(card.error, 26), 'var(--rd)', '11px', card.error)}</span>` : (card.bucket !== 'off' ? `<span class="msep">|</span><span style="color:var(--dim)">无最近错误</span>` : '')) +
`</div>`;
h += credBoxHtml(card);
if (ui.open && card.bucket !== 'off') {
h += `<div class="mx-grid">` + card.groups.map((g) => `<div style="min-width:0">` +
`<div class="lab" style="margin-bottom:6px">${esc(g.name)}</div>` +
`<table class="dtable"><thead><tr><th>接口</th><th>用途</th><th>最近成功</th><th>延迟</th><th>状态</th></tr></thead><tbody>` +
g.rows.map((it) => `<tr>` +
`<td style="color:var(--cy);font-size:11px;word-break:break-all;min-width:90px">${esc(it.api)}</td>` +
`<td style="color:var(--mut);font-size:11px;white-space:nowrap">${esc(it.use)}</td>` +
`<td class="num" style="font-size:11px;color:${it.ok.includes('×') || it.ok === '昨日' ? 'var(--rd)' : 'var(--mut)'}">${esc(it.ok)}</td>` +
`<td style="font-size:11px">${latHtml(it.lat)}</td>` +
`<td style="white-space:nowrap"><span style="display:flex;align-items:center;gap:6px">${pillHtml(it.st, it.st === 'plan' && it.obs === false ? '已配置 · 待观测' : '')}${it.err ? ellipHtml(plainError(it.err, 16), 'var(--rd)', '10px', it.err) : ''}</span></td>` +
`</tr>`).join('') +
`</tbody></table></div>`).join('') + `</div>`;
}
if (card.bucket === 'off') {
h += `<div style="padding:16px"><div class="offbox">凭证未配置 · ${card.totalCount} 个预留接口处于停用状态 · 配置后将成为图表 / 问财 / 实时快照主源` +
`<div style="margin-top:8px;display:flex;justify-content:center;gap:6px;flex-wrap:wrap">${card.groups[0].rows.map((i) => tagHtml('dashed', esc(i.api))).join('')}</div>` +
`</div></div>`;
}
return h + `</section>`;
}
/* ---------- 凭证可编辑区(样图 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 `<div class="cred-box" data-cred-form="${card.id}">` +
`<span class="lab">${esc(form.label)} <span class="cred-flag">· 新增可编辑区</span></span>` +
`<div class="cred-line">` +
`<input type="password" autocomplete="off" data-cred-input="${form.field}"` +
` placeholder="${saved ? '••••••••••••••••••••' : esc(form.placeholder)}">` +
`<span class="cred-acts">` +
(form.test ? `<button class="tbtn" data-cred-test="${card.id}"${card.bucket === 'off' ? ' disabled' : ''}>测试连接</button>` : '') +
`<button class="pbtn" data-cred-save="${card.id}">${esc(form.save)}</button></span>` +
`</div>` +
`<div class="cred-feet"><span class="form-hint">${esc(form.hint)}</span>` +
`<span class="form-hint">${esc(form.note) || probe}</span></div>` +
`</div>`;
}
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(`<span style="display:inline-flex;align-items:center;gap:6px;font-size:11px">${ledHtml('fail', true)}<span style="color:var(--rd)">${esc(c.name)} 故障${c.error ? ' · ' + esc(c.error) : ''}</span></span>`);
else if (c.bucket === 'degrade' || c.bucket === 'slow') alerts.push(`<span style="display:inline-flex;align-items:center;gap:6px;font-size:11px">${ledHtml('slow')}<span style="color:var(--amb)">${esc(c.name)} 降级/延迟</span></span>`);
});
return `<div class="col">` +
`<div class="panel strip">` +
`<span style="font-size:13px;color:var(--txt-strong)">数据源 <span class="num" style="color:var(--cy)">${vm.cards.length}</span> 路 · 接口 <span class="num" style="color:var(--cy)">${vm.usedIfaces}/${vm.totalIfaces}</span> 在用</span>` +
alerts.join('') +
`<span class="flex1"></span>` +
`<span style="font-size:10px;color:var(--dim);letter-spacing:.12em">凭证安全存储 · 探测只读 · 不影响盘中链路</span>` +
`</div>` +
`<div id="srcCards" class="col">${vm.cards.map(sourceCardHtml).join('')}</div>` +
`<div class="rsv-grid">${vm.reserved.map((r) => `<div class="rsv">${ledHtml('off')}<span style="font-size:13px">${esc(r.label)}</span>${tagHtml('dashed', '预留位 · 未接入')}</div>`).join('')}</div>` +
`</div>`;
}
function renderSourceCards() {
const el = $('srcCards');
if (!el) return;
el.innerHTML = buildSourcesVM().cards.map(sourceCardHtml).join('');
paintSparks();
}
/* =====================================================================
数据血缘页(接口级状态 + 已配置/待观测 + 真实频率 + 事件脉冲)
===================================================================== */
function pageNameOf(consumer) {
const idx = consumer.indexOf('(');
return (idx > 0 ? consumer.slice(0, idx) : consumer).trim();
}
/* 一条血缘项的服务接口集合(primary/backup 冒号后的真实接口名) */
function servingInterfaces(item) {
const out = new Set([item.dataset]);
[item.primary_source, item.backup_source].forEach((src) => {
if (!src) return;
src.split(':').slice(1).join(':').split('+').forEach((api) => {
const name = api.trim();
if (name) out.add(name);
});
});
return out;
}
/* 接口级状态:只看本数据集真正走的那几条接口,不用提供商级汇总顶替。 */
function lineageItemRowsHealth(item) {
const apis = servingInterfaces(item);
return (item.live_provider_health || []).filter((r) => apis.has(r.interface));
}
function lineageItemBucket(item) {
const rows = lineageItemRowsHealth(item);
if (item.tier === 'official') {
if (item.publication) return item.publication.state === 'published' ? 'ok' : (item.publication.state === 'degraded' ? 'degrade' : 'fail');
return rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'plan';
}
if (item.tier === 'licensed') {
const cred = ((RAW.catalog && RAW.catalog.items) || []).find((c) => c.provider === 'ifind');
if (cred && !(cred.credential && cred.credential.configured)) return 'off';
return rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'off';
}
// provisional:已有数据来源;有观测行看接口级状态,暂无观测行=已配置/待观测
return rows.length ? worstBucket(rows.map((r) => bucketOf(r.state))) : 'plan';
}
function lineageItemNote(item) {
const rows = lineageItemRowsHealth(item);
const note = rows.find((r) => r.last_error)?.last_error || rows.find((r) => r.last_fallback_reason)?.last_fallback_reason || '';
if (note) return note;
if (item.tier === 'licensed') return 'iFinD 未配置 · 功能隐藏';
if (item.tier === 'provisional' && !rows.length) return '已配置 · 暂无观测记录';
return '';
}
const linFilter = { onlyBad: false, src: '全部', q: '' };
let linHover = null;
let GW = 1500, GH = 286;
const gpos = {};
const adj = { sd: new Map(), ds: new Map(), dp: new Map(), pd: new Map() };
function adjAdd(m, k, v) { if (!m.has(k)) m.set(k, new Set()); m.get(k).add(v); }
function buildLineageVM() {
const items = (RAW.lineage && RAW.lineage.items) || [];
const PROV_LABEL = { tushare: 'Tushare', eastmoney: '东方财富', tencent: '腾讯行情', ifind: 'iFinD' };
const srcState = {};
const dsState = {};
const pageState = {};
const edgesSD = [];
const edgesDP = [];
const rows = [];
items.forEach((it) => {
const rowsHealth = lineageItemRowsHealth(it);
const bucket = lineageItemBucket(it);
dsState[it.dataset] = bucket;
(it.providers || []).forEach((p) => {
srcState[p] = worstBucket([srcState[p] || 'ok', ...rowsHealth.filter((r) => r.provider === p).map((r) => bucketOf(r.state))]);
edgesSD.push([p, it.dataset]);
});
const consumers = it.known_consumers || [];
const pages = [...new Set(consumers.map(pageNameOf))];
pages.forEach((pg) => {
pageState[pg] = worstBucket([pageState[pg] || 'ok', bucket]);
edgesDP.push([it.dataset, pg]);
});
rows.push({
page: pages.join(' / ') || '—', item: consumers.join(' / '), dataset: it.dataset,
via: `${it.primary_source}${it.backup_source ? ' → ' + it.backup_source : ''}`,
role: it.backup_source ? '主/备' : (it.tier === 'licensed' ? '主(授权)' : '主(无备)'),
freq: it.update_freq || '—',
ok: it.tier === 'official' && it.publication ? it.publication.published_at : (rowsHealth.find((r) => r.last_ok_at) || {}).last_ok_at,
lat: rowsHealth.map((r) => r.last_latency_ms).find((v) => v != null),
st: bucket,
note: lineageItemNote(it),
});
});
const sources = Object.keys(srcState).map((id) => ({ id, name: PROV_LABEL[id] || id, st: srcState[id] }));
const datasets = Object.keys(dsState).map((id) => ({ id, st: dsState[id] }));
const pages = Object.keys(pageState).map((id) => ({ id, name: id, st: pageState[id] }));
return { sources, datasets, pages, edgesSD, edgesDP, rows };
}
function spread(n, y0, y1) { return Array.from({ length: n }, (_, i) => (n === 1 ? (y0 + y1) / 2 : y0 + (i / (n - 1)) * (y1 - y0))); }
function layoutGraph(vm) {
const wide = (window.innerWidth || 1440) > 1100;
GW = wide ? 1500 : 1000; GH = wide ? 286 : 560;
vm.sources.forEach((s, i) => { gpos['s:' + s.id] = { x: wide ? 80 : 60, y: spread(vm.sources.length, wide ? 60 : 90, wide ? 232 : 500)[i] }; });
vm.datasets.forEach((d, i) => { gpos['d:' + d.id] = { x: wide ? 700 : 480, y: spread(vm.datasets.length, wide ? 30 : 45, wide ? 264 : 525)[i] }; });
vm.pages.forEach((p, i) => { gpos['p:' + p.id] = { x: wide ? 1400 : 930, y: spread(vm.pages.length, wide ? 26 : 30, wide ? 276 : 540)[i] }; });
}
function linkPath(p1, p2) { const mx = (p1.x + p2.x) / 2; return `M ${p1.x} ${p1.y} C ${mx} ${p1.y}, ${mx} ${p2.y}, ${p2.x} ${p2.y}`; }
const stColorOf = (b) => (HEALTH[b] || HEALTH.off).color;
function lineageGraphHtml(vm) {
layoutGraph(vm);
adj.sd.clear(); adj.ds.clear(); adj.dp.clear(); adj.pd.clear();
vm.edgesSD.forEach(([s, d]) => { adjAdd(adj.sd, s, d); adjAdd(adj.ds, d, s); });
vm.edgesDP.forEach(([d, p]) => { adjAdd(adj.dp, d, p); adjAdd(adj.pd, p, d); });
let h = `<svg viewBox="0 0 ${GW} ${GH}" style="width:100%;display:block" id="linSvg">`;
const colStyle = 'font-size="10" letter-spacing="2" style="fill:var(--dim)"';
if (vm.sources.length) h += `<text x="${gpos['s:' + vm.sources[0].id].x}" y="12" text-anchor="middle" ${colStyle}>来源 · ${vm.sources.length}</text>`;
if (vm.datasets.length) h += `<text x="${gpos['d:' + vm.datasets[0].id].x}" y="12" text-anchor="middle" ${colStyle}>数据集 · ${vm.datasets.length}</text>`;
if (vm.pages.length) h += `<text x="${gpos['p:' + vm.pages[0].id].x}" y="12" text-anchor="middle" ${colStyle}>主站页面 · ${vm.pages.length}</text>`;
const dsBy = {}; vm.datasets.forEach((d) => { dsBy[d.id] = d.st; });
// 已连接线路全部有可辨识的定向流动(flowline),颜色按数据集真实状态
vm.edgesSD.forEach(([s, d]) => {
if (!gpos['s:' + s] || !gpos['d:' + d]) return;
h += `<path data-link data-kind="sd" data-a="${esc(s)}" data-b="${esc(d)}" d="${linkPath(gpos['s:' + s], gpos['d:' + d])}" fill="none" stroke-width="1" stroke-opacity="0.28" class="${dsBy[d] === 'off' ? '' : 'flowline'}" style="stroke:${stColorOf(dsBy[d])};transition:stroke-opacity .18s ease,stroke-width .18s ease"/>`;
});
vm.edgesDP.forEach(([d, p]) => {
if (!gpos['d:' + d] || !gpos['p:' + p]) return;
h += `<path data-link data-kind="dp" data-a="${esc(d)}" data-b="${esc(p)}" d="${linkPath(gpos['d:' + d], gpos['p:' + p])}" fill="none" stroke-width="1" stroke-opacity="0.22" class="${dsBy[d] === 'off' ? '' : 'flowline'}" style="stroke:${stColorOf(dsBy[d])};transition:stroke-opacity .18s ease,stroke-width .18s ease"/>`;
});
vm.sources.forEach((s) => {
const p = gpos['s:' + s.id]; if (!p) return;
h += `<g data-nid="${esc(s.id)}" data-gkind="src" style="cursor:pointer;transition:opacity .18s">` +
`<circle cx="${p.x}" cy="${p.y}" r="16" style="fill:rgba(34,211,238,.05);stroke:var(--line2)"/>` +
`<circle cx="${p.x}" cy="${p.y}" r="5" ${s.st !== 'off' ? `class="gnode-pulse" style="fill:${stColorOf(s.st)};animation-delay:${(vm.sources.indexOf(s) * 0.37).toFixed(2)}s"` : `style="fill:${stColorOf(s.st)}"`}/>` +
`<text x="${p.x + 24}" y="${p.y + 4}" font-size="12" style="fill:var(--txt)" font-weight="600">${esc(s.name)}</text></g>`;
});
vm.datasets.forEach((d) => {
const p = gpos['d:' + d.id]; if (!p) return;
h += `<g data-nid="${esc(d.id)}" data-gkind="ds" style="cursor:pointer;transition:opacity .18s">` +
`<circle cx="${p.x}" cy="${p.y}" r="4" style="fill:var(--panel);stroke:${stColorOf(d.st)}" stroke-width="1.4"/>` +
`<text x="${p.x - 12}" y="${p.y + 3.5}" text-anchor="end" font-size="10.5" style="fill:var(--mut)">${esc(d.id)}</text></g>`;
});
vm.pages.forEach((pg) => {
const p = gpos['p:' + pg.id]; if (!p) return;
h += `<g data-nid="${esc(pg.id)}" data-gkind="pg" style="cursor:pointer;transition:opacity .18s">` +
`<circle cx="${p.x}" cy="${p.y}" r="3" style="fill:${stColorOf(pg.st)}"/>` +
`<text x="${p.x - 12}" y="${p.y + 3.5}" text-anchor="end" font-size="10.5" style="fill:var(--mut)">${esc(pg.name)}</text></g>`;
});
h += `</svg><div class="lin-hint" id="linHint">悬停节点 · 追踪上下游链路</div>`;
return h;
}
function activeSetCalc(hover, vm) {
if (!hover) return null;
const set = new Set([hover]);
const isSrc = vm.sources.some((s) => s.id === hover);
const isDs = vm.datasets.some((d) => d.id === hover);
if (isSrc) { (adj.sd.get(hover) || new Set()).forEach((d) => { set.add(d); (adj.dp.get(d) || new Set()).forEach((p) => set.add(p)); }); }
else if (isDs) { (adj.ds.get(hover) || new Set()).forEach((s) => set.add(s)); (adj.dp.get(hover) || new Set()).forEach((p) => set.add(p)); }
else { (adj.pd.get(hover) || new Set()).forEach((d) => { set.add(d); (adj.ds.get(d) || new Set()).forEach((s) => set.add(s)); }); }
return set;
}
function applyLinHover(vm) {
const act = activeSetCalc(linHover, vm);
const isSrcHov = vm.sources.some((s) => s.id === linHover);
document.querySelectorAll('#linSvg path[data-link]').forEach((p) => {
const a = p.dataset.a, b = p.dataset.b;
const shown = !act || (act.has(a) && act.has(b));
let hot = false;
if (act && act.has(a) && act.has(b)) hot = isSrcHov ? (a === linHover ? true : (adj.sd.get(linHover) || new Set()).has(a)) : true;
p.setAttribute('stroke-width', hot ? 1.8 : 1);
p.setAttribute('stroke-opacity', shown ? (act ? 0.85 : (p.dataset.kind === 'sd' ? 0.28 : 0.22)) : 0.05);
});
document.querySelectorAll('#linSvg g[data-nid]').forEach((g) => {
const id = g.dataset.nid, kind = g.dataset.gkind;
const dimmed = act && !act.has(id);
g.setAttribute('opacity', dimmed ? (kind === 'src' ? 0.3 : 0.25) : 1);
if (kind !== 'src') { const t = g.querySelector('text'); if (t) t.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) => `<tr class="${cls(r.st)}">` +
`<td style="color:var(--txt-strong);white-space:nowrap">${esc(r.page)}</td>` +
`<td style="color:var(--mut);font-size:11px;white-space:nowrap">${esc(r.item)}</td>` +
`<td style="color:var(--cy);font-size:11px">${esc(r.dataset)}</td>` +
`<td style="color:var(--mut);font-size:11px;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.via)}">${esc(r.via)}</td>` +
`<td style="color:var(--dim);font-size:11px;white-space:nowrap">${esc(r.role)}</td>` +
`<td class="num" style="color:var(--mut);font-size:11px;white-space:nowrap">${esc(r.freq)}</td>` +
`<td class="num" style="font-size:11px;white-space:nowrap;color:var(--mut)">${r.ok ? esc(hm(r.ok)) : '—'}</td>` +
`<td class="num" style="font-size:11px;color:${r.lat != null && r.lat > 1000 ? 'var(--amb)' : 'var(--mut)'}">${r.lat != null ? Number(r.lat).toLocaleString('en-US') + 'ms' : '—'}</td>` +
`<td><span style="display:flex;align-items:center;gap:8px;white-space:nowrap">${pillHtml(r.st, stLabel(r))}${r.note && !stLabel(r) ? ellipHtml(plainError(r.note, 20), 'var(--dim)', '10px', r.note) : ''}</span></td>` +
`</tr>`).join('') || `<tr><td colspan="9" class="empty-hint">无匹配记录 · 调整筛选条件</td></tr>`;
if (bodyN) bodyN.innerHTML = rows.map((r) => `<tr class="${cls(r.st)}">` +
`<td style="color:var(--txt-strong);white-space:nowrap">${esc(r.page)}<span class="sub" style="color:var(--mut)">${esc(r.item)}</span></td>` +
`<td style="color:var(--cy);font-size:11px">${esc(r.dataset)}</td>` +
`<td style="color:var(--mut);font-size:11px;word-break:break-all">${esc(r.via)}</td>` +
`<td class="num" style="color:var(--mut);font-size:11px;white-space:nowrap">${esc(r.freq)}<span class="sub">${esc(r.role)}</span></td>` +
`<td class="num" style="font-size:11px;white-space:nowrap;color:var(--mut)">${r.ok ? esc(hm(r.ok)) : '—'}<span class="sub">${r.lat != null ? r.lat + 'ms' : '—'}</span></td>` +
`<td>${pillHtml(r.st, stLabel(r))}${r.note && !stLabel(r) ? `<span class="sub ellip" title="${esc(r.note)}">${esc(plainError(r.note, 20))}</span>` : ''}</td>` +
`</tr>`).join('') || `<tr><td colspan="6" class="empty-hint">无匹配记录 · 调整筛选条件</td></tr>`;
}
function lineageHtml() {
const vm = buildLineageVM();
const failDs = vm.datasets.filter((d) => d.st === 'fail');
let impactTag;
if (failDs.length) {
const pages = new Set();
failDs.forEach((d) => { (adj.dp.get(d.id) || new Set()).forEach((p) => pages.add(p)); });
impactTag = tagHtml('rd', `${failDs.map((d) => d.id).join('/')} 断链影响 ${pages.size} 页`);
} else {
impactTag = tagHtml('mint', '暂无断链影响');
}
const html = `<div class="col">` +
`<div class="panel strip" style="gap:8px 20px">` +
`<span style="font-size:13px;color:var(--txt-strong)">血缘地图</span>` +
`<span class="num" style="font-size:11px;color:var(--mut)"><span style="color:var(--cy)">${vm.sources.length}</span> 源 → <span style="color:var(--cy)">${vm.datasets.length}</span> 数据集 → <span style="color:var(--cy)">${vm.pages.length}</span> 页面</span>` +
`<span class="legend" style="color:var(--dim);letter-spacing:0"><span>${ledHtml('ok')}健康</span><span>${ledHtml('slow')}慢/降级</span><span>${ledHtml('fail')}失败</span><span>${ledHtml('plan')}计划</span><span>${ledHtml('off')}未配置</span></span>` +
`<span class="flex1"></span>` +
impactTag +
`</div>` +
`<section class="panel"><div style="position:relative">${lineageGraphHtml(vm)}</div></section>` +
`<section class="panel">` +
`<div class="lin-filter">` +
`<select class="lin-sel" id="linSel">${['全部', 'Tushare', '东方财富', '腾讯', 'iFinD'].map((o) => `<option${linFilter.src === o ? ' selected' : ''}>按源筛选 · ${o}</option>`).join('')}</select>` +
`<button class="tbtn${linFilter.onlyBad ? ' on' : ''}" id="linBad">${ledHtml(linFilter.onlyBad ? 'fail' : 'plan')} 只看异常</button>` +
`<span class="flex1"></span>` +
`<span class="num" style="font-size:10px;color:var(--dim)" id="linCount"></span>` +
`<input class="lin-q" id="linQ" placeholder="搜索页面 / 接口 / 数据集" value="${esc(linFilter.q)}">` +
`</div>` +
`<div class="lin-scroll"><table class="dtable lin-table"><thead><tr><th>主站页面</th><th>数据项</th><th>数据集</th><th>源 : 接口</th><th>主/备</th><th>更新频率</th><th>最近成功</th><th>延迟</th><th>状态</th></tr></thead><tbody id="linBody"></tbody></table>` +
`<table class="dtable lin-table-n"><thead><tr><th>主站页面</th><th>数据集</th><th>源 : 接口</th><th>更新频率</th><th>最近成功</th><th>状态</th></tr></thead><tbody id="linBodyN"></tbody></table></div>` +
`</section>` +
`</div>`;
return html;
}
/* =====================================================================
模型池(供应商制,样图 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
? '<span class="mr-state">测试中…</span>'
: `<span class="mr-state" style="color:${test.ok ? 'var(--mint)' : 'var(--rd)'}">${test.ok ? `已连通 · ${test.ms}ms` : `未连通 · ${esc(test.error || '失败')}`}</span>`)
: '<span class="mr-state">未测试</span>';
return `<div class="model-row" data-model-id="${esc(model.id)}">` +
`<div class="mr-main"><div class="mr-name">${esc(model.name || model.id)} ${modelRoleTag(model.id)}</div>` +
`<div class="mr-sub">${esc(vendorLabel)} · ${esc(model.model || '')}</div></div>` +
`<span class="mr-test"><button class="tbtn" data-model-test="${esc(model.id)}">测试连接</button>${result}</span>` +
`<button class="tbtn" data-model-remove="${esc(model.id)}">删除</button>` +
`</div>`;
}
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 `<div class="vend-note">${ledHtml('rev', true)}正在请求 /models … 通常 1~3 秒,期间不可重复点击</div>`;
}
if (draft.error) {
return `<div class="vend-note bad">${ledHtml('fail')}拉取失败 · ${esc(draft.error)} · 也可改用手动录入模型标识</div>` +
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 `<label class="vend-pick"><input type="checkbox" data-vend-pick="${esc(group.base_url)}" value="${esc(name)}"` +
`${added ? ' checked disabled' : ''}>${esc(name)}${added ? '<span class="added">已添加</span>' : ''}</label>`;
}).join('');
return `<div class="vend-note">${ledHtml('ok')}已拉取 ${draft.fetched.length} 个模型` +
`${draft.fetchedAt ? ' · ' + esc(draft.fetchedAt) : ''} · 来自 GET /models</div>` +
`<div class="vend-fetch-list">${picks}</div>` +
`<div class="form-actions"><button class="pbtn" data-vend-add="${esc(group.base_url)}"${pending.length ? '' : ' disabled'}>` +
`添加勾选模型${pending.length ? ` (${pending.length})` : ''}</button>` +
`<span class="form-hint">${pending.length ? '勾选后一键添加,免手敲模型标识' : '全部已添加'}</span></div>` +
manualEntryHtml(group);
}
function manualEntryHtml(group) {
const draft = vendorDraft(group.base_url);
if (!draft.error && !draft.manual) {
return `<div class="form-actions"><button class="tbtn" data-vend-manual="${esc(group.base_url)}">手动录入模型</button>` +
`<span class="form-hint">拉取失败或供应商不支持 /models 时使用</span></div>`;
}
return `<div class="vend-manual"><span class="lab">手动录入(兜底)</span>` +
`<div class="field"><span class="lab">模型标识 <span style="color:var(--cy)">*</span></span>` +
`<input data-vend-manual-input="${esc(group.base_url)}" placeholder="例:qwen-plus"></div>` +
`<button class="tbtn" data-vend-manual-add="${esc(group.base_url)}">添加该模型</button></div>`;
}
function vendorCardHtml(group) {
const draft = vendorDraft(group.base_url);
const required = '<span style="color:var(--cy)">*</span>';
// 样图:地址与 Key 并排一行,两个操作按钮贴在同一行右端,提示挂在 Key 下面
const body = `<div class="vend-line">` +
`<div class="field"><span class="lab">API BASE URL ${required}</span>` +
`<input data-vend-url="${esc(group.base_url)}" value="${esc(group.base_url)}" placeholder="https://api.example.com/v1"></div>` +
`<div class="field"><span class="lab">API KEY ${required}</span>` +
`<input type="password" autocomplete="off" data-vend-key="${esc(group.base_url)}"` +
`${group.configured ? ' placeholder="••••••••••••••••"' : ' placeholder="输入 API Key"'}>` +
`<span class="form-hint">${group.configured ? '掩码显示 · 留空保持不变' : '密钥仅服务端加密保存,任何界面不回显明文'}</span></div>` +
`<span class="vend-acts">` +
`<button class="tbtn" data-vend-fetch="${esc(group.base_url)}"${draft.fetching ? ' disabled' : ''}>` +
`${group.models.length ? '重新拉取模型列表' : '获取模型列表'}</button>` +
`<button class="pbtn" data-vend-save="${esc(group.base_url)}">保存供应商</button></span>` +
`</div>` +
vendorFetchHtml(group, draft) +
group.models.map((model) => modelRowHtml(model, group.label)).join('');
const head = `<span class="vend-name">${ledHtml(group.configured ? 'ok' : 'off')}${esc(group.label)}</span>` +
vendorStateTag(group, draft) +
tagHtml('cy', `${group.models.length} 个模型`);
return `<section class="panel"><header class="panel-hd vend-hd">${head}</header>` +
`<div class="panel-b vend-body">${body}</div></section>`;
}
function modelsHtml() {
const data = state.models;
if (!data) return panelHtml('模型池', '', '<p class="form-hint">正在从主站读取模型池…</p>');
const named = (id) => (data.models.find((model) => model.id === id) || {}).name || '';
const option = (model, want) =>
`<option value="${esc(model.id)}"${model.id === want ? ' selected' : ''}>${esc(model.name || model.id)}</option>`;
const strip = `<div class="panel strip">` +
`<span style="font-size:13px;color:var(--txt-strong)">供应商 <span class="num" style="color:var(--cy)">${data.groups.length}</span>` +
` · 模型 <span class="num" style="color:var(--cy)">${data.models.length}</span> 个` +
` · 主模型 <span style="color:var(--cy)">${esc(named(data.primary_model_id) || '未指定')}</span>` +
` · 辅助模型 <span style="color:${data.fallback_model_id ? 'var(--amb)' : 'var(--dim)'}">${esc(named(data.fallback_model_id) || '未启用')}</span></span>` +
`<span class="flex1"></span>` +
`<span style="font-size:10px;color:var(--dim);letter-spacing:.12em">密钥加密存储 · 保存后全站即时生效 · 自主站系统管理迁入</span>` +
`</div>`;
const routing = panelHtml('调用编排 ROUTING',
`<span style="font-size:10px;color:var(--dim)">主模型故障时自动回落辅助模型</span>`,
`<div class="route-line">` +
`<div class="field"><span class="lab">主模型 PRIMARY</span><select id="primaryModel">` +
`<option value="">未指定</option>${data.models.map((model) => option(model, data.primary_model_id)).join('')}</select></div>` +
`<div class="field"><span class="lab">辅助模型 FALLBACK</span><select id="fallbackModel">` +
`<option value="">不启用辅助模型</option>${data.models.map((model) => option(model, data.fallback_model_id)).join('')}</select></div>` +
`<button class="pbtn" id="saveRoles">保存模型池</button>` +
`<span class="form-hint">主 / 辅不可指向同一模型</span></div>`);
const vendors = data.groups.map(vendorCardHtml).join('');
const adder = `<section class="panel"><div class="panel-b form-actions">` +
`<button class="tbtn" id="addVendor">+ 添加供应商</button>` +
`<select id="newVendorPreset"><option value="">自定义接口地址…</option>` +
data.vendors.map((vendor) => `<option value="${esc(vendor.base_url)}">${esc(vendor.label)}</option>`).join('') +
`</select>` +
`<input id="newVendorUrl" placeholder="https://api.example.com/v1">` +
`<span class="form-hint">同一供应商(同一 API 地址)可挂多个模型 · 主 / 辅正在引用的模型不可删除,需先在上方切换</span>` +
`</div></section>`;
return `<div class="col">${strip}${routing}${vendors}${adder}</div>`;
}
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('会员管理', '', '<p class="form-hint">正在从主站读取会员与邀请码…</p>');
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 = `<section class="panel"><div class="panel-b" style="display:flex;flex-wrap:wrap;align-items:center;gap:10px 18px">` +
`<span style="font-size:13px;color:var(--txt-strong)">注册用户 <span class="num" style="color:var(--cy)">${users.length}</span>` +
`<span style="color:var(--dim)"> · </span>会员 <span class="num" style="color:var(--mint)">${memberCount}</span>` +
`<span style="color:var(--dim)"> · </span>今日调用 <span class="num" style="color:var(--cy)">${usage}</span></span>` +
`<span class="flex1"></span>` +
`<span class="lab" style="margin:0">会员每日调用额度</span>` +
`<input id="memberQuota" class="num" value="${esc(String(quota))}" inputmode="numeric" style="width:90px;background:var(--panel);border:1px solid var(--line2);border-radius:4px;color:var(--txt);font-family:inherit;font-size:12px;padding:6px 9px">` +
`<button class="pbtn" id="saveQuota">保存额度</button></div></section>`;
const rows = users.map((u) => {
const id = String(u.id);
const canEdit = u.role !== 'admin';
return `<tr>` +
`<td style="color:var(--txt-strong);white-space:nowrap">${esc(u.username)}</td>` +
`<td><span style="display:flex;gap:6px">${memberIdentityHtml(u)}</span></td>` +
`<td class="num">${esc(String(u.used_today ?? 0))}</td>` +
`<td><select data-member-status="${esc(id)}"${canEdit ? '' : ' disabled title="管理员会员状态由主站固定,不在此处调整"'}>` +
[['inactive', '未开通'], ['active', '有效'], ['suspended', '停用']]
.map(([v, l]) => `<option value="${v}"${memberStatusValue(u) === v ? ' selected' : ''}>${l}</option>`).join('') +
`</select></td>` +
`<td>${canEdit
? `<select data-member-duration="${esc(id)}">` +
MEMBER_DURATIONS.map(([v, l]) => `<option value="${v}">${l}</option>`).join('') + `</select>`
: '<span style="color:var(--dim)">—</span>'}</td>` +
`<td style="font-size:11px;color:${u.membership_subscribed ? 'var(--txt)' : 'var(--dim)'};white-space:nowrap">${esc(memberExpiryText(u))}</td>` +
`<td class="cell-actions">${canEdit ? `<button class="tbtn" data-member-save="${esc(id)}">应用</button>` : '<span style="color:var(--dim)">—</span>'}</td>` +
`</tr>`;
}).join('');
const memberPanel = panelHtml('会员管理 MEMBERS', `<span style="font-size:10px;color:var(--dim)">自主站系统管理迁入</span>`,
`<div class="wide-scroll"><table class="dtable wide-table"><thead><tr>` +
`<th>用户名</th><th>身份</th><th>今日调用</th><th>会员状态</th><th>开通 / 续期</th><th>当前到期</th><th>操作</th>` +
`</tr></thead><tbody>${rows || '<tr><td colspan="7" class="empty-hint">暂无注册用户</td></tr>'}</tbody></table></div>`, 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 `<tr>` +
`<td class="invite-code"${c.status === 'unused' ? '' : ' style="color:var(--dim)"'}>${esc(fresh || c.code_masked)}</td>` +
`<td>${tone}</td>` +
`<td class="num" style="font-size:11px">${esc(stamp(c.created_at) || '—')}</td>` +
`<td style="font-size:11px;color:var(--mut)">${use}</td>` +
`<td class="cell-actions">` +
(c.status === 'unused'
? `<button class="tbtn" data-invite-copy="${esc(c.code_id)}">复制</button>` +
`<button class="tbtn" data-invite-revoke="${esc(c.code_id)}">作废</button>`
: '<span style="color:var(--dim)">—</span>') +
`</td></tr>`;
}).join('');
const counts = [1, 2, 3, 5, 10].map((n) => `<option value="${n}">${n} 个</option>`).join('');
const invitePanel = panelHtml('一次性邀请码 INVITE CODES',
`<span style="font-size:10px;color:var(--dim)">生成</span>` +
`<select id="inviteCount" style="background:var(--panel);border:1px solid var(--line2);border-radius:4px;color:var(--txt);font-family:inherit;font-size:11px;padding:5px 8px">${counts}</select>` +
`<button class="pbtn" id="createInvites">生成邀请码</button>`,
`<div class="invite-note">${ledHtml('slow')}每个邀请码仅可成功注册一次 · 注册必须提交有效邀请码 · 并发重复使用只会成功一次,其余请求立即失效</div>` +
`<div class="wide-scroll"><table class="dtable wide-table"><thead><tr>` +
`<th>邀请码</th><th>状态</th><th>生成时间</th><th>使用情况</th><th>操作</th>` +
`</tr></thead><tbody>${codeRows || '<tr><td colspan="5" class="empty-hint">还没有邀请码</td></tr>'}</tbody></table></div>` +
`<div class="table-foot">邀请码仅掩码显示 · 完整码仅在生成瞬间与「复制」动作中可得 · 已使用 / 已作废不可恢复</div>`, false);
return `${strip}<div class="grid-1">${memberPanel}${invitePanel}</div>`;
}
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 `<span class="tape-item${fresh ? ' flash' : ''}"><span class="tt num">${esc(hm(e.ts))}</span><span class="ts">${esc(e.src)}</span><span class="tm">${esc(e.msg)}</span>` +
(e.ok ? `<span class="tok num">✓${e.ms ? ` ${e.ms}ms` : ''}</span>` : '<span class="tbad">× 失败</span>') +
`<span class="tsep">///</span></span>`;
}
function renderTape(freshIds) {
const items = tapeBuf.slice(-14);
const html = items.length
? items.map((e) => tapeEventHtml(e, freshIds && freshIds.has(e.sortId))).join('')
: `<span class="tape-item"><span class="tm" style="color:var(--dim)">暂无真实事件 · 等待首次调用/任务</span><span class="tsep">///</span></span>`;
$('tapeA').innerHTML = html;
$('tapeB').innerHTML = html;
}
function tapeEventsFromRaw() {
const evs = [];
((RAW.overview && RAW.overview.recent_calls) || []).forEach((c) => { if (c.id > tapeSeen.call) evs.push({ ts: c.created_at, src: 'tushare', msg: c.endpoint, ok: c.ok, ms: c.latency_ms, sortId: 'c' + c.id }); });
((RAW.providers && RAW.providers.recent_calls) || []).forEach((c) => { if (c.id > tapeSeen.provCall) evs.push({ ts: c.fetched_at, src: c.provider, msg: c.interface, ok: c.status === 'ok', ms: c.latency_ms, sortId: 'p' + c.id }); });
((RAW.audit && RAW.audit.items) || []).forEach((a) => { if (a.id > tapeSeen.audit) evs.push({ ts: a.created_at, src: 'hub_admin', msg: `${a.action} ${a.target || ''}`.trim(), ok: true, sortId: 'a' + a.id }); });
((RAW.jobs && RAW.jobs.runs) || []).forEach((r) => { if (r.id > tapeSeen.job) evs.push({ ts: r.finished_at || r.started_at, src: 'scheduler', msg: `${r.job_id} ${r.state}`, ok: r.state !== 'failed', sortId: 'j' + r.id }); });
const maxOf = (arr, key) => arr.reduce((m, x) => Math.max(m, x[key] || 0), 0);
tapeSeen.call = Math.max(tapeSeen.call, maxOf((RAW.overview && RAW.overview.recent_calls) || [], 'id'));
tapeSeen.provCall = Math.max(tapeSeen.provCall, maxOf((RAW.providers && RAW.providers.recent_calls) || [], 'id'));
tapeSeen.audit = Math.max(tapeSeen.audit, maxOf((RAW.audit && RAW.audit.items) || [], 'id'));
tapeSeen.job = Math.max(tapeSeen.job, maxOf((RAW.jobs && RAW.jobs.runs) || [], 'id'));
return evs;
}
function seedTape() {
// 首次加载:预装最近的真实事件(打样 EVENT TAPE 开场即有内容滚动)
const evs = [];
((RAW.providers && RAW.providers.recent_calls) || []).forEach((c) => evs.push({ ts: c.fetched_at, src: c.provider, msg: c.interface, ok: c.status === 'ok', ms: c.latency_ms, sortId: 'p' + c.id }));
((RAW.overview && RAW.overview.recent_calls) || []).forEach((c) => evs.push({ ts: c.created_at, src: 'tushare', msg: c.endpoint, ok: c.ok, ms: c.latency_ms, sortId: 'c' + c.id }));
((RAW.jobs && RAW.jobs.runs) || []).forEach((r) => evs.push({ ts: r.finished_at || r.started_at, src: 'scheduler', msg: `${r.job_id} ${r.state}`, ok: r.state !== 'failed', sortId: 'j' + r.id }));
((RAW.audit && RAW.audit.items) || []).forEach((a) => evs.push({ ts: a.created_at, src: 'hub_admin', msg: `${a.action} ${a.target || ''}`.trim(), ok: true, sortId: 'a' + a.id }));
evs.sort((x, y) => String(x.ts).localeCompare(String(y.ts)));
tapeBuf = evs.slice(-14);
tapeSeeded = true;
renderTape(null);
}
function updateTape() {
if (!tapeSeeded) { seedTape(); return; }
const evs = tapeEventsFromRaw();
if (!evs.length) return;
const fresh = new Set(evs.map((e) => e.sortId));
tapeBuf = tapeBuf.concat(evs).slice(-16);
renderTape(fresh);
}
/* ---------- 抽屉:调度任务 / 盘后发布 / 审计 ---------- */
let drawerTab = 'jobs';
function openOpsDrawer(tab, ctx) {
drawerTab = tab || 'jobs';
drawerCtx = ctx || {};
$('drawerMask').classList.add('open');
renderDrawer();
}
function closeOpsDrawer() { $('drawerMask').classList.remove('open'); }
let drawerCtx = {};
$('drawerClose').addEventListener('click', closeOpsDrawer);
$('drawerMask').addEventListener('click', (e) => { if (e.target.id === 'drawerMask') closeOpsDrawer(); });
$('opsBtn').addEventListener('click', () => openOpsDrawer('jobs', {}));
$('drawerTabs').addEventListener('click', (e) => {
const b = e.target.closest('[data-dtab]');
if (!b) return;
drawerTab = b.dataset.dtab;
renderDrawer();
});
function renderDrawer() {
$('drawerTabs').querySelectorAll('[data-dtab]').forEach((b) => b.classList.toggle('act', b.dataset.dtab === drawerTab));
$('drawerSub').textContent = { jobs: '既有调度手动触发', release: '盘后发布 / 回滚 / 补数', audit: '操作审计日志' }[drawerTab] || '';
if (drawerTab === 'jobs') renderDrawerJobs();
else if (drawerTab === 'release') renderDrawerRelease();
else renderDrawerAudit();
}
function renderDrawerJobs() {
const jobs = (RAW.jobs && RAW.jobs.jobs) || [];
const runs = (RAW.jobs && RAW.jobs.runs) || [];
const runsByJob = {};
runs.forEach((r) => { (runsByJob[r.job_id] = runsByJob[r.job_id] || []).push(r); });
$('drawerBody').innerHTML = `
<div class="drawer-section">
<div class="lab">调度任务定义</div>
<table class="dtable"><thead><tr><th>任务</th><th>时刻</th><th>操作</th></tr></thead><tbody>
${jobs.map((j) => `<tr><td style="color:var(--txt-strong)">${esc(j.id)} · ${esc(j.title)}</td><td class="num" style="color:var(--mut)">${esc(j.at)}</td>
<td><input class="lin-q" data-run-date="${esc(j.id)}" placeholder="YYYYMMDD 留空=今天" style="width:150px"><button class="opbtn" data-run="${esc(j.id)}">手动触发</button></td></tr>`).join('')}
</tbody></table>
</div>
<div class="drawer-section">
<div class="lab">最近运行(最新 20 条)</div>
<table class="dtable"><thead><tr><th>ID</th><th>任务</th><th>状态</th><th>开始</th><th>结束</th><th>错误</th></tr></thead><tbody>
${runs.slice(0, 20).map((r) => `<tr><td class="num">${r.id}</td><td>${esc(r.job_id)}</td><td>${pillHtml(bucketOf(r.state))}</td><td class="num" style="font-size:11px">${esc(r.started_at || '')}</td><td class="num" style="font-size:11px">${esc(r.finished_at || '')}</td><td style="color:var(--rd);font-size:11px">${esc(r.error || '')}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-hint">暂无运行记录</td></tr>'}
</tbody></table>
</div>`;
$('drawerBody').querySelectorAll('[data-run]').forEach((btn) => {
btn.addEventListener('click', async () => {
const dateInput = $('drawerBody').querySelector(`[data-run-date="${btn.dataset.run}"]`);
try {
await api(`/admin/api/jobs/${btn.dataset.run}/run`, { method: 'POST', body: JSON.stringify({ trade_date: (dateInput && dateInput.value) || '' }) });
toast('已触发:' + btn.dataset.run, 'ok');
await refreshQuiet(); renderDrawer();
} catch (err) { toast(err.message, 'err'); }
});
});
}
function renderDrawerRelease() {
const date = drawerCtx.date || today();
$('drawerBody').innerHTML = `
<div class="dtoolbar"><label style="font-size:11px;color:var(--mut)">日期 <input id="relDate" value="${esc(date)}" style="margin-left:6px"></label>
<button class="opbtn" id="relLoad">查看</button>
<button class="opbtn danger" id="relBackfill">补数</button></div>
<div id="relBody"></div>`;
$('relLoad').addEventListener('click', async () => { drawerCtx.date = $('relDate').value; await loadRelease(); });
$('relBackfill').addEventListener('click', () => openDangerModal('backfill', { dataset: drawerCtx.dataset || '', date: drawerCtx.date || date }));
loadRelease();
}
async function loadRelease() {
const date = drawerCtx.date || today();
let data;
try { data = await api(`/admin/api/batches?date=${encodeURIComponent(date)}`); } catch (err) { toast(err.message, 'err'); return; }
$('relBody').innerHTML = `
<div class="drawer-section"><div class="lab">发布映射</div>
<table class="dtable"><thead><tr><th>数据集</th><th>活跃批次</th><th>上一批次</th><th>状态</th><th>发布时间</th><th>操作</th></tr></thead><tbody>
${data.publications.map((row) => `<tr><td style="color:var(--txt-strong)">${esc(row.dataset)}</td><td class="num" style="font-size:11px">${esc(row.active_batch)}</td><td class="num" style="font-size:11px">${esc(row.prev_batch || '')}</td><td>${pillHtml(bucketOf(row.state))}</td><td class="num" style="font-size:11px">${esc(row.published_at || '')}</td>
<td>${row.prev_batch ? `<button class="opbtn danger" data-rollback="${esc(row.dataset)}">回滚</button>` : '—'}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-hint">当日暂无发布记录</td></tr>'}
</tbody></table></div>
<div class="drawer-section"><div class="lab">批次</div>
<table class="dtable"><thead><tr><th>batch_id</th><th>数据集</th><th>状态</th><th>行数</th><th>错误</th></tr></thead><tbody>
${data.batches.map((row) => `<tr><td class="num" style="font-size:11px">${esc(row.batch_id)}</td><td>${esc(row.dataset)}</td><td>${pillHtml(bucketOf(row.state))}</td><td class="num">${row.rows_out ?? ''}</td><td style="color:var(--rd);font-size:11px">${esc(row.error || '')}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-hint">当日暂无批次</td></tr>'}
</tbody></table></div>`;
$('relBody').querySelectorAll('[data-rollback]').forEach((btn) => btn.addEventListener('click', () => openDangerModal('rollback', { dataset: btn.dataset.rollback, date })));
}
function renderDrawerAudit() {
const items = (RAW.audit && RAW.audit.items) || [];
$('drawerBody').innerHTML = `<table class="dtable"><thead><tr><th>时间</th><th>操作者</th><th>动作</th><th>对象</th><th>详情</th></tr></thead><tbody>
${items.map((row) => `<tr><td class="num" style="font-size:11px">${esc(row.created_at)}</td><td>${esc(row.actor)}</td><td style="color:var(--cy)">${esc(row.action)}</td><td style="font-size:11px">${esc(row.target || '')}</td><td style="font-size:11px;color:var(--mut);max-width:220px;overflow:hidden;text-overflow:ellipsis">${esc(row.detail || '')}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-hint">暂无审计记录</td></tr>'}
</tbody></table>`;
}
/* ---------- 危险操作确认弹层(回滚 / 补数) ---------- */
let dangerCtx = null;
function openDangerModal(kind, ctx) {
dangerCtx = { kind, ...ctx };
const word = `${ctx.dataset}:${ctx.date}`;
$('modalTitle').textContent = kind === 'rollback' ? '确认回滚' : '确认补数';
$('modalDesc').textContent = kind === 'rollback'
? `将把 ${ctx.dataset}${ctx.date} 的发布回退到上一批次,需管理密码与确认词。`
: `将对 ${ctx.dataset || '(请先在发布列表选择数据集)'}${ctx.date} 重新补数并发布,需管理密码与确认词。`;
$('modalConfirmWord').textContent = word;
$('modalPassword').value = ''; $('modalConfirm').value = ''; $('modalErr').textContent = '';
$('modalMask').classList.add('open');
}
$('modalCancel').addEventListener('click', () => $('modalMask').classList.remove('open'));
$('modalMask').addEventListener('click', (e) => { if (e.target.id === 'modalMask') $('modalMask').classList.remove('open'); });
$('modalOk').addEventListener('click', async () => {
if (!dangerCtx) return;
const password = $('modalPassword').value;
const confirm = $('modalConfirm').value;
const path = dangerCtx.kind === 'rollback' ? '/admin/api/rollback' : '/admin/api/backfill';
try {
await api(path, { method: 'POST', body: JSON.stringify({ dataset: dangerCtx.dataset, trade_date: dangerCtx.date, password, confirm }) });
$('modalMask').classList.remove('open');
toast('操作成功', 'ok');
await refreshQuiet();
if (state.page === 'overview') renderPage('overview');
if (drawerTab === 'release') loadRelease();
} catch (err) {
$('modalErr').textContent = err.message;
}
});
/* ---------- toast ---------- */
let toastT = null;
function toast(msg, kind) {
const el = $('toastEl');
el.textContent = msg;
el.className = 'toast show' + (kind ? ' ' + kind : '');
clearTimeout(toastT);
toastT = setTimeout(() => { el.classList.remove('show'); }, 3200);
}
/* ================= 顶栏:时钟 / 减少动态 ================= */
let clockEls = null;
function updateClock() {
if (!clockEls) clockEls = { d: $('ckD'), h: $('ckH'), m: $('ckM'), s: $('ckS') };
const now = new Date();
const ds = `${now.getFullYear()}-${p2(now.getMonth() + 1)}-${p2(now.getDate())}`;
if (clockEls.d.textContent !== ds) clockEls.d.textContent = ds;
const hs = p2(now.getHours()), ms = p2(now.getMinutes()), ss = p2(now.getSeconds());
if (clockEls.h.textContent !== hs) clockEls.h.textContent = hs;
if (clockEls.m.textContent !== ms) clockEls.m.textContent = ms;
if (clockEls.s.textContent !== ss) clockEls.s.textContent = ss;
}
function applyCalm(on) {
state.calm = on;
// 挂在 body + #app(覆盖 appRoot 及其兄弟 drawer/modal/toast),否则
// .reduce-motion * 选不中旁路层过渡。
document.body.classList.toggle('reduce-motion', on);
const app = $('app');
if (app) app.classList.toggle('reduce-motion', on);
$('calmBtn').textContent = on ? '恢复动态' : '减少动态';
$('calmBtn').classList.toggle('on', on);
}
$('calmBtn').addEventListener('click', () => applyCalm(!state.calm));
if (window.matchMedia) {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
const syncMq = () => { if (mq.matches && !state.calm) applyCalm(true); };
syncMq();
mq.addEventListener ? mq.addEventListener('change', syncMq) : mq.addListener(syncMq);
}
/* ================= 动态更新:200ms tick(打样节奏) =================
- 时钟每秒翻字(冒号 blink 由 CSS 承担)
- 缓存年龄依据真实时间戳每秒增长(>30s 变琥珀)
- 倒计时按真实 next_retry_at 每秒递减
- sparkline 端点闪烁由 CSS 承担,折线只在真实历史变化时重画
- 延迟数字只在真实值变化时闪一下(flash),绝不随机伪造数值 */
function paintSparks() {
document.querySelectorAll('svg.spark').forEach((svg) => sparkPaint(svg));
}
const prevLat = new Map();
function flashChangedLatencies() {
document.querySelectorAll('[data-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();