feat(all): 科学计算正确性修复、调度竞态消除、节点优雅退出、安全加固与收敛分析重构

科学计算正确性:
- 修复 Fortran 无-E 科学记数法(指数≥100 时 E 被挤掉,如 -1.35+118)导致
  发散行被静默跳过、误判收敛的 bug;扩展大气无效检测覆盖 Inf 与 *** 溢出标记
- 种子匹配改为 CNO 有向距离(富金属方向重罚 4×、贫金属方向轻罚 1×),
  基于 1191 个真实种子配对回测标定,回测净改善 314 个点
- GridAxisValue 反序列化拒绝非法文本(不再静默 NaN);chmax≤0 显式报错
- ions 行宽列宽对齐真实 fort.5 格式

调度与队列竞态:
- 原子选点(IMMEDIATE 事务 SELECT+UPDATE)消除并发调度重复派发 (#5)
- 调度互斥锁 + 冷启动优先策略(SeedStep 仅作失败后救援,不再正常路径热启动)
- 毒消息 dead_letter 标记防出队死循环;clear_queue 保留 claimed 行 (#6)
- 孤儿 running 点回收兜底;stale_sec 默认 7800→21600s(3 倍超时缓冲)

节点生命周期:
- SIGTERM+SIGINT 双信号监听(修复 Docker stop 发 SIGTERM 不触发优雅退出)
- SlotGuard RAII 防活动 slot 泄漏;子进程超时增加二级 30s wait 防 Fortran hang
- SeedStep 种子下载 fail-fast + 沙盒私有副本解耦 LRU 清理竞争
- reqwest Client 增加连接/请求超时;启动清理残留 task_* 沙盒

安全加固:
- 节点注册 registration_secret 二次凭据 (H8),恒定时间比对防时序旁路
- token 缓存 generation 机制消除 reissue 后旧 token TOCTOU 复活窗口
- 新增 /api/auth/logout 服务端 session 即时撤销;fail-closed 鉴权启动策略
- 前端 token 迁移 sessionStorage;YAML 高亮改 DOM API 消除 XSS 注入面
- 备份文件权限收紧 0600;点表动态值全面 escapeHtml

前端 Dashboard:
- 收敛性分析从热力图重构为 Parallel Sets 平行集合图(6 维+状态轴,手写 SVG 零依赖)
- 进度曲线横轴改为真实时间(服务端 now 锚定,停滞期诚实留白);轮询指数退避
- 移除 imported 收敛途径分类,导入点按实际途径 cold_run/seed_step 归类
- 初始化时 /api/auth/check 校验 token;401 toast 提示替代静默 reload

服务端恢复与工具链:
- 启动恢复 initializing 态工作流;默认工作流 INSERT-only 不覆盖 API 编辑
- body limit 分层(10MB 不再截断 256MB report);multipart 显式错误处理
- 嵌入二进制原子写(tmp+rename)防半写损坏
- import_results 判定收敛途径透传 success_method;conv.json 格式对齐本项目
- push_import_results.sh 退出码修复 + .bat UTF-8 BOM + scp 上传
- Docker USE_MIRRORS 默认关闭;移除无用 assets 挂载;删除 hosts.ini 入库
This commit is contained in:
fmq
2026-08-01 17:01:40 +08:00
parent 1bfa240cb0
commit c8fd24b120
45 changed files with 2821 additions and 831 deletions
+461 -134
View File
@@ -19,6 +19,7 @@ import {
let pollTimer = null;
let onVisibility = null;
let onResize = null;
let abortCtl = null;
let currentName = null;
let activeTab = 'overview';
@@ -28,13 +29,16 @@ let latestStats = null;
let pointsFilter = { status: '', method: '', wave: '', q: '', sort: 'wave', order: 'asc', limit: 100, offset: 0 };
let pointsBuilt = false;
// Tab3 收敛性分析状态
const HEAT_DIMS = ['teff', 'logg', 'loghe', 'logc', 'logn', 'logo', 'cno_sum', 'wave'];
let heatState = { points: [], x: 'teff', y: 'cno_sum', slice: {} };
let heatBuilt = false;
// Tab3 收敛性分析状态Parallel Sets 平行集合图:6 维类别轴 + 状态轴,色带宽度=点数)
const PS_DIMS = ['teff', 'logg', 'loghe', 'logc', 'logn', 'logo'];
let psState = { points: [], hover: null }; // hover: 高亮的状态 keynull=无)
let psBuilt = false;
// 进度曲线节流:快照 ~30s 一条,前端 30s 拉一次即可
let lastProgressAt = 0;
// 最近一次进度数据缓存:轮询每 5s 用 innerHTML 重建概览面板会清空曲线 DOM,
// 故每次重建后用它立即重绘——拉取仍按 30s 节流,渲染不再等节流(否则两次拉取之间空白)。
let lastProgress = null;
const STATUS_MAP = {
running: ['运行中', 'online'],
@@ -66,6 +70,13 @@ function fmtTime(iso) {
return d.toLocaleTimeString('zh-CN');
}
/** 同 fmtTime 的规范化口径,返回 epoch 毫秒(解析失败为 NaN)。供进度曲线按真实时间铺横轴。 */
function parseTsMs(iso) {
if (!iso) return NaN;
const normalized = iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z';
return new Date(normalized).getTime();
}
// ===== 生命周期 =====
export function mountWorkflowDetail(name) {
@@ -73,6 +84,13 @@ export function mountWorkflowDetail(name) {
activeTab = 'overview';
latestStats = null;
lastProgressAt = 0;
lastProgress = null;
// 切换工作流时重置各 tab 缓存,避免显示上一个工作流的残留数据
pointsBuilt = false;
pointsFilter = { status: '', method: '', wave: '', q: '', sort: 'wave', order: 'asc', limit: 100, offset: 0 };
psBuilt = false;
psState.points = [];
psState.hover = null;
const root = document.getElementById('view-root');
if (!root) return;
root.innerHTML = pageSkeleton(name);
@@ -83,6 +101,7 @@ export function mountWorkflowDetail(name) {
export function unmountWorkflowDetail() {
stopScopedPolling();
closePointPanel();
if (psTooltipEl) { psTooltipEl.remove(); psTooltipEl = null; }
currentName = null;
latestStats = null;
const root = document.getElementById('view-root');
@@ -94,6 +113,9 @@ export function unmountWorkflowDetail() {
function startScopedPolling(name) {
stopScopedPolling();
abortCtl = new AbortController();
// 连续失败退避计数:失败越多间隔越长(指数退避,上限 60s),成功即重置为基准 5s。
// 避免服务端持续故障时空轮询刷请求与 console.warn。
let failCount = 0;
onVisibility = () => {
if (document.hidden) {
if (pollTimer) {
@@ -105,6 +127,18 @@ function startScopedPolling(name) {
}
};
document.addEventListener('visibilitychange', onVisibility);
// Parallel Sets 用容器实测宽度布局,窗口缩放后重算(防抖;仅 analysis tab 且宽度显著变化时)
let resizeTimer = null;
onResize = () => {
if (activeTab !== 'analysis' || !psBuilt) return;
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
const c = document.getElementById('ps-container');
const prev = psState.layout?.W || 0;
if (c && Math.abs((c.clientWidth || 0) - prev) > 20) renderParSets();
}, 200);
};
window.addEventListener('resize', onResize);
schedule();
async function schedule() {
@@ -113,9 +147,21 @@ function startScopedPolling(name) {
pollTimer = null;
}
if (document.hidden) return;
await refreshStats(name);
if (activeTab === 'points') await refreshPoints();
pollTimer = setTimeout(schedule, 5000);
let ok = true;
try {
await refreshStats(name);
if (activeTab === 'points') await refreshPoints();
} catch (e) {
ok = false;
}
if (ok) {
failCount = 0;
} else {
failCount = Math.min(failCount + 1, 4); // 上限对应 ~5*2^4=80s,再 clamp 到 60s
}
// 指数退避:5s * 2^failCountclamp 到 [5s, 60s]
const delay = Math.min(5000 * Math.pow(2, failCount), 60000);
pollTimer = setTimeout(schedule, delay);
}
}
@@ -128,6 +174,10 @@ function stopScopedPolling() {
document.removeEventListener('visibilitychange', onVisibility);
onVisibility = null;
}
if (onResize) {
window.removeEventListener('resize', onResize);
onResize = null;
}
if (abortCtl) {
abortCtl.abort();
abortCtl = null;
@@ -148,7 +198,11 @@ async function refreshStats(name) {
if (json.success && json.data) {
latestStats = json.data;
renderStrip(json.data);
if (activeTab === 'overview') renderOverview(json.data);
if (activeTab === 'overview') {
renderOverview(json.data);
// renderOverview 重建 DOM 会清空曲线,用缓存立即重绘,避免两次 30s 拉取之间空白
if (lastProgress) renderSparkline(lastProgress);
}
maybeRefreshProgress();
}
} catch (err) {
@@ -346,7 +400,6 @@ function renderOverview(s) {
<div class="panel-body method-attribution">
<span class="method-badge cold">冷启动收敛 ${s.cold_run_converged}</span>
<span class="method-badge seed">种子步进收敛 ${s.seed_step_converged}</span>
<span class="method-badge imported">历史导入 ${s.imported_converged}</span>
<span class="method-badge fail">失败 ${s.failed}</span>
</div>
</div>
@@ -380,7 +433,10 @@ async function refreshProgress() {
const res = await fetchWorkflowProgressApi(currentName, { hours: 24 }, { signal: abortCtl?.signal });
if (!res.ok) return;
const json = await res.json();
if (json.success && json.data) renderSparkline(json.data);
if (json.success && json.data) {
lastProgress = json.data;
renderSparkline(json.data);
}
} catch (err) {
if (err.name === 'AbortError') return;
console.warn('获取进度曲线失败:', err);
@@ -402,10 +458,18 @@ function renderSparkline(prog) {
const PAD = 2;
const n = series.length;
const pctOf = (p, key) => (p.total > 0 ? (p[key] / p.total) * 100 : 0);
const x = (i) => (i / (n - 1)) * W;
// 横轴 = 真实时间(而非快照序号):快照只在计数变化时写入,按序号均分会把忙碌段拉长、
// 把停滞段抹平,曲线斜率 ≠ 实际速率。锚定窗口 [now-hours, now]now 由服务端返回,
// 避免前后端时钟偏差),右缘恒为“现在”,停滞期诚实显示为空白。
const tsMs = series.map((p) => parseTsMs(p.ts));
const tNow = parseTsMs(prog.now);
const tEnd = Number.isFinite(tNow) ? tNow : tsMs[n - 1];
const tStart = tEnd - (prog.hours || 24) * 3600 * 1000;
const span = Math.max(1, tEnd - tStart);
const x = (i) => ((tsMs[i] - tStart) / span) * W;
const y = (pct) => H - PAD - (Math.min(100, Math.max(0, pct)) / 100) * (H - PAD * 2);
const line = (key) => series.map((p, i) => `${x(i).toFixed(2)},${y(pctOf(p, key)).toFixed(2)}`).join(' ');
const areaPts = `0,${H} ${line('converged')} ${W},${H}`;
const areaPts = `${x(0).toFixed(2)},${H} ${line('converged')} ${x(n - 1).toFixed(2)},${H}`;
// 两条细线(2pxnon-scaling):收敛率实线 + 失败率虚线(CVD/打印下的第二编码)
wrap.innerHTML = `
@@ -430,12 +494,21 @@ function renderSparkline(prog) {
svg.addEventListener('mousemove', (e) => {
const rect = svg.getBoundingClientRect();
const rel = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
const idx = Math.round(rel * (n - 1));
const p = series[idx];
crosshair.setAttribute('x1', x(idx));
crosshair.setAttribute('x2', x(idx));
const t = tStart + rel * span;
// 横轴非均分,鼠标位置 → 时间 → 二分查找最近快照(tsMs 升序)
let lo = 0;
let hi = n - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (tsMs[mid] < t) lo = mid + 1;
else hi = mid;
}
if (lo > 0 && Math.abs(tsMs[lo - 1] - t) <= Math.abs(tsMs[lo] - t)) lo -= 1;
const p = series[lo];
crosshair.setAttribute('x1', x(lo));
crosshair.setAttribute('x2', x(lo));
crosshair.setAttribute('visibility', 'visible');
readout.textContent = `${p.ts.replace('T', ' ')} · 收敛 ${p.converged}/${p.total} (${pctOf(p, 'converged').toFixed(1)}%) · 失败 ${p.failed} · 运行 ${p.running} · 排队 ${p.queued}`;
readout.textContent = `${p.ts} · 收敛 ${p.converged}/${p.total} (${pctOf(p, 'converged').toFixed(1)}%) · 失败 ${p.failed} · 运行 ${p.running} · 排队 ${p.queued}`;
});
svg.addEventListener('mouseleave', () => {
crosshair.setAttribute('visibility', 'hidden');
@@ -491,7 +564,6 @@ function pointStatusBadge(status) {
function pointMethodBadge(method) {
if (method === 'cold_run') return '<span class="method-badge cold">冷启动</span>';
if (method === 'seed_step') return '<span class="method-badge seed">种子步进</span>';
if (method === 'imported') return '<span class="method-badge imported">导入</span>';
return '<span class="text-hint">—</span>';
}
@@ -523,7 +595,7 @@ function renderPointsTab() {
}
pointsBuilt = true;
const waveOpts = (latestStats?.waves || [])
.map(w => `<option value="${w.wave}">wave ${w.wave}</option>`)
.map(w => `<option value="${escapeHtml(w.wave)}">wave ${escapeHtml(w.wave)}</option>`)
.join('');
el.innerHTML = `
<div class="panel card">
@@ -542,7 +614,6 @@ function renderPointsTab() {
<option value="">全部手段</option>
<option value="cold_run">冷启动</option>
<option value="seed_step">种子步进</option>
<option value="imported">历史导入</option>
</select>
<select data-wf-filter="wave" aria-label="按波次过滤">
<option value="">全部波次</option>${waveOpts}
@@ -626,16 +697,16 @@ function renderPointsRows(tbody, points) {
const rescued = p.status === 'converged' && p.attempt_count > 1;
const html = `
<td class="col-point-name node-id" title="${name}">${name}</td>
<td class="tabular-num">${p.teff}</td>
<td class="tabular-num">${p.logg}</td>
<td class="tabular-num">${p.loghe}</td>
<td class="tabular-num">${p.cno_sum}</td>
<td class="tabular-num">${p.wave}</td>
<td class="tabular-num">${escapeHtml(p.teff)}</td>
<td class="tabular-num">${escapeHtml(p.logg)}</td>
<td class="tabular-num">${escapeHtml(p.loghe)}</td>
<td class="tabular-num">${escapeHtml(p.cno_sum)}</td>
<td class="tabular-num">${escapeHtml(p.wave)}</td>
<td>${pointStatusBadge(p.status)}</td>
<td>${pointMethodBadge(p.success_method)}</td>
<td class="col-max-relc tabular-num">${fmtRelc(p.last_max_relc)}</td>
<td class="tabular-num">${fmtDur(p.last_elapsed_sec)}</td>
<td class="tabular-num"${rescued ? ' title="冷启动失败后被种子步进救回"' : ''}>${p.attempt_count}${rescued ? ' ↻' : ''}</td>
<td class="tabular-num"${rescued ? ' title="冷启动失败后被种子步进救回"' : ''}>${escapeHtml(p.attempt_count)}${rescued ? ' ↻' : ''}</td>
<td>
<button type="button" class="btn btn-secondary btn-sm" data-point-name="${name}">查看</button>
</td>`;
@@ -667,142 +738,396 @@ function renderPointsPagination(total) {
if (next) next.disabled = cur >= pages;
}
// ===== Tab3 收敛性分析(冷启动成功热力图) =====
function dimValues(points, dim) {
const set = new Set(points.map(p => p[dim]));
return [...set].sort((a, b) => a - b);
// ===== Tab3 收敛性分析(Parallel Sets 平行集合图:6 维类别 + 状态,色带=点数) =====
//
// 为什么用 Parallel Sets 而非 PCP:网格是离散采样点(每维有限取值)+ 分类状态,
// PCP 用单条折线表示个体,离散维度上大量线重叠无法区分;Parallel Sets 用色带宽度
// 表示聚合数量,专为此场景设计——一眼看出「某维度某值的状态构成」与「跨维度流向」。
// 手写 SVG(零依赖,不碰 CSP),几何 = 按状态分段的堆叠条 + 条间贝塞尔色带。
/** 状态轴档位(收敛拆冷启动/种子步进,物理意义不同:稳定区 vs 救回区)。 */
const PS_STATUS = ['failed', 'cold', 'seed', 'running', 'queued', 'pending'];
const PS_STATUS_LABEL = {
cold: '冷启动收敛', seed: '种子步进', failed: '失败',
running: '运行', queued: '排队', pending: '未开始',
};
/** 点 → 状态档位 key。 */
function psSlot(p) {
if (p.status === 'converged') return p.success_method === 'seed_step' ? 'seed' : 'cold';
if (p.status === 'failed') return 'failed';
if (p.status === 'running') return 'running';
if (p.status === 'queued') return 'queued';
return 'pending';
}
function heatCellClass(p) {
if (p.status === 'converged') {
if (p.success_method === 'seed_step') return 'hc-seed';
if (p.success_method === 'imported') return 'hc-imported';
return 'hc-cold';
}
if (p.status === 'failed') return 'hc-failed';
if (p.status === 'running' || p.status === 'queued') return 'hc-running';
return 'hc-pending';
/** 维度取值的显示格式。 */
function psFmtDim(dim, v) {
const n = Number(v);
if (!Number.isFinite(n)) return String(v);
if (dim === 'teff') return `${Math.round(n)}K`;
if (dim === 'logg') return n.toFixed(1);
return n.toFixed(0); // loghe/logc/logn/logo 都是整数采样
}
function renderAnalysisTab() {
const el = document.getElementById('wf-tab-analysis');
if (!el) return;
if (!heatBuilt) {
heatBuilt = true;
if (!psBuilt) {
psBuilt = true;
el.innerHTML = `
<div class="panel card">
<div class="panel-header"><h2>冷启动成功图谱</h2></div>
<div class="panel-header"><h2>收敛性联合分析(平行集合图)</h2></div>
<div class="panel-body">
<div class="heat-controls">
<label>X 轴 <select data-heat-axis="x">${HEAT_DIMS.map(d => `<option value="${d}"${d === heatState.x ? ' selected' : ''}>${d}</option>`).join('')}</select></label>
<label>Y 轴 <select data-heat-axis="y">${HEAT_DIMS.map(d => `<option value="${d}"${d === heatState.y ? ' selected' : ''}>${d}</option>`).join('')}</select></label>
<span id="heat-slice-selects" class="heat-slice-selects"></span>
<div class="ps-controls">
<button type="button" class="btn btn-secondary btn-sm" data-ps-refresh title="重新拉取全量网格点(数据缓存,仅手动刷新)">刷新数据</button>
<span id="ps-summary" class="ps-summary tabular-num"></span>
</div>
<div id="heatmap-container" class="heatmap-container"><p class="text-hint">加载中…</p></div>
<div class="heat-legend">
<span class="heat-legend-item"><i class="hc hc-cold"></i>冷启动收敛</span>
<span class="heat-legend-item"><i class="hc hc-seed"></i>种子步进收敛</span>
<span class="heat-legend-item"><i class="hc hc-imported"></i>历史导入</span>
<span class="heat-legend-item"><i class="hc hc-failed"></i>失败</span>
<span class="heat-legend-item"><i class="hc hc-running"></i>运行/排队</span>
<span class="heat-legend-item"><i class="hc hc-pending"></i>未开始</span>
</div>
<p id="heat-conclusion" class="heat-conclusion"></p>
<p class="text-hint ps-hint">每条色带宽度 = 网格点数量,按收敛状态着色。轴上每个取值的色块高度反映该值下各状态的点数占比;悬停高亮某一状态的全链路流向,点击色块查看该组网格点。</p>
<div id="ps-container" class="ps-container"><p class="text-hint">加载中…</p></div>
<p id="ps-conclusion" class="ps-conclusion"></p>
</div>
</div>
`;
}
refreshHeatData();
// 缓存策略:仅当本地无数据时拉取;切 tab / 轮询都不自动拉。唯一重拉入口是「刷新数据」。
if (psState.points.length === 0) refreshPsData();
else renderParSets();
}
async function refreshHeatData() {
/** 拉全量网格点(后端 limit 缺省即全量)。force=true 用于刷新按钮。 */
async function refreshPsData(force = false) {
if (!currentName) return;
if (!force && psState.points.length > 0) return;
const container = document.getElementById('ps-container');
const btn = document.querySelector('[data-ps-refresh]');
if (container) container.innerHTML = '<p class="text-hint">加载全量网格点…</p>';
if (btn) { btn.disabled = true; btn.textContent = '加载中…'; }
try {
const res = await fetchWorkflowPointsApi(currentName, { limit: 500 }, { signal: abortCtl?.signal });
if (!res.ok) return;
const res = await fetchWorkflowPointsApi(currentName, {}, { signal: abortCtl?.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
heatState.points = json.data?.points || [];
initHeatSlice();
renderHeatmap();
psState.points = json.data?.points || [];
renderParSets();
} catch (err) {
if (err.name === 'AbortError') return;
console.warn('获取收敛性分析数据失败:', err);
if (container) container.innerHTML = `<p class="text-hint">加载失败:${escapeHtml(err.message)}(点「刷新数据」重试)</p>`;
} finally {
if (btn) { btn.disabled = false; btn.textContent = '刷新数据'; }
}
}
/** 非轴维度存在多值时生成切片选择器(固定其余维度取一个值,保证 2D 投影无叠格)。 */
function initHeatSlice() {
const container = document.getElementById('heat-slice-selects');
if (!container) return;
const dims = HEAT_DIMS.filter(d => d !== heatState.x && d !== heatState.y);
Object.keys(heatState.slice).forEach(k => {
if (!dims.includes(k)) delete heatState.slice[k];
});
const selects = [];
dims.forEach(d => {
const vals = dimValues(heatState.points, d);
if (vals.length <= 1) {
delete heatState.slice[d];
return;
}
if (heatState.slice[d] == null || !vals.includes(heatState.slice[d])) {
heatState.slice[d] = vals[0];
}
selects.push(`<label>${d} = <select data-heat-slice="${d}">${vals.map(v => `<option value="${v}"${v === heatState.slice[d] ? ' selected' : ''}>${v}</option>`).join('')}</select></label>`);
});
container.innerHTML = selects.join('');
/** 维度的有序离散取值列表。 */
function psDimValues(points, dim) {
const set = new Set(points.map(p => Number(p[dim])));
return [...set].filter(Number.isFinite).sort((a, b) => a - b);
}
function renderHeatmap() {
const container = document.getElementById('heatmap-container');
/**
* 核心渲染:Parallel Sets。
* 布局:7 条垂直类别轴(6 物理 + 状态),等间距分布。每轴内按取值纵向堆叠,
* 每个取值的色块再按状态分段(高度=点数占比)。相邻轴同状态间用贝塞尔色带连接。
* 关键数据结构:flows[i] = { from:{dim,val,seg}, to:{dim,val,seg}, count, slot }
* ——「seg」是该取值×状态在轴上的堆叠段,色带从 from.seg 的状态段连到 to.seg 的同状态段。
*/
function renderParSets() {
const container = document.getElementById('ps-container');
if (!container) return;
const { x, y, slice, points } = heatState;
const filtered = points.filter(p => Object.entries(slice).every(([d, v]) => p[d] === v));
renderConclusion(filtered);
if (filtered.length === 0) {
container.innerHTML = '<p class="text-hint">当前切片无数据(调整切片过滤或先启动工作流)</p>';
const points = psState.points;
if (points.length === 0) {
container.innerHTML = '<p class="text-hint">暂无网格点数据(启动工作流后生成)</p>';
return;
}
const xs = dimValues(filtered, x);
const ys = dimValues(filtered, y).reverse(); // 大值在上
const cellMap = new Map();
filtered.forEach(p => cellMap.set(`${p[x]}|${p[y]}`, p));
const header = '<div class="heatmap-corner"></div>' +
xs.map(xv => `<div class="heatmap-collabel tabular-num">${xv}</div>`).join('');
const rowsHtml = ys.map(yv => {
const cells = xs.map(xv => {
const p = cellMap.get(`${xv}|${yv}`);
if (!p) return '<div class="heatmap-cell hc-empty"></div>';
const title = `${p.name}\nrelc ${fmtRelc(p.last_max_relc)} · 尝试 ${p.attempt_count}`;
return `<div class="heatmap-cell ${heatCellClass(p)}" data-point-name="${escapeHtml(p.name)}" title="${escapeHtml(title)}"></div>`;
const dims = [...PS_DIMS, 'status'];
// 每维取值列表(status 用 PS_STATUS 顺序)
const dimValues = {};
PS_DIMS.forEach(d => { dimValues[d] = psDimValues(points, d); });
dimValues.status = PS_STATUS;
// 统计:每维每值的总数 + 每维每值每状态计数
const dimValueTotal = {}; // dim -> val -> count
const dimValSlotCount = {}; // dim -> val -> slot -> count
dims.forEach(d => { dimValueTotal[d] = {}; dimValSlotCount[d] = {}; });
points.forEach(p => {
const slot = psSlot(p);
dims.forEach(d => {
const v = d === 'status' ? slot : Number(p[d]);
if (d !== 'status' && !Number.isFinite(v)) return;
dimValueTotal[d][v] = (dimValueTotal[d][v] || 0) + 1;
if (!dimValSlotCount[d][v]) dimValSlotCount[d][v] = {};
dimValSlotCount[d][v][slot] = (dimValSlotCount[d][v][slot] || 0) + 1;
});
});
// 布局尺寸(像素 viewBox,与容器等比避免拉伸)
const cw = Math.max(360, container.clientWidth || 700);
const W = cw;
const H = Math.round(W * 9 / 16);
const PAD_T = 16, PAD_B = 36;
const top = PAD_T, bot = H - PAD_B;
const nDims = dims.length;
const colW = W / nDims;
const axisW = Math.min(28, colW * 0.16); // 轴色块宽度
const gap = 3; // 色块间距
psState.layout = { W, H, top, bot, dims, dimValues, dimValueTotal, dimValSlotCount, colW, axisW, gap };
psState.dimX = (i) => i * colW + colW / 2; // 轴中心 x
// 计算每个轴上每个取值的 y 区间(按总值占比纵向铺满 [top,bot])
// segY: dim -> val -> slot -> {y0, y1}(该状态段在轴上的像素区间)
const segY = {};
dims.forEach((d, di) => {
segY[d] = {};
const vals = dimValues[d];
const totalAll = vals.reduce((s, v) => s + (dimValueTotal[d][v] || 0), 0) || 1;
let yCursor = top;
vals.forEach(v => {
const cnt = dimValueTotal[d][v] || 0;
const h = (cnt / totalAll) * (bot - top);
segY[d][v] = {};
let sy = yCursor;
// 按固定状态顺序在该值内分段
PS_STATUS.forEach(sl => {
const sc = (dimValSlotCount[d][v] && dimValSlotCount[d][v][sl]) || 0;
if (sc === 0) return;
const sh = (sc / cnt) * h;
segY[d][v][sl] = { y0: sy, y1: sy + sh };
sy += sh;
});
yCursor += h + gap;
});
});
psState.segY = segY;
// 渲染轴色块(每值一个 g,内含按状态分段的 rect)+ 取值标签
const axisHtml = dims.map((d, di) => {
const x = psState.dimX(di);
const rx = x - axisW / 2;
const vals = dimValues[d];
const blocks = vals.map(v => {
const segs = PS_STATUS.map(sl => {
const seg = segY[d][v][sl];
if (!seg) return '';
const h = Math.max(0.5, seg.y1 - seg.y0);
const dimmed = psState.hover && psState.hover !== sl ? ' ps-dim' : '';
const hi = psState.hover === sl ? ' ps-hit' : '';
return `<rect class="ps-seg ps-${sl}${dimmed}${hi}" data-dim="${d}" data-val="${v}" data-slot="${sl}" x="${rx.toFixed(1)}" y="${seg.y0.toFixed(1)}" width="${axisW.toFixed(1)}" height="${h.toFixed(1)}"></rect>`;
}).join('');
// 取值标签(标在色块右侧,垂直居中于该值整块)。状态轴用中文+对应状态色,
// 状态轴本身即图例(刻度=状态名、颜色=该状态色),不再单列底部图例。
const segList = PS_STATUS.map(s => segY[d][v][s]).filter(Boolean);
const yMid = segList.length ? (segList[0].y0 + segList[segList.length - 1].y1) / 2 : top;
const isStatusAxis = d === 'status';
const labelTxt = isStatusAxis ? PS_STATUS_LABEL[v] : psFmtDim(d, v);
const colorCls = isStatusAxis ? ` ps-vlabel-${v}` : '';
return `${segs}<text class="ps-vlabel tabular-num${colorCls}" x="${(rx + axisW + 4).toFixed(1)}" y="${yMid.toFixed(1)}" dominant-baseline="central">${escapeHtml(labelTxt)}</text>`;
}).join('');
return `<div class="heatmap-rowlabel tabular-num">${yv}</div>${cells}`;
const labelTxt = d === 'status' ? '状态' : d;
return `<g class="ps-axis" data-dim="${d}">${blocks}<text class="ps-axis-label" x="${x.toFixed(1)}" y="${(H - 16).toFixed(1)}" text-anchor="middle">${labelTxt}</text></g>`;
}).join('');
// 渲染色带:相邻轴同状态之间连贝塞尔。flow[slot] 累加该 slot 在相邻段间的路径。
// 关键:色带从 (fromDim,fromVal,slot) 的段 → (toDim,toVal,slot) 的段,
// 宽度 = 同时满足两维取值的点数中该 slot 的数量。
const ribbonHtml = [];
for (let di = 0; di < dims.length - 1; di++) {
const fromD = dims[di], toD = dims[di + 1];
const x0 = psState.dimX(di) + axisW / 2;
const x1 = psState.dimX(di + 1) - axisW / 2;
const xc1 = x0 + (x1 - x0) * 0.5;
// 统计 fromVal×toVal×slot 计数
const triple = new Map(); // key "fv|tv|slot" -> count
points.forEach(p => {
const slot = psSlot(p);
const fv = fromD === 'status' ? slot : Number(p[fromD]);
const tv = toD === 'status' ? slot : Number(p[toD]);
if (fromD !== 'status' && !Number.isFinite(fv)) return;
if (toD !== 'status' && !Number.isFinite(tv)) return;
const k = `${fv}|${tv}|${slot}`;
triple.set(k, (triple.get(k) || 0) + 1);
});
// 为每个 (fromVal,toVal,slot) 画色带。为避免色带互相覆盖顺序混乱,
// 按 fromVal 的轴顺序遍历,同 fromVal 内按 slot 顺序。
dimValues[fromD].forEach(fv => {
PS_STATUS.forEach(slot => {
const fromSeg = segY[fromD][fv][slot];
if (!fromSeg) return;
dimValues[toD].forEach(tv => {
const toSeg = segY[toD][tv][slot];
if (!toSeg) return;
const cnt = triple.get(`${fv}|${tv}|${slot}`) || 0;
if (cnt === 0) return;
// 色带两端高度按各自轴上该 slot 段的像素高 × 此流量占比,形成渐变带(流量大则宽)。
// fromCount/toCount 是该 slot 在 fromVal/toVal 的总点数;cnt 是两者交集。
const fromCount = dimValSlotCount[fromD][fv][slot] || cnt;
const toCount = dimValSlotCount[toD][tv][slot] || cnt;
const h0 = Math.max(0.6, (fromSeg.y1 - fromSeg.y0) * (cnt / fromCount));
const h1 = Math.max(0.6, (toSeg.y1 - toSeg.y0) * (cnt / toCount));
const fy = (fromSeg.y0 + fromSeg.y1) / 2;
const ty = (toSeg.y0 + toSeg.y1) / 2;
const dimmed = psState.hover && psState.hover !== slot ? ' ps-dim' : '';
const hi = psState.hover === slot ? ' ps-hit' : '';
// 梯形色带:上沿从 (x0,fy-h0/2) 经贝塞尔到 (x1,ty-h1/2),下沿对称返回
const y0t = (fy - h0 / 2), y0b = (fy + h0 / 2);
const y1t = (ty - h1 / 2), y1b = (ty + h1 / 2);
const dpath = `M${x0.toFixed(1)},${y0t.toFixed(1)} C${xc1.toFixed(1)},${y0t.toFixed(1)} ${xc1.toFixed(1)},${y1t.toFixed(1)} ${x1.toFixed(1)},${y1t.toFixed(1)} L${x1.toFixed(1)},${y1b.toFixed(1)} C${xc1.toFixed(1)},${y1b.toFixed(1)} ${xc1.toFixed(1)},${y0b.toFixed(1)} ${x0.toFixed(1)},${y0b.toFixed(1)} Z`;
ribbonHtml.push(`<path class="ps-ribbon ps-${slot}${dimmed}${hi}" data-dim="${fromD}" data-from="${fv}" data-to-dim="${toD}" data-to="${tv}" data-slot="${slot}" data-cnt="${cnt}" d="${dpath}"></path>`);
});
});
});
}
container.innerHTML = `
<div class="heatmap" style="grid-template-columns: max-content repeat(${xs.length}, minmax(26px, 1fr));">
${header}${rowsHtml}
</div>
<p class="text-hint heat-axis-note">X: ${x} · Y: ${y}${points.length >= 500 ? ' · 网格较大,仅取前 500 点' : ''}</p>
<svg class="ps" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img"
aria-label="6 维参数与收敛状态的平行集合分布图">
<g class="ps-ribbons">${ribbonHtml.join('')}</g>
${axisHtml}
</svg>
`;
bindPsHover();
renderPsConclusion(points);
const sum = document.getElementById('ps-summary');
if (sum) sum.textContent = `${points.length}`;
}
function renderConclusion(pts) {
const el = document.getElementById('heat-conclusion');
/** 自动洞察:扫各维度取值,挑失败率显著高于均值的位置。 */
function renderPsConclusion(pts) {
const el = document.getElementById('ps-conclusion');
if (!el) return;
const total = pts.length;
if (total === 0) {
el.textContent = '';
return;
}
if (total === 0) { el.textContent = ''; return; }
const cold = pts.filter(p => p.status === 'converged' && p.success_method === 'cold_run').length;
const seed = pts.filter(p => p.status === 'converged' && p.success_method === 'seed_step').length;
const imp = pts.filter(p => p.status === 'converged' && p.success_method === 'imported').length;
const failed = pts.filter(p => p.status === 'failed').length;
const rate = Math.round((cold / total) * 100);
el.textContent = `冷启动成功 ${cold}/${total} (${rate}%) · 种子步进救回 ${seed} · 历史导入 ${imp} · 失败 ${failed}`;
const baseFail = failed / total;
const findings = [];
PS_DIMS.forEach(d => {
const groups = new Map();
pts.forEach(p => {
const v = Number(p[d]);
if (!Number.isFinite(v)) return;
if (!groups.has(v)) groups.set(v, []);
groups.get(v).push(p);
});
[...groups.entries()].forEach(([v, g]) => {
if (g.length < 3) return;
const gf = g.filter(p => p.status === 'failed').length / g.length;
if (gf >= 0.3 && gf - baseFail >= 0.15) {
findings.push({ score: gf - baseFail, text: `${d}=${psFmtDim(d, v)}:失败率 ${Math.round(gf * 100)}%${g.length} 点,均值 ${Math.round(baseFail * 100)}%` });
}
});
});
findings.sort((a, b) => b.score - a.score);
const lines = [
`冷启动 ${cold} · 种子步进 ${seed} · 失败 ${failed} / 共 ${total}`,
...findings.slice(0, 3).map(f => f.text),
];
el.innerHTML = lines.map(l => `<span class="ps-concl-line">${escapeHtml(l)}</span>`).join('');
}
/** 悬停交互:移到色带/色块 → 高亮该状态的全链路(色带 + 同状态色块),其余压暗。 */
function bindPsHover() {
const svg = document.querySelector('.ps');
if (!svg) return;
const apply = (slot) => {
psState.hover = slot;
svg.querySelectorAll('.ps-ribbon, .ps-seg').forEach(el => {
const s = el.getAttribute('data-slot');
el.classList.toggle('ps-dim', slot != null && s !== slot);
el.classList.toggle('ps-hit', slot != null && s === slot);
});
};
const clear = () => { psState.hover = null; apply(null); hidePsTooltip(); };
svg.addEventListener('mousemove', (e) => {
const target = e.target.closest('[data-slot]');
if (!target) { if (psState.hover) clear(); return; }
const slot = target.getAttribute('data-slot');
if (psState.hover !== slot) apply(slot);
const cnt = target.getAttribute('data-cnt');
const dim = target.getAttribute('data-dim');
const isRibbon = target.classList.contains('ps-ribbon');
const label = isRibbon
? `${PS_STATUS_LABEL[slot]}${dim}=${psFmtDim(dim, target.getAttribute('data-from'))}${target.getAttribute('data-to-dim')}=${target.getAttribute('data-to')}${cnt}`
: `${dim}=${psFmtDim(dim, target.getAttribute('data-val'))}${PS_STATUS_LABEL[slot]}${(psState.dimValSlotCount[dim]?.[target.getAttribute('data-val')]?.[slot]) || 0}`;
showPsTooltip(e.clientX, e.clientY, label);
});
svg.addEventListener('mouseleave', clear);
// 点击色块 → 弹出该维度值下的网格点名列表(点点名开详情面板)
svg.addEventListener('click', (e) => {
const seg = e.target.closest('.ps-seg');
if (!seg) return;
const dim = seg.getAttribute('data-dim');
const val = seg.getAttribute('data-val');
const slot = seg.getAttribute('data-slot');
const names = psState.points
.filter(p => psSlot(p) === slot && (dim === 'status' || Number(p[dim]) === Number(val)))
.map(p => p.name).slice(0, 200);
if (names.length === 0) return;
if (names.length === 1) { openPointPanel(names[0]); return; }
showPsPointList(e.clientX, e.clientY, dim, val, slot, names);
});
}
// tooltip 单例
let psTooltipEl = null;
function showPsTooltip(clientX, clientY, html) {
if (!psTooltipEl) {
psTooltipEl = document.createElement('div');
psTooltipEl.className = 'ps-tooltip';
document.body.appendChild(psTooltipEl);
}
psTooltipEl.textContent = html;
const tw = psTooltipEl.offsetWidth || 160, th = psTooltipEl.offsetHeight || 32;
let x = clientX + 14, y = clientY + 14;
if (x + tw > window.innerWidth) x = clientX - tw - 14;
if (y + th > window.innerHeight) y = clientY - th - 14;
psTooltipEl.style.left = x + 'px';
psTooltipEl.style.top = y + 'px';
psTooltipEl.style.display = 'block';
}
function hidePsTooltip() {
if (psTooltipEl) psTooltipEl.style.display = 'none';
}
/** 点击色块时,若该组有多个点,弹出点名列表浮层(点点名 → openPointPanel)。 */
let psPointListEl = null;
function showPsPointList(clientX, clientY, dim, val, slot, names) {
closePsPointList();
psPointListEl = document.createElement('div');
psPointListEl.className = 'ps-pointlist card';
psPointListEl.innerHTML = `
<div class="ps-pointlist-head">
<span>${dim}=${escapeHtml(psFmtDim(dim, val))} · ${PS_STATUS_LABEL[slot]} · ${names.length} 点</span>
<button type="button" class="modal-close ps-pointlist-close" aria-label="关闭">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="ps-pointlist-body">${names.map(n => `<a class="ps-pointlink" data-name="${escapeHtml(n)}" title="查看详情">${escapeHtml(n)}</a>`).join('')}</div>
`;
document.body.appendChild(psPointListEl);
// 定位(锚定点击处,限制在视口内)
const r = psPointListEl.getBoundingClientRect();
let x = clientX, y = clientY;
if (x + r.width > window.innerWidth - 8) x = window.innerWidth - r.width - 8;
if (y + r.height > window.innerHeight - 8) y = window.innerHeight - r.height - 8;
x = Math.max(8, x); y = Math.max(8, y);
psPointListEl.style.left = x + 'px';
psPointListEl.style.top = y + 'px';
psPointListEl.addEventListener('click', (e) => {
if (e.target.closest('.ps-pointlist-close')) { closePsPointList(); return; }
const link = e.target.closest('[data-name]');
if (link) { openPointPanel(link.getAttribute('data-name')); closePsPointList(); }
});
}
function closePsPointList() {
if (psPointListEl) { psPointListEl.remove(); psPointListEl = null; }
}
// ===== 点详情滑入面板(尝试历史 + 阶段链诊断) =====
@@ -999,8 +1324,10 @@ function switchTab(tab) {
['overview', 'points', 'analysis'].forEach(t => {
document.getElementById(`wf-tab-${t}`)?.classList.toggle('hidden', t !== tab);
});
if (tab === 'overview' && latestStats) renderOverview(latestStats);
else if (tab === 'points') renderPointsTab();
if (tab === 'overview' && latestStats) {
renderOverview(latestStats);
if (lastProgress) renderSparkline(lastProgress);
} else if (tab === 'points') renderPointsTab();
else if (tab === 'analysis') renderAnalysisTab();
}
@@ -1042,14 +1369,26 @@ function bindEvents(root) {
return;
}
// 点详情入口(表格"查看"按钮 / 热力图格子
// 点详情入口(表格"查看"按钮)
const pointEl = e.target.closest('[data-point-name]');
if (pointEl) {
openPointPanel(pointEl.getAttribute('data-point-name'));
return;
}
// Parallel Sets:手动刷新数据(强制重拉全量;唯一触发重新请求的入口)
if (e.target.closest('[data-ps-refresh]')) {
refreshPsData(true);
return;
}
// Parallel Sets 点名列表浮层关闭(点空白处)
if (e.target.closest('.ps-pointlist-close') || !e.target.closest('.ps-pointlist')) {
closePsPointList();
}
});
// 过滤器 / 热力轴 / 切片 变更
// 过滤器变更
root.addEventListener('change', (e) => {
const f = e.target.closest('[data-wf-filter]');
if (f) {
@@ -1059,18 +1398,6 @@ function bindEvents(root) {
refreshPoints();
return;
}
const ax = e.target.closest('[data-heat-axis]');
if (ax) {
heatState[ax.getAttribute('data-heat-axis')] = e.target.value;
initHeatSlice();
renderHeatmap();
return;
}
const sl = e.target.closest('[data-heat-slice]');
if (sl) {
heatState.slice[sl.getAttribute('data-heat-slice')] = Number(sl.value);
renderHeatmap();
}
});
// 点名搜索框回车立即生效