Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3203574b6a | ||
|
|
35ee43ea02 | ||
|
|
78931f9c42 | ||
|
|
6f99ee9d9e | ||
|
|
9b2f0993d3 |
@@ -162,6 +162,30 @@ function hm(iso) {
|
|||||||
if (idx < 0 || s.length < idx + 6) return s.slice(0, 5) || '—';
|
if (idx < 0 || s.length < idx + 6) return s.slice(0, 5) || '—';
|
||||||
return s.slice(idx + 1, idx + 6);
|
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');
|
const p2 = (x) => String(x).padStart(2, '0');
|
||||||
|
|
||||||
/* ================= sparkline(真实历史:来自 provider_call_log) ================= */
|
/* ================= sparkline(真实历史:来自 provider_call_log) ================= */
|
||||||
@@ -377,7 +401,7 @@ function heroHtml(vm) {
|
|||||||
`</div><span class="flex1"></span>` +
|
`</div><span class="flex1"></span>` +
|
||||||
`<div class="kpis">` +
|
`<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('已发布数据集', `<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('主站回退页面', `${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') +
|
kpiHtml('EOD 尝试', `${vm.eod.attempts || 0} 次`, '上限 5 · 23:30 止', vm.eod.attempts ? 'rd' : 'txt') +
|
||||||
`</div></div>`;
|
`</div></div>`;
|
||||||
@@ -389,7 +413,7 @@ function datasetStripHtml(vm) {
|
|||||||
let note = '';
|
let note = '';
|
||||||
if (d.time) note += `<span class="num" style="color:#8b9bb4">${esc(d.time)} · </span>`;
|
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.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>';
|
if (!d.time && !d.rows && !d.err) note = '<span style="color:#54637e">今日尚未发布</span>';
|
||||||
return `<div class="panel dsc ${ring}" data-dataset="${d.id}">` +
|
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-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 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>` +
|
const rows = vm.observers.length ? vm.observers.map((o) => `<tr>` +
|
||||||
`<td style="color:#e8f1ff">${esc(o.name)}</td>` +
|
`<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>${ageHtml(o.lastOk)}</td>` +
|
||||||
`<td>${latHtml(o.lat, o.bucket !== 'ok')}</td>` +
|
`<td>${latHtml(o.lat, o.bucket !== 'ok')}</td>` +
|
||||||
`<td>${sparkHtml(o.jid, o.bucket === 'slow' ? 'amb' : 'mint', 64, 16)}</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>`;
|
`</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>`;
|
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);
|
return panelHtml('实时观察层', right, body, false);
|
||||||
@@ -418,7 +442,7 @@ function siteImpactHtml(vm) {
|
|||||||
const rows = bad.length ? bad.slice(0, 5).map((s) => `<tr>` +
|
const rows = bad.length ? bad.slice(0, 5).map((s) => `<tr>` +
|
||||||
`<td style="color:#e8f1ff;width:40%">${esc(s.page)}</td>` +
|
`<td style="color:#e8f1ff;width:40%">${esc(s.page)}</td>` +
|
||||||
`<td style="width:20%">${pillHtml(s.bucket)}</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>` +
|
`<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>`;
|
`</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 }));
|
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}` +
|
return `<div class="inc ${it.sev}">${tag}` +
|
||||||
`<div style="flex:1;min-width:0">` +
|
`<div style="flex:1;min-width:0">` +
|
||||||
`<div class="inc-title"${it.sev === 'off' ? ' style="color:#8b9bb4"' : ''}>${esc(it.title)}</div>` +
|
`<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>` +
|
`</div>` +
|
||||||
`<button class="tbtn" style="flex:none" data-incident-action data-dataset="${esc(it.dataset || '')}" data-provider="${esc(it.provider || '')}">去处理 →</button>` +
|
`<button class="tbtn" style="flex:none" data-incident-action data-dataset="${esc(it.dataset || '')}" data-provider="${esc(it.provider || '')}">去处理 →</button>` +
|
||||||
`</div>`;
|
`</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>接口 <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>最后探测 ${probeCell}</span>` +
|
||||||
`<span class="msep">|</span><span>延迟 ${latHtml(card.latency)}</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>`;
|
`</div>`;
|
||||||
if (ui.open && card.bucket !== 'off') {
|
if (ui.open && card.bucket !== 'off') {
|
||||||
h += `<div class="mx-grid">` + card.groups.map((g) => `<div style="min-width:0">` +
|
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 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 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="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('') +
|
`</tr>`).join('') +
|
||||||
`</tbody></table></div>`).join('') + `</div>`;
|
`</tbody></table></div>`).join('') + `</div>`;
|
||||||
}
|
}
|
||||||
@@ -765,7 +789,7 @@ function lineageGraphHtml(vm) {
|
|||||||
const p = gpos['s:' + s.id]; if (!p) return;
|
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">` +
|
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="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>`;
|
`<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) => {
|
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="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;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 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>`;
|
`</tr>`).join('') || `<tr><td colspan="9" class="empty-hint">无匹配记录 · 调整筛选条件</td></tr>`;
|
||||||
if (bodyN) bodyN.innerHTML = rows.map((r) => `<tr class="${cls(r.st)}">` +
|
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>` +
|
`<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 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="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 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>`;
|
`</tr>`).join('') || `<tr><td colspan="6" class="empty-hint">无匹配记录 · 调整筛选条件</td></tr>`;
|
||||||
}
|
}
|
||||||
function lineageHtml() {
|
function lineageHtml() {
|
||||||
@@ -1212,6 +1236,16 @@ function paintSparks() {
|
|||||||
}
|
}
|
||||||
const prevLat = new Map();
|
const prevLat = new Map();
|
||||||
function flashChangedLatencies() {
|
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) => {
|
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 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 v = el.dataset.lat;
|
||||||
@@ -1369,7 +1403,7 @@ async function startApp() {
|
|||||||
renderPage(pageFromHash());
|
renderPage(pageFromHash());
|
||||||
updateTape(); // 预装最近真实事件并开始滚动
|
updateTape(); // 预装最近真实事件并开始滚动
|
||||||
updateClock();
|
updateClock();
|
||||||
setInterval(() => { updateClock(); updateDynamics(); }, 200); // 打样 tick 节奏
|
setInterval(() => { if (document.hidden) return; updateClock(); updateDynamics(); }, 200); // 打样 tick 节奏;页面隐藏时不做任何工作
|
||||||
await Poller.start();
|
await Poller.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -375,6 +375,18 @@ body {
|
|||||||
0% { transform: scale(.4); opacity: .8; }
|
0% { transform: scale(.4); opacity: .8; }
|
||||||
80%, 100% { transform: scale(3.2); opacity: 0; }
|
80%, 100% { transform: scale(3.2); opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- HEL-529 第二轮返工生产补充(布局鲁棒 + 错峰编排) ----------
|
||||||
|
1) .ellip:表格/卡片里的长错误与备注统一单行截断,完整原文走 title;
|
||||||
|
2) spark-end / LED 相位错峰:同一动画不同 delay,避免所有元素同频同步。
|
||||||
|
均复用既有 token 与动效曲线,不新增缓动、不改打样规则。 ---------- */
|
||||||
|
.ellip { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; max-width: 100%; display: inline-block; vertical-align: bottom; }
|
||||||
|
.ellip:empty { display: none; }
|
||||||
|
table.dtable tbody tr:nth-child(2n) .spark-end { animation-delay: -.7s; }
|
||||||
|
table.dtable tbody tr:nth-child(3n) .spark-end { animation-delay: -.35s; }
|
||||||
|
.ds-grid .dsc:nth-child(2n) .spark-end { animation-delay: -.5s; }
|
||||||
|
.ds-grid .dsc:nth-child(3n) .spark-end { animation-delay: -.9s; }
|
||||||
|
.ds-grid .dsc:nth-child(5n) .spark-end { animation-delay: -.2s; }
|
||||||
.lin-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid #1a2540; }
|
.lin-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid #1a2540; }
|
||||||
.lin-sel, .lin-q { background: #0c1220; border: 1px solid #243152; border-radius: 4px; font-size: 11px; color: #8b9bb4; padding: 6px 8px; outline: none; font-family: inherit; }
|
.lin-sel, .lin-q { background: #0c1220; border: 1px solid #243152; border-radius: 4px; font-size: 11px; color: #8b9bb4; padding: 6px 8px; outline: none; font-family: inherit; }
|
||||||
.lin-q { color: #d7e1f0; padding: 6px 10px; width: 220px; }
|
.lin-q { color: #d7e1f0; padding: 6px 10px; width: 220px; }
|
||||||
|
|||||||
@@ -184,9 +184,31 @@ DATASETS: list[dict[str, Any]] = [
|
|||||||
"tier": "licensed",
|
"tier": "licensed",
|
||||||
"update_freq": "按需调用",
|
"update_freq": "按需调用",
|
||||||
"v1_endpoint": "/v1/query (api_name=ifind_wencai)",
|
"v1_endpoint": "/v1/query (api_name=ifind_wencai)",
|
||||||
"primary_source": "ifind:smart_stock_picking",
|
"primary_source": "ifind:wencai",
|
||||||
"backup_source": None,
|
"backup_source": None,
|
||||||
"known_consumers": ["问师(自然语言选股,需 iFinD 凭证)"],
|
# Real call site: backend/features/pools/service.py:82 (wencai(query,"stock"))
|
||||||
|
# feeds 股票池's ifind_event_enrichment_v1 (涨停/炸板/跌停原因、首末
|
||||||
|
# 涨停时间、开板次数). 问师 does NOT call wencai anywhere — its main
|
||||||
|
# body is LLM + 主站市场快照 (mentor/service.py builds context from
|
||||||
|
# dashboard/popularity snapshots only).
|
||||||
|
"known_consumers": ["股票池(涨停/炸板/跌停事件补充 enrichment,需 iFinD 凭证)"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dataset": "ifind_history",
|
||||||
|
"tier": "licensed",
|
||||||
|
"update_freq": "问师问询时按需 · 45 日回看",
|
||||||
|
"v1_endpoint": "/v1/query (api_name=ifind_history)",
|
||||||
|
"primary_source": "ifind:history",
|
||||||
|
"backup_source": None,
|
||||||
|
# Real call site: backend/features/mentor/service.py:433-457
|
||||||
|
# (_mentor_market_matrix → ifind.history(close/volume/amount, 45 日回看)).
|
||||||
|
# Only the trend/macro thinking-model profiles use it, and only when
|
||||||
|
# iFinD is configured — it fails open to [] otherwise. 问师其余子能力
|
||||||
|
# (本体问答/低吸/人气上下文等) 不依赖 iFinD.
|
||||||
|
"known_consumers": [
|
||||||
|
"问师·趋势思维模型(指数动量矩阵,可选)",
|
||||||
|
"问师·宏观思维模型(宽基指数与核心ETF矩阵,可选)",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1491,7 +1491,19 @@ class Pipeline:
|
|||||||
else:
|
else:
|
||||||
keys = [(row.get("ts_code"), row.get("trade_date")) for row in rows]
|
keys = [(row.get("ts_code"), row.get("trade_date")) for row in rows]
|
||||||
dup = row_n - len(set(keys))
|
dup = row_n - len(set(keys))
|
||||||
|
# Extended soft datasets (popularity/dragon_tiger/…) already collapse
|
||||||
|
# within-batch dups in _dedupe_staging_rows before INSERT. Upstream
|
||||||
|
# ths/dc (and similar) routinely emit duplicate business keys; those
|
||||||
|
# collapsed dups must not hard-fail publish (HEL-562). Datasets without
|
||||||
|
# a staging business key — and all hard/core soft gates — still treat
|
||||||
|
# raw duplicate keys as errors.
|
||||||
|
staging_collapses_dups = (
|
||||||
|
dataset in EXTENDED_SOFT_DATASETS and dataset in STAGING_KEY_FIELDS
|
||||||
|
)
|
||||||
if dup:
|
if dup:
|
||||||
|
if staging_collapses_dups:
|
||||||
|
warnings.append(f"duplicate keys: {dup}")
|
||||||
|
else:
|
||||||
errors.append(f"duplicate keys: {dup}")
|
errors.append(f"duplicate keys: {dup}")
|
||||||
bad_date = sum(1 for row in rows if str(row.get("trade_date")) != trade_date)
|
bad_date = sum(1 for row in rows if str(row.get("trade_date")) != trade_date)
|
||||||
if bad_date:
|
if bad_date:
|
||||||
@@ -1511,7 +1523,8 @@ class Pipeline:
|
|||||||
field_report = self._field_gate(dataset, trade_date, rows, errors)
|
field_report = self._field_gate(dataset, trade_date, rows, errors)
|
||||||
if dataset in SOFT_DATASETS:
|
if dataset in SOFT_DATASETS:
|
||||||
allow_empty = dataset in {"popularity", "dragon_tiger", "moneyflow", "auction"}
|
allow_empty = dataset in {"popularity", "dragon_tiger", "moneyflow", "auction"}
|
||||||
hard_fail = bool(dup or bad_date or (empty and not allow_empty))
|
hard_dup = 0 if staging_collapses_dups else dup
|
||||||
|
hard_fail = bool(hard_dup or bad_date or (empty and not allow_empty))
|
||||||
else:
|
else:
|
||||||
hard_fail = bool(errors) and (dataset in HARD_DATASETS or dataset == STOCKS_DATASET)
|
hard_fail = bool(errors) and (dataset in HARD_DATASETS or dataset == STOCKS_DATASET)
|
||||||
report = {
|
report = {
|
||||||
|
|||||||
@@ -108,6 +108,91 @@ class StagingDedupePublishTests(_Base):
|
|||||||
kept = dt[0]
|
kept = dt[0]
|
||||||
self.assertEqual(kept["buy_amount"], 300)
|
self.assertEqual(kept["buy_amount"], 300)
|
||||||
|
|
||||||
|
def test_hel562_popularity_collapsed_dups_publish_not_hard_fail(self) -> None:
|
||||||
|
"""HEL-562: staging dedupe alone is not enough — quality gate used to
|
||||||
|
hard-fail on the same within-batch dups after they were already
|
||||||
|
collapsed (live 20260915: duplicate keys: 3 → integrity_gate)."""
|
||||||
|
trade_date = "20240902"
|
||||||
|
# 3 within-batch dups on (ts_code, trade_date, source=dc) — mirrors
|
||||||
|
# ths+dc merge where dc_hot repeats the same keys.
|
||||||
|
rows = [
|
||||||
|
{"ts_code": "600000.SH", "trade_date": trade_date, "source": "ths",
|
||||||
|
"ts_name": "浦发银行", "rank": 1, "pct_change": 1.2, "current_price": 10.2,
|
||||||
|
"hot": 90.0, "concept": "银行", "data_type": "热股"},
|
||||||
|
{"ts_code": "000001.SZ", "trade_date": trade_date, "source": "dc",
|
||||||
|
"ts_name": "平安银行", "rank": 1, "pct_change": 2.0, "current_price": 11.0,
|
||||||
|
"hot": 88.0, "concept": "银行", "data_type": "A股市场"},
|
||||||
|
{"ts_code": "600000.SH", "trade_date": trade_date, "source": "dc",
|
||||||
|
"ts_name": "浦发银行", "rank": 2, "pct_change": 1.2, "current_price": 10.2,
|
||||||
|
"hot": 80.0, "concept": "银行", "data_type": "A股市场"},
|
||||||
|
{"ts_code": "600000.SH", "trade_date": trade_date, "source": "dc",
|
||||||
|
"ts_name": "浦发银行", "rank": 3, "pct_change": 1.3, "current_price": 10.3,
|
||||||
|
"hot": 81.0, "concept": "银行", "data_type": "A股市场"},
|
||||||
|
{"ts_code": "600000.SH", "trade_date": trade_date, "source": "dc",
|
||||||
|
"ts_name": "浦发银行", "rank": 4, "pct_change": 1.4, "current_price": 10.4,
|
||||||
|
"hot": 82.0, "concept": "银行", "data_type": "A股市场"},
|
||||||
|
]
|
||||||
|
report = self.hub.pipeline.validate("popularity", "b-gate", trade_date, rows)
|
||||||
|
self.assertFalse(report["hard_fail"])
|
||||||
|
self.assertEqual(report["errors"], [])
|
||||||
|
self.assertEqual(report["warnings"], ["duplicate keys: 2"])
|
||||||
|
|
||||||
|
result = self.hub.pipeline.run_dataset("popularity", trade_date, prepared_rows=rows)
|
||||||
|
# warnings present → soft_fail → publication state is degraded (still served)
|
||||||
|
self.assertEqual(result["state"], "degraded")
|
||||||
|
self.assertFalse(result["quality"]["hard_fail"])
|
||||||
|
self.assertTrue(result["quality"]["soft_fail"])
|
||||||
|
self.assertIn("duplicate keys: 2", result["quality"]["warnings"])
|
||||||
|
eod = self.hub.db.fetchall(
|
||||||
|
"SELECT * FROM eod_popularity WHERE trade_date = ? AND batch_id = ?",
|
||||||
|
(trade_date, result["batch_id"]),
|
||||||
|
)
|
||||||
|
# 5 raw → 3 unique keys after staging collapse (ths + two dc codes)
|
||||||
|
self.assertEqual(len(eod), 3)
|
||||||
|
pub = self.hub.db.fetchone(
|
||||||
|
"SELECT * FROM publications WHERE dataset='popularity' AND trade_date=?",
|
||||||
|
(trade_date,),
|
||||||
|
)
|
||||||
|
self.assertEqual(pub["active_batch"], result["batch_id"])
|
||||||
|
self.assertEqual(pub["state"], "degraded")
|
||||||
|
# Serving path accepts degraded the same as published (no DATASET_NOT_PUBLISHED)
|
||||||
|
from datahub.serving import V1API
|
||||||
|
|
||||||
|
api = V1API(self.hub.db, self.hub.pipeline, self.hub.settings)
|
||||||
|
payload = api.handle("/v1/popularity", {"date": [trade_date]})
|
||||||
|
self.assertEqual(len(payload["data"]), 3)
|
||||||
|
self.assertEqual(payload["meta"]["state"], "degraded")
|
||||||
|
self.assertEqual(payload["meta"]["batch_id"], result["batch_id"])
|
||||||
|
|
||||||
|
def test_hel562_core_soft_still_hard_fails_on_duplicate_keys(self) -> None:
|
||||||
|
"""moneyflow/auction stay on the old soft gate: raw dups → hard_fail."""
|
||||||
|
trade_date = "20240902"
|
||||||
|
rows = [
|
||||||
|
{"ts_code": "600000.SH", "trade_date": trade_date,
|
||||||
|
"buy_sm_amount": 1, "sell_sm_amount": 1, "buy_md_amount": 1, "sell_md_amount": 1,
|
||||||
|
"buy_lg_amount": 1, "sell_lg_amount": 1, "buy_elg_amount": 1, "sell_elg_amount": 1,
|
||||||
|
"net_mf_amount": 0},
|
||||||
|
{"ts_code": "600000.SH", "trade_date": trade_date,
|
||||||
|
"buy_sm_amount": 2, "sell_sm_amount": 2, "buy_md_amount": 2, "sell_md_amount": 2,
|
||||||
|
"buy_lg_amount": 2, "sell_lg_amount": 2, "buy_elg_amount": 2, "sell_elg_amount": 2,
|
||||||
|
"net_mf_amount": 0},
|
||||||
|
]
|
||||||
|
report = self.hub.pipeline.validate("moneyflow", "b-mf", trade_date, rows)
|
||||||
|
self.assertTrue(report["hard_fail"])
|
||||||
|
self.assertIn("duplicate keys: 1", report["errors"])
|
||||||
|
self.assertEqual(report["warnings"], [])
|
||||||
|
|
||||||
|
def test_hel562_popularity_date_mismatch_still_hard_fails(self) -> None:
|
||||||
|
"""Collapsed-dup carve-out must not weaken other soft integrity checks."""
|
||||||
|
rows = [
|
||||||
|
{"ts_code": "600000.SH", "trade_date": "20240901", "source": "ths",
|
||||||
|
"ts_name": "浦发银行", "rank": 1, "pct_change": 1.2, "current_price": 10.2,
|
||||||
|
"hot": 90.0, "concept": "银行", "data_type": "热股"},
|
||||||
|
]
|
||||||
|
report = self.hub.pipeline.validate("popularity", "b-bad-date", "20240902", rows)
|
||||||
|
self.assertTrue(report["hard_fail"])
|
||||||
|
self.assertIn("date mismatch rows: 1", report["errors"])
|
||||||
|
|
||||||
|
|
||||||
class OverviewAnomalyConvergenceTests(_Base):
|
class OverviewAnomalyConvergenceTests(_Base):
|
||||||
def _seed_batches(self, today: str) -> None:
|
def _seed_batches(self, today: str) -> None:
|
||||||
@@ -209,5 +294,52 @@ class SourceCatalogJoinTests(_Base):
|
|||||||
self.assertTrue(item.get("update_freq"), f"missing update_freq for {item['dataset']}")
|
self.assertTrue(item.get("update_freq"), f"missing update_freq for {item['dataset']}")
|
||||||
|
|
||||||
|
|
||||||
|
class LineageMentorIfindTests(_Base):
|
||||||
|
"""问师 → iFinD 血缘修正(第二轮返工):不按名称猜关系,按真实调用代码。"""
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
def test_no_mentor_dependency_on_ifind_wencai(self) -> None:
|
||||||
|
items = self.hub.admin.lineage("20240902")["items"]
|
||||||
|
wencai = [i for i in items if i["dataset"] == "ifind_wencai"]
|
||||||
|
self.assertEqual(len(wencai), 1)
|
||||||
|
consumers = wencai[0]["known_consumers"]
|
||||||
|
for consumer in consumers:
|
||||||
|
self.assertNotIn("问师", consumer, f"wencai consumer must not be 问师: {consumer}")
|
||||||
|
all_consumers = " ".join(c for i in items for c in i["known_consumers"])
|
||||||
|
self.assertNotIn("问师(自然语言选股", all_consumers)
|
||||||
|
|
||||||
|
def test_wencai_real_consumer_is_pools_enrichment_with_code_evidence(self) -> None:
|
||||||
|
items = self.hub.admin.lineage("20240902")["items"]
|
||||||
|
wencai = [i for i in items if i["dataset"] == "ifind_wencai"][0]
|
||||||
|
self.assertTrue(any("股票池" in c for c in wencai["known_consumers"]))
|
||||||
|
# Real call evidence in the main-site source tree:
|
||||||
|
pools_src = (self.REPO / "backend" / "features" / "pools" / "service.py").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("ifind.wencai(", pools_src)
|
||||||
|
self.assertIn("ifind_event_enrichment_v1", pools_src)
|
||||||
|
# And 问师 itself never calls wencai:
|
||||||
|
mentor_src = (self.REPO / "backend" / "features" / "mentor" / "service.py").read_text(encoding="utf-8")
|
||||||
|
self.assertNotIn(".wencai(", mentor_src)
|
||||||
|
|
||||||
|
def test_mentor_optional_ifind_history_subcapabilities(self) -> None:
|
||||||
|
items = self.hub.admin.lineage("20240902")["items"]
|
||||||
|
history = [i for i in items if i["dataset"] == "ifind_history"]
|
||||||
|
self.assertEqual(len(history), 1)
|
||||||
|
consumers = history[0]["known_consumers"]
|
||||||
|
self.assertTrue(any("趋势思维模型" in c for c in consumers))
|
||||||
|
self.assertTrue(any("宏观思维模型" in c for c in consumers))
|
||||||
|
# every consumer must be a 问师 sub-capability, not the whole board
|
||||||
|
for consumer in consumers:
|
||||||
|
self.assertIn("·", consumer, f"not a sub-capability mapping: {consumer}")
|
||||||
|
# Real call evidence: mentor builds market matrices via ifind.history
|
||||||
|
mentor_src = (self.REPO / "backend" / "features" / "mentor" / "service.py").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("ifind.history(", mentor_src)
|
||||||
|
self.assertIn("_mentor_market_matrix", mentor_src)
|
||||||
|
self.assertIn("MENTOR_INDEX_UNIVERSE", mentor_src)
|
||||||
|
self.assertIn("MENTOR_ETF_UNIVERSE", mentor_src)
|
||||||
|
# Optional dependency: fails open when ifind is not configured
|
||||||
|
self.assertIn("if not ifind or not ifind.configured", mentor_src)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user