fix(HEL-529): 布局鲁棒、问师血缘修正与动效语义增强

布局:表格长错误/备注统一单行大白话摘要+截断(.ellip),完整原文放
title 悬停;当前来源列 nowrap 防逐字竖排;压力态(长错误/多接口/多观测行)
1920×920、1920×1080、1024 均无横向溢出、血缘 1080P 一屏。
血缘:按真实调用代码修正——ifind_wencai 的真实消费者是股票池事件补充
(pools/service.py:82),不是问师;新增 ifind_history 条目对应问师·趋势/
宏观思维模型的可选指数/ETF 矩阵 (mentor/service.py:433 ifind.history,
未配置时 fail-open);新增 3 项自动测试锁定映射并以源码调用点为证。
动效:延迟/成功率仅在真实采样值变化时翻动高亮(data-rate flash);
spark-end/血缘心跳按行/节点错峰相位;页面隐藏时动态循环不做任何工作;
LINK 呼吸(gnode-pulse)与真实调用脉冲(node-ping)语义区分保持。
测试:全套 194 项通过。

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
施工员2号
2026-09-15 15:11:32 +08:00
co-authored by multica-agent
parent 78931f9c42
commit 35ee43ea02
4 changed files with 129 additions and 14 deletions
+46 -12
View File
@@ -162,6 +162,30 @@ function hm(iso) {
if (idx < 0 || s.length < idx + 6) return s.slice(0, 5) || '—';
return s.slice(idx + 1, idx + 6);
}
/* 大白话错误摘要:主表只放一行短句,完整原文放 title 悬停提示。
不猜业务结论,只做确定性文案映射 + 定长截断。 */
function plainError(raw, max = 18) {
const s = String(raw || '');
if (!s) return '';
const rules = [
[/UNIQUE constraint failed/i, '重复数据写入冲突'],
[/circuit open|breaker/i, '熔断保护中'],
[/timeout|timed out|ETIMEDOUT/i, '请求超时'],
[/row ratio/i, '行数校验未达标'],
[/field gate/i, '字段完整率未达标'],
[/empty official batch|no_rows_returned|empty_or_incomplete/i, '上游返回空数据'],
[/release group not switched/i, '发布组未切换'],
[/missing_fields/i, '返回缺字段'],
[/data_age_exceeds/i, '数据过期超阈值'],
[/blocked|captcha|安全验证|访问异常/i, '被上游拦截'],
];
for (const [re, text] of rules) if (re.test(s)) return text;
return s.length > max ? s.slice(0, max) + '…' : s;
}
/* 单行截断单元格:class ellipstyles.css 生产补充段),title 放全文 */
function ellipHtml(text, color, fontSize, title) {
return `<span class="ellip" style="color:${color};font-size:${fontSize || ''}" title="${esc(title ?? text)}">${esc(text)}</span>`;
}
const p2 = (x) => String(x).padStart(2, '0');
/* ================= sparkline(真实历史:来自 provider_call_log ================= */
@@ -377,7 +401,7 @@ function heroHtml(vm) {
`</div><span class="flex1"></span>` +
`<div class="kpis">` +
kpiHtml('已发布数据集', `<span class="glow-mint">${vm.publishedCount}</span><span style="color:#54637e;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:#54637e">—</span>' : `<span>${vm.successRate.toFixed(1)}%</span>`, null, 'mint', vm.successRate != null) +
kpiHtml('今日调用成功率', vm.successRate == null ? '<span style="color:#54637e">—</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>`;
@@ -389,7 +413,7 @@ function datasetStripHtml(vm) {
let note = '';
if (d.time) note += `<span class="num" style="color:#8b9bb4">${esc(d.time)} · </span>`;
if (d.rows) note += `<span class="num">${Number(d.rows).toLocaleString('en-US')} 行</span>`;
if (d.err) { const brief = String(d.err).length > 22 ? String(d.err).slice(0, 22) + '…' : d.err; note += `<span style="color:#f87171" title="${esc(d.err)}">${esc(brief)}</span>`; }
if (d.err) note += `<span class="ellip" style="color:#f87171" title="${esc(d.err)}">${esc(plainError(d.err, 16))}</span>`;
if (!d.time && !d.rows && !d.err) note = '<span style="color:#54637e">今日尚未发布</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>` +
@@ -403,11 +427,11 @@ function observatoryHtml(vm) {
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:#e8f1ff">${esc(o.name)}</td>` +
`<td style="color:#8b9bb4">${o.provider === 'eastmoney' ? '东方财富' : '腾讯行情'}</td>` +
`<td style="color:#8b9bb4;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><span style="display:flex;align-items:center;gap:8px">${pillHtml(o.bucket)}${o.note ? `<span class="obs-note">${esc(o.note)}</span>` : ''}</span></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);
@@ -418,7 +442,7 @@ function siteImpactHtml(vm) {
const rows = bad.length ? bad.slice(0, 5).map((s) => `<tr>` +
`<td style="color:#e8f1ff;width:40%">${esc(s.page)}</td>` +
`<td style="width:20%">${pillHtml(s.bucket)}</td>` +
`<td style="font-size:11px;color:#54637e">${s.note ? esc(s.note) : '—'}</td>` +
`<td style="font-size:11px;color:#54637e;max-width:220px">${s.note ? ellipHtml(plainError(s.note, 22), '#54637e', '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:#e8f1ff;width:40%">全部页面正常</td><td>${pillHtml('ok')}</td><td style="font-size:11px;color:#54637e">—</td><td></td></tr>`;
const footPages = vm.siteImpact.map((s) => ({ name: s.short, bucket: s.bucket }));
@@ -479,7 +503,7 @@ function incidentsHtml(vm) {
return `<div class="inc ${it.sev}">${tag}` +
`<div style="flex:1;min-width:0">` +
`<div class="inc-title"${it.sev === 'off' ? ' style="color:#8b9bb4"' : ''}>${esc(it.title)}</div>` +
`<div class="inc-meta">${meta}</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>`;
@@ -589,7 +613,7 @@ function sourceCardHtml(card) {
`<span>接口 <span class="num" style="color:${card.bucket === 'off' ? '#54637e' : '#22d3ee'}">${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:#f87171">最近错误 ${esc(card.error)}</span>` : (card.bucket !== 'off' ? `<span class="msep">|</span><span style="color:#54637e">无最近错误</span>` : '')) +
(card.error ? `<span class="msep">|</span><span style="color:#f87171;display:inline-flex;align-items:center;gap:4px;min-width:0">最近错误 ${ellipHtml(plainError(card.error, 26), '#f87171', '11px', card.error)}</span>` : (card.bucket !== 'off' ? `<span class="msep">|</span><span style="color:#54637e">无最近错误</span>` : '')) +
`</div>`;
if (ui.open && card.bucket !== 'off') {
h += `<div class="mx-grid">` + card.groups.map((g) => `<div style="min-width:0">` +
@@ -600,7 +624,7 @@ function sourceCardHtml(card) {
`<td style="color:#8b9bb4;font-size:11px;white-space:nowrap">${esc(it.use)}</td>` +
`<td class="num" style="font-size:11px;color:${it.ok.includes('×') || it.ok === '昨日' ? '#f87171' : '#8b9bb4'}">${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 ? `<span style="font-size:10px;color:#f87171">${esc(it.err)}</span>` : ''}</span></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), '#f87171', '10px', it.err) : ''}</span></td>` +
`</tr>`).join('') +
`</tbody></table></div>`).join('') + `</div>`;
}
@@ -765,7 +789,7 @@ function lineageGraphHtml(vm) {
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" fill="rgba(34,211,238,.05)" stroke="#243152"/>` +
`<circle cx="${p.x}" cy="${p.y}" r="5" fill="${stColorOf(s.st)}"${s.st !== 'off' ? ' class="gnode-pulse"' : ''}/>` +
`<circle cx="${p.x}" cy="${p.y}" r="5" fill="${stColorOf(s.st)}"${s.st !== 'off' ? ` class="gnode-pulse" style="animation-delay:${(vm.sources.indexOf(s) * 0.37).toFixed(2)}s"` : ''}/>` +
`<text x="${p.x + 24}" y="${p.y + 4}" font-size="12" fill="#d7e1f0" font-weight="600">${esc(s.name)}</text></g>`;
});
vm.datasets.forEach((d) => {
@@ -838,7 +862,7 @@ function renderLinRows(vm) {
`<td class="num" style="color:#8b9bb4;font-size:11px;white-space:nowrap">${esc(r.freq)}</td>` +
`<td class="num" style="font-size:11px;white-space:nowrap;color:#8b9bb4">${r.ok ? esc(hm(r.ok)) : '—'}</td>` +
`<td class="num" style="font-size:11px;color:${r.lat != null && r.lat > 1000 ? '#fbbf24' : '#8b9bb4'}">${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) ? `<span style="font-size:10px;color:#54637e">${esc(r.note)}</span>` : ''}</span></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), '#54637e', '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:#e8f1ff;white-space:nowrap">${esc(r.page)}<span class="sub" style="color:#8b9bb4">${esc(r.item)}</span></td>` +
@@ -846,7 +870,7 @@ function renderLinRows(vm) {
`<td style="color:#8b9bb4;font-size:11px;word-break:break-all">${esc(r.via)}</td>` +
`<td class="num" style="color:#8b9bb4;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:#8b9bb4">${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">${esc(r.note)}</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() {
@@ -1212,6 +1236,16 @@ function paintSparks() {
}
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;
@@ -1369,7 +1403,7 @@ async function startApp() {
renderPage(pageFromHash());
updateTape(); // 预装最近真实事件并开始滚动
updateClock();
setInterval(() => { updateClock(); updateDynamics(); }, 200); // 打样 tick 节奏
setInterval(() => { if (document.hidden) return; updateClock(); updateDynamics(); }, 200); // 打样 tick 节奏;页面隐藏时不做任何工作
await Poller.start();
}