主站 - 新增 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>
2036 lines
116 KiB
JavaScript
2036 lines
116 KiB
JavaScript
'use strict';
|
||
/* =====================================================================
|
||
小白复盘 · 数据中枢 admin/app.js — HEL-529 返工:按定稿 100% 还原
|
||
视觉与 DOM 结构逐行照抄已确认打样 hub-kimi.html(v12 血缘压缩增补),
|
||
但本文件不含任何随机/模拟数据——全部状态、数字、事件均来自真实接口:
|
||
/admin/api/{overview,sources,jobs,batches,audit,...} 与 HEL-543 只读旁路
|
||
/admin/api/{providers/status,source-catalog,lineage,lineage/affected}。
|
||
动效遵循 SPEC-V11:marquee 46s 线性 / flowline 1.4s / radar 4.2s /
|
||
blink 1.1s steps(2) / ledPing 1.6s / sparkBlink 1.4s / 时钟冒号 blink /
|
||
延迟仅在真实值变化时闪烁(禁止随机伪造数值)/ 缓存年龄每秒真实增长。
|
||
===================================================================== */
|
||
|
||
/* ================= 基础:DOM helper / API / CSRF ================= */
|
||
function $(id) { return document.getElementById(id); }
|
||
function esc(v) { return String(v ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
||
|
||
const state = {
|
||
csrf: '', page: 'overview', calm: false, username: '',
|
||
reviewUrl: '', theme: 'night',
|
||
// 站点侧数据(模型池 / 会员 / 邀请码)由主站经桥接返回,只在本页缓存
|
||
models: null, members: null, invites: null,
|
||
vendorDraft: {}, freshCodes: {}, modelTests: {},
|
||
};
|
||
|
||
async function api(path, options = {}) {
|
||
const headers = Object.assign({ 'Content-Type': 'application/json' }, options.headers || {});
|
||
if (state.csrf && (options.method || 'GET') !== 'GET') headers['X-CSRF-Token'] = state.csrf;
|
||
const res = await fetch(path, Object.assign({}, options, { headers, credentials: 'same-origin' }));
|
||
const body = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
const msg = (body.error && body.error.message) || body.error || res.statusText;
|
||
const error = new Error(msg);
|
||
error.status = res.status;
|
||
error.detail = body;
|
||
throw error;
|
||
}
|
||
return body;
|
||
}
|
||
|
||
/* ================= 认证:主站会话门禁(数据中枢无独立账号) =================
|
||
浏览器在同一主机不同端口间共享主站的 xiaobai_session cookie,本页只把它
|
||
交给 /admin/api/session 由主站校验:未登录 401 → 引导去主站登录页,
|
||
非管理员 403 → 明确拒绝,管理员才渲染控制台外壳。 */
|
||
function showView(id) {
|
||
$('gate-view').hidden = id !== 'gate';
|
||
$('appRoot').hidden = id !== 'shell';
|
||
}
|
||
|
||
function showGate(title, desc, links) {
|
||
$('gate-title').textContent = title;
|
||
$('gate-desc').textContent = desc;
|
||
const login = $('gate-login');
|
||
const site = $('gate-site');
|
||
login.hidden = !links.loginUrl;
|
||
if (links.loginUrl) login.href = links.loginUrl;
|
||
site.hidden = !links.reviewUrl;
|
||
if (links.reviewUrl) site.href = links.reviewUrl;
|
||
showView('gate');
|
||
}
|
||
|
||
async function boot() {
|
||
applyTheme(localStorage.getItem('datahub-theme') === 'day' ? 'day' : 'night');
|
||
try {
|
||
const session = await api('/admin/api/session');
|
||
state.csrf = session.csrf;
|
||
state.username = session.username;
|
||
state.reviewUrl = session.review_url || '';
|
||
$('who').textContent = session.username;
|
||
showView('shell');
|
||
await startApp();
|
||
} catch (err) {
|
||
const detail = err.detail || {};
|
||
if (err.status === 403) {
|
||
showGate('仅管理员可进入', '数据中枢只对管理员开放。当前登录账号「' + (detail.username || '未知') + '」没有管理员权限,请用管理员账号登录主站后再进入。', { reviewUrl: state.reviewUrl || '/' });
|
||
return;
|
||
}
|
||
const loginUrl = detail.login_url || '';
|
||
if (loginUrl) state.reviewUrl = loginUrl.replace(/\/login\/?$/, '');
|
||
showGate('请先登录小白复盘', '数据中枢使用主站账号,未检测到登录状态。到主站用管理员账号登录后回到本页即可。', { loginUrl: loginUrl });
|
||
}
|
||
}
|
||
|
||
$('gate-retry').addEventListener('click', () => { boot(); });
|
||
|
||
$('logout-btn').addEventListener('click', async () => {
|
||
let loginUrl = '';
|
||
try {
|
||
const result = await api('/admin/api/logout', { method: 'POST', body: '{}' });
|
||
loginUrl = result.login_url || '';
|
||
} catch {}
|
||
Poller.stop();
|
||
showGate('已退出登录', '主站会话已结束。重新登录后可再次进入数据中枢。', { loginUrl: loginUrl });
|
||
});
|
||
|
||
/* ---------- 日间 / 夜间主题(同一组 token 换值,不叠加覆盖样式) ---------- */
|
||
function applyTheme(theme) {
|
||
state.theme = theme === 'day' ? 'day' : 'night';
|
||
document.documentElement.dataset.theme = state.theme;
|
||
localStorage.setItem('datahub-theme', state.theme);
|
||
const btn = $('themeBtn');
|
||
if (btn) btn.textContent = state.theme === 'day' ? '夜间' : '日间';
|
||
}
|
||
$('themeBtn').addEventListener('click', () => applyTheme(state.theme === 'day' ? 'night' : 'day'));
|
||
|
||
/* ================= 状态归类(真实状态字符串 → 7 类视觉桶) =================
|
||
桶: ok / slow / fail / plan / review / degrade / off
|
||
来源覆盖:observability.classify_error/classify_rows、CircuitBreaker、
|
||
eod/revision progress、batches/publications、job_runs 的全部真实取值。 */
|
||
const HEALTH = {
|
||
ok: { label: '正常', led: 'ok', color: 'var(--mint)' },
|
||
slow: { label: '慢', led: 'slow', color: 'var(--amb)' },
|
||
fail: { label: '失败', led: 'fail', color: 'var(--rd)' },
|
||
plan: { label: '计划', led: 'plan', color: 'var(--mut)' },
|
||
review: { label: '复核中', led: 'rev', color: 'var(--cy)' },
|
||
degrade: { label: '降级', led: 'slow', color: 'var(--amb)' },
|
||
off: { label: '未配置', led: 'off', color: 'var(--dim)' },
|
||
};
|
||
const STATE_MAP = {
|
||
ok: 'ok', closed: 'ok', published: 'ok', done: 'ok', aligned: 'ok',
|
||
half_open: 'slow', waiting_upstream: 'slow',
|
||
open: 'fail', timeout: 'fail', blocked: 'fail', empty: 'fail', missing_fields: 'fail',
|
||
error: 'fail', failed: 'fail', cutoff_failed: 'fail', cutoff: 'fail', review_failed: 'fail',
|
||
degraded: 'degrade', stale: 'degrade',
|
||
pending_first_attempt: 'plan', pending_publish: 'plan',
|
||
running: 'review', staged: 'review', waiting_review: 'review',
|
||
unconfigured: 'off', reserved: 'off', unknown: 'off', closed_day: 'off',
|
||
};
|
||
function bucketOf(rawState) { return STATE_MAP[String(rawState || '').toLowerCase()] || 'off'; }
|
||
const SEV = { fail: 6, degrade: 5, slow: 4, review: 3, plan: 2, off: 1, ok: 0 };
|
||
function worstBucket(list) {
|
||
let best = 'ok', bestSev = -1;
|
||
for (const b of list) { const s = SEV[b] ?? 0; if (s > bestSev) { bestSev = s; best = b; } }
|
||
return list.length ? best : 'ok';
|
||
}
|
||
|
||
/* ================= 渲染小工具(结构照抄 hub-kimi.html) ================= */
|
||
function ledHtml(st, pulse) { return `<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 ellip(styles.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:15–23:30 · 每 30 分钟 · 成功即停 · 已试 ${eod.attempts || 0} 次${(eod.missing_datasets || []).length ? ' · 缺 ' + eod.missing_datasets.join(',') : ''}`, dataset: (eod.missing_datasets || [])[0] || '', cd: eod.next_retry_at });
|
||
}
|
||
const hidx = {};
|
||
((RAW.providers && RAW.providers.health) || []).forEach((r) => { (hidx[r.provider] = hidx[r.provider] || []).push(r); });
|
||
Object.keys(hidx).forEach((prov) => {
|
||
const bad = (hidx[prov] || []).filter((r) => (r.consec_failures || 0) > 0 && bucketOf(r.state) === 'fail');
|
||
if (bad.length) incidents.push({ sev: 'fail', tag: prov, title: `${prov} 部分接口连续失败`, meta: bad.map((r) => `${r.interface}×${r.consec_failures}`).join(' · '), provider: prov });
|
||
});
|
||
const ifindCat = catalogByProv.ifind;
|
||
if (ifindCat && !(ifindCat.credential && ifindCat.credential.configured)) {
|
||
incidents.push({ sev: 'off', tag: '未配置', title: 'iFinD · 授权源未启用', meta: '凭证未配置 · 功能隐藏 · 配置后启用', provider: 'ifind' });
|
||
}
|
||
|
||
return {
|
||
trade_date: ov.trade_date, session_phase: ov.session_phase, eod, rev,
|
||
publishedCount, totalOfficial, anomalyCount, successRate,
|
||
fallbackPages, datasetCards, observers, siteImpact, okPages, timeline, incidents,
|
||
unpublished: OFFICIAL_DATASET_IDS.filter((id) => !pubsByDs[id] || pubsByDs[id].state !== 'published'),
|
||
};
|
||
}
|
||
|
||
function heroHtml(vm) {
|
||
const anomalyTxt = vm.anomalyCount ? `<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.py:credential_key 为空的来源(eastmoney/tencent 等公开
|
||
// 接口)真实语义是"无需凭证"而非"已配置",两者都体现为 configured:true,
|
||
// 靠 note 字段区分,这里必须原样展示 note,不能一律折叠成"已配置"。
|
||
const credBadge = card.credential.note ? card.credential.note :
|
||
card.credential.configured ? `已配置${card.credential.last4 ? ' · ' + card.credential.last4 : ''}` : '未配置';
|
||
const badges = [credBadge];
|
||
if (card.credential.configured && card.credential.updated_at) badges.push(`${String(card.credential.updated_at).slice(5, 10).replace('-', '/')} 更新`);
|
||
badges.push(card.id === 'tushare' ? '主(无备)' : (card.id === 'ifind' ? '图表主源(未启用)' : (card.id === 'tencent' ? '实时层备' : '实时层主')));
|
||
let h = `<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();
|