feat(all): 任务引擎双阶段解耦、僵尸涡旋修复、动态 CPU 配额与前端详情页重构

将 TLUSTY/SYNSPEC 拆为各自独立的 enabled/policy/strategies 阶段,
以策略链自动弹栈取代单级 seed_step 布尔回退;定向修复 2026-08-02
僵尸任务涡旋事故;新增节点并发配额热调;前端详情页从 1412 行巨型
视图拆为薄控制器 + detail 子模块,并补齐工具层与单测。

引擎与调度(task_engine_decoupling_design.md)
- models.rs: 新增 StagePolicy / EngineStageConfig / TaskSpec 阶段字段、
  normalize_compat() 校正旧版在途消息策略链、failed_stage 归因
- scheduler.rs: resolve_dispatchable_chain 派发门控、
  trigger_strategy_fallback 按 failed_stage 精确弹栈;启动期
  force_recompute/skip_converged(默认)/skip_failed 三策略
- db.rs: tasks 表 +7 列持久化阶段配置;终态守卫
  (mark_grid_point_running 仅 pending/queued→running;
  record_task_report 拒绝迟到失败翻黑 converged);策略弹栈快照

僵尸涡旋修复(runbook-20260802-zombie-vortex-fix.md)
- 全链路跨库活性交叉校验:派发/claim/孤儿回收/回退统一查 MQ 队列活性,
  活则放行、死则清僵尸,结构性消除"每点重复派发"
- stop/重启卫生:清队列同步 delete_tasks_by_ids,杜绝遗留 pending 行
- report_task: 幂等吸收 + 409 区分迟到冗余结果,仅 state_changed 时回退
- MQ: NULL workflow_name 回填 __legacy__、requeue 后迟到上报被 403 竞态修复

动态 CPU 配额(dynamic_cpu_slots_design.md)
- admin.rs: POST /admin/nodes/:id/quota(Option<Option<i32>> 区分
  缺字段/显式 null);nodes 表 +admin_max_slots
- worker.rs: effective_max_slots = min(admin, physical),心跳下发原子生效

科学产物保全(tlusty_result_artifacts.md)
- runner.rs: SYNSPEC 启动前快照 fort.12/fort.14 → .bfac/.emflux 防覆盖
- 半失败点(大气收敛+光谱失败)改判 Failed 并写入 note;仅 SYNSPEC
  场景不再恒判失败;撤销归档 LRU 200 上限改为永久保留
- executor.rs: 透传 synspec_params 数值参数(此前固定 None)

前端(dashboard/)
- workflowDetail.js 1412→328 行,拆出 views/detail/{ctx,overview,
  pointsTable,parSets,pointPanel}.js,AbortController 治理监听/请求生命周期
- 删除 wfActions.js,新增 wfEnginePanel.js(双阶段三维配置编辑面板)
- 新增 utils/{errors,format,icons,polling,yamlStage}.js 纯函数模块
- 路由级动态 import 代码分割;节点配额三点菜单 + Modal 管理
- 首次引入 node:test 单测(format/polling/yamlStage/psCache,644 行)
- 系统性补齐 a11y:skip-link、ARIA、Tab 键盘漫游、toast 关闭、退出动画

文档与工具
- 新增 6 篇设计/调研:引擎解耦、动态配额、涡旋 runbook、
  光谱正确性分析、收敛判断、产物归档
- PIPELINE/design/api/database 等协同重写为分布式 C/S 架构口径
- scripts/fetch_results.sh 跨节点产物备份;import_results 按 cno 升序导入
- workflows/sdB_cno.yaml: 新增 tlusty/synspec_stage 配置块,修正 wstart 笔误
This commit is contained in:
fmq
2026-08-04 23:40:52 +08:00
parent c8fd24b120
commit cd370d88e7
74 changed files with 13386 additions and 3033 deletions
+54
View File
@@ -0,0 +1,54 @@
/* 工作流详情页共享上下文(detail ctx)
*
* workflowDetail.js 拆分后,各 detail 子模块需要访问当前工作流名、AbortController、
* 最新统计、进度曲线、点表过滤、Parallel Sets 状态等共享可变状态。这些原本是
* workflowDetail.js 顶部的 15+ 个模块级 let(跨工作流切换须手动逐字段 reset,
* 是历史遗留的泄漏/残留 bug 源头),现收敛到本模块:
*
* 生命周期:mount 时 createDetailCtx(name) 新建 → 子模块经参数读写 ctx →
* unmount 后整体丢弃(含 abort 在途请求)。
*
* 子模块以 ctx 为第一参数接收,直接读写字段(vanilla JS 下的务实选择——
* 避免 getter/setter 样板;这些字段本就属于同一挂载作用域)。
*/
/**
* 创建详情页上下文。
* @param {string} name 工作流名
*/
export function createDetailCtx(name) {
return {
name,
// 本轮作用域轮询的 AbortControllerunmount / 换工作流时 abort 在途请求)
abortCtl: new AbortController(),
// ===== Tab1 执行概览 =====
activeTab: 'overview',
latestStats: null,
overviewBuilt: false, // 概览骨架仅首次构建,其后轮询原位 diff
lastWavesSig: null,
lastActivitySig: null,
knownActivityKeys: new Set(), // 已在 feed 中的条目指纹(点名|完成时间)
// 进度曲线(30s 节流拉取,DOM 跨轮询保留)
lastProgressAt: 0,
lastProgress: null,
sparkBuilt: false,
lastSparkSig: null,
sparkHoverCtx: null, // 悬停上下文:mousemove 读取、数据刷新时写入
sparkKbIdx: -1, // 键盘十字线索引(-1=无)
// ===== Tab2 网格点明细 =====
pointsBuilt: false,
pointsFilter: { status: '', method: '', wave: '', q: '', sort: 'wave', order: 'asc', limit: 100, offset: 0 },
// ===== Tab3 收敛性分析(Parallel Sets 平行集合图) =====
psBuilt: false,
psState: { points: [], hover: null, layout: null, dimX: null, segY: null, ts: null }, // ts: 数据快照时间(epoch ms)
// ===== 浮层单例(unmount 时清理) =====
psTooltipEl: null,
psPointListEl: null,
pointPanelEl: null,
};
}
+453
View File
@@ -0,0 +1,453 @@
/* 工作流详情页 · Tab1 执行概览(任务控制条 + 指标卡 + 进度曲线 + 最近动态)
*
* 性能守则(历史回归总结):
* - 概览骨架仅首次构建(ctx.overviewBuilt),其后轮询一律原位 diff 更新;
* - 进度曲线 SVG 骨架只建一次、事件监听只绑一次,数据刷新仅原位改 points 属性;
* - 最近动态 stale-while-revalidate:请求期间/失败时保留旧内容,签名 diff 才碰 DOM。
* 整块 innerHTML 每 tick 重建是页面抖动、feed 高度脉动与悬停状态丢失的根源,彻底避免。
*/
import {
escapeHtml, parseTsMs, fmtTime, fmtDuration, statusBadge, pointMethodBadge,
} from '../../utils/format.js';
import { fetchWorkflowPointsApi, fetchWorkflowProgressApi } from '../../api.js';
import { isAbortError, logError } from '../../utils/errors.js';
import { ICONS } from '../../utils/icons.js';
/** 分段进度条档位:顺序与计数行一致。五段常驻(空档宽度 0),原位改 width 复用 CSS 过渡。 */
const SEG_DEFS = [
['converged', 'seg-converged', '收敛'],
['failed', 'seg-failed', '失败'],
['running', 'seg-running', '运行'],
['queued', 'seg-queued', '排队'],
['pending', 'seg-pending', '待定'],
];
/** 任务控制条:状态徽章 / 分段进度条 / 计数与 ETA。
*
* 操作按钮(启动/暂停/删除/查看)已移除——全部收敛至内嵌引擎面板
* wfEnginePanel.js,见 docs/task_engine_decoupling_design.md §6)。
* 剩余元素签名 diff 后原位更新,轮询不重建 DOM。 */
export function renderStrip(s) {
const statusEl = document.getElementById('wf-detail-status');
if (statusEl && statusEl.getAttribute('data-status') !== s.status) {
statusEl.setAttribute('data-status', s.status);
statusEl.innerHTML = statusBadge(s.status);
}
const segEl = document.getElementById('wf-seg-progress');
if (segEl) {
if (!segEl.firstElementChild) {
segEl.innerHTML = SEG_DEFS.map(([key, cls, label]) =>
`<div class="seg ${cls}" data-seg="${key}" style="width:0%" title="${label} 0"></div>`).join('');
}
const total = s.total || 0;
SEG_DEFS.forEach(([key, , label]) => {
const el = segEl.querySelector(`[data-seg="${key}"]`);
if (!el) return;
const n = s[key] || 0;
// total=0 时 pending 占满整条,沿用旧版「暂无网格点」语义
const w = total > 0 ? (n / total) * 100 : (key === 'pending' ? 100 : 0);
const wStr = `${w}%`;
if (el.style.width !== wStr) el.style.width = wStr;
const title = total === 0 && key === 'pending' ? '暂无网格点' : `${label} ${n}`;
if (el.title !== title) el.title = title;
});
}
const countsEl = document.getElementById('wf-strip-counts');
if (countsEl) {
const pct = s.total ? Math.round((s.converged / s.total) * 100) : 0;
const parts = [
`${s.total} 网格点`,
`${s.converged} 收敛${s.total ? ` (${pct}%)` : ''}`,
`${s.running} 运行`,
`${s.queued} 排队`,
`${s.pending} 待定`,
`${s.failed} 失败`,
];
if (s.eta_sec != null) parts.push(`ETA ≈ ${fmtDuration(s.eta_sec)}`);
const text = parts.join(' · ');
if (countsEl.textContent !== text) countsEl.textContent = text;
}
}
/** Tab1 入口:轮询每 tick 调用。骨架仅首次构建,其后 updateOverview 原位 diff。 */
export function renderOverview(ctx, s) {
const panel = document.getElementById('wf-tab-overview');
if (!panel) return;
if (!ctx.overviewBuilt) {
ctx.overviewBuilt = true;
panel.innerHTML = overviewSkeleton();
}
updateOverview(ctx, s);
refreshActivityFeed(ctx);
}
/** 概览骨架:动态部分全部挂 ID,供 updateOverview 原位更新。 */
function overviewSkeleton() {
return `
<section class="metrics-grid">
<div class="metric-card card">
<span class="metric-title">网格总数</span>
<span class="metric-value tabular-num" id="ov-m-total">—</span>
<span class="metric-sub">6 维参数笛卡尔积展开</span>
</div>
<div class="metric-card card">
<span class="metric-title">已收敛</span>
<span class="metric-value tabular-num" id="ov-m-converged">—</span>
<span class="metric-sub" id="ov-m-converged-sub">—</span>
</div>
<div class="metric-card card">
<span class="metric-title">失败</span>
<span class="metric-value tabular-num" id="ov-m-failed">—</span>
<span class="metric-sub">冷启动发散且无种子可救</span>
</div>
<div class="metric-card card">
<span class="metric-title">运行 + 排队</span>
<span class="metric-value tabular-num" id="ov-m-runqueue">—</span>
<span class="metric-sub" id="ov-m-runqueue-sub">—</span>
</div>
</section>
<section class="panel card">
<div class="panel-header"><h2>进度曲线(最近 24 小时)</h2></div>
<div class="panel-body">
<div id="wf-sparkline" class="sparkline-wrap"></div>
<p id="wf-rate-line" class="rate-line text-hint"></p>
<div id="wf-stall-banner" class="stall-banner hidden" role="alert">
${ICONS.warn({ size: 14 })}
<span class="stall-banner-text"></span>
</div>
</div>
</section>
<section class="wf-overview-grid">
<div class="panel card">
<div class="panel-header"><h2>难度波次推进</h2></div>
<div class="panel-body" id="ov-waves"></div>
</div>
<div class="panel card">
<div class="panel-header"><h2>最近动态</h2></div>
<div class="panel-body">
<div id="wf-activity-feed" class="activity-feed"><p class="text-hint">加载中…</p></div>
</div>
</div>
</section>
`;
}
/** 原位文本更新:内容相同则跳过,避免无谓 DOM 写。 */
function setOvText(id, v) {
const el = document.getElementById(id);
const str = String(v);
if (el && el.textContent !== str) el.textContent = str;
}
/** 轮询原位刷新概览数值(指标卡 + 归因徽章 + 波次)。 */
function updateOverview(ctx, s) {
const pct = s.total ? Math.round((s.converged / s.total) * 100) : 0;
setOvText('ov-m-total', s.total);
setOvText('ov-m-converged', s.converged);
setOvText('ov-m-converged-sub', `完成率 ${pct}%`);
setOvText('ov-m-failed', s.failed);
setOvText('ov-m-runqueue', s.running + s.queued);
setOvText('ov-m-runqueue-sub', `${s.running} 运行 · ${s.queued} 排队`);
// 收敛手段归因面板已移除(见 docs/task_engine_decoupling_design.md §6.1),
// cold/seed 计数仍在点表明细与 parSets 中可用,概览不再单独展示。
updateWaves(ctx, s);
}
/** 波次行:签名 diff,数据不变不重建。行高只随波次数量增长,不随进度值脉动。 */
function updateWaves(ctx, s) {
const el = document.getElementById('ov-waves');
if (!el) return;
const waves = s.waves || [];
const sig = waves.map(w => `${w.wave}:${w.converged}/${w.total}/${w.failed || 0}`).join('|');
if (sig === ctx.lastWavesSig) return;
ctx.lastWavesSig = sig;
el.innerHTML = waves.length > 0
? waves.map(w => {
const wp = w.total ? Math.round((w.converged / w.total) * 100) : 0;
return `
<div class="wave-row">
<span class="wave-label tabular-num">wave ${w.wave}</span>
<div class="progress-bar-track" aria-hidden="true">
<div class="progress-bar-fill progress-fill-emerald" style="width:${wp}%"></div>
</div>
<span class="wave-count tabular-num">${w.converged}/${w.total}${w.failed ? ` · ${w.failed} 失败` : ''}</span>
</div>`;
}).join('')
: '<p class="text-hint">尚无波次数据(启动工作流后按难度分批生成)</p>';
}
/** 最近动态:stale-while-revalidate——请求期间/失败时保留旧内容,绝不先清空再填充。
* 签名 diff:数据不变则完全不碰 DOM(保留滚动位置、不重放动画);
* 仅新出现的条目带 activity-new 入场动画。 */
async function refreshActivityFeed(ctx) {
const feed = document.getElementById('wf-activity-feed');
if (!feed || !ctx.name) return;
try {
const res = await fetchWorkflowPointsApi(
ctx.name,
{ sort: 'last_completed_at', order: 'desc', limit: 12 },
{ signal: ctx.abortCtl?.signal }
);
if (!res.ok) return;
const json = await res.json();
const pts = (json.data?.points || []).filter(p => p.last_completed_at);
const keyOf = p => `${p.name}|${p.last_completed_at}`;
const sig = pts.map(keyOf).join(';');
if (sig === ctx.lastActivitySig) return;
const prevKeys = ctx.knownActivityKeys;
ctx.lastActivitySig = sig;
ctx.knownActivityKeys = new Set(pts.map(keyOf));
if (pts.length === 0) {
feed.innerHTML = '<p class="text-hint">暂无已完成的计算记录</p>';
return;
}
feed.innerHTML = pts.map(p => {
const isNew = !prevKeys.has(keyOf(p));
const ok = p.status === 'converged';
// 归因徽章用 success_method(收敛点权威归因,能正确显示 synspec-only 的 "standard"),
// 不用 last_task_type——后者是 task_type 兼容兜底字段,synspec-only 任务被调度器固定
// 填 cold_run,会把光谱重算误标成「冷启动」。失败点 success_method 为空 → 显示 —。
const method = pointMethodBadge(p.success_method);
return `
<div class="activity-item${isNew ? ' activity-new' : ''}">
<span class="activity-point" title="${escapeHtml(p.name)}">${escapeHtml(p.name)}</span>
${method}
<span class="activity-result ${ok ? 'ok' : 'fail'}">${ok ? '收敛' : '失败'}</span>
<span class="activity-elapsed tabular-num" title="该次计算墙钟耗时">${fmtDuration(p.last_elapsed_sec)}</span>
<span class="activity-time tabular-num">${fmtTime(p.last_completed_at)}</span>
</div>`;
}).join('');
} catch (err) {
if (isAbortError(err)) return;
logError(err, '获取最近动态失败');
}
}
// ===== 进度曲线(概览内嵌,30s 节流拉取快照序列) =====
/** 节流:快照 ~30s 一条,前端 30s 拉一次即可。 */
export function maybeRefreshProgress(ctx) {
const now = Date.now();
if (now - ctx.lastProgressAt < 30000) return;
ctx.lastProgressAt = now;
refreshProgress(ctx);
}
export async function refreshProgress(ctx) {
if (!ctx.name || !document.getElementById('wf-sparkline')) return;
try {
const res = await fetchWorkflowProgressApi(ctx.name, { hours: 24 }, { signal: ctx.abortCtl?.signal });
if (!res.ok) return;
const json = await res.json();
if (json.success && json.data) {
ctx.lastProgress = json.data;
renderSparkline(ctx, json.data);
}
} catch (err) {
if (isAbortError(err)) return;
logError(err, '获取进度曲线失败');
}
}
/** 进度曲线:SVG 骨架只建一次(ctx.sparkBuilt),事件监听器只绑一次;
* 数据刷新仅原位更新 polyline/polygon 的 points 属性 + 悬停上下文,
* 杜绝每 30s innerHTML 重建导致的悬停状态丢失与事件监听器泄漏。
* 键盘交互:SVG 可聚焦,ArrowLeft/Right 移动十字线,Escape 清除。 */
export function renderSparkline(ctx, prog) {
const wrap = document.getElementById('wf-sparkline');
if (!wrap) return;
const series = prog.series || [];
renderRateLine(ctx, prog);
if (series.length < 2) {
if (!wrap.querySelector('.text-hint')) {
wrap.innerHTML = '<p class="text-hint">暂无足够快照(后台循环在进度变化时写入,约每 30 秒检测一次)</p>';
}
ctx.sparkBuilt = false;
ctx.lastSparkSig = null;
ctx.sparkHoverCtx = null;
ctx.sparkKbIdx = -1;
return;
}
const W = 100;
const H = 32;
const PAD = 2;
const n = series.length;
const pctOf = (p, key) => (p.total > 0 ? (p[key] / p.total) * 100 : 0);
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 = `${x(0).toFixed(2)},${H} ${line('converged')} ${x(n - 1).toFixed(2)},${H}`;
// 时间轴标签:窗口起点 / 中点 / 终点
const fmtAxisTime = (ms) => {
const d = new Date(ms);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
const axisLabels = [fmtAxisTime(tStart), fmtAxisTime(tStart + span / 2), fmtAxisTime(tEnd)];
// 签名 diff
const sig = `${n}|${tsMs[0]}|${tsMs[n - 1]}|${series[n - 1].converged}|${series[n - 1].failed}`;
if (sig === ctx.lastSparkSig && ctx.sparkBuilt) {
ctx.sparkHoverCtx = { tsMs, tStart, span, series, n, pctOf, x };
return;
}
ctx.lastSparkSig = sig;
ctx.sparkHoverCtx = { tsMs, tStart, span, series, n, pctOf, x };
if (!ctx.sparkBuilt) {
ctx.sparkBuilt = true;
wrap.innerHTML = `
<svg class="sparkline" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img"
tabindex="0" aria-label="最近 ${prog.hours} 小时收敛率与失败率曲线,可用方向键浏览数据点">
<polygon class="spark-area" />
<polyline class="spark-failed" />
<polyline class="spark-converged" />
<line class="spark-crosshair" x1="0" y1="0" x2="0" y2="${H}" visibility="hidden" />
</svg>
<div class="spark-time-axis" aria-hidden="true">
<span class="spark-axis-label" data-ax="0"></span>
<span class="spark-axis-label" data-ax="1"></span>
<span class="spark-axis-label" data-ax="2"></span>
</div>
<div class="spark-legend">
<span class="spark-legend-item"><svg width="18" height="8" aria-hidden="true"><line x1="0" y1="4" x2="18" y2="4" class="spark-converged"/></svg>收敛率</span>
<span class="spark-legend-item"><svg width="18" height="8" aria-hidden="true"><line x1="0" y1="4" x2="18" y2="4" class="spark-failed"/></svg>失败率</span>
</div>
<p id="spark-readout" class="spark-readout tabular-num" aria-live="polite"></p>
`;
const svg = wrap.querySelector('svg.sparkline');
const crosshair = wrap.querySelector('.spark-crosshair');
const readout = wrap.querySelector('#spark-readout');
// 共享:按索引显示十字线 + 读数
const showIdx = (idx) => {
const c = ctx.sparkHoverCtx;
if (!c || idx < 0 || idx >= c.n) return;
const p = c.series[idx];
crosshair.setAttribute('x1', c.x(idx));
crosshair.setAttribute('x2', c.x(idx));
crosshair.setAttribute('visibility', 'visible');
readout.textContent = `${p.ts} · 收敛 ${p.converged}/${p.total} (${c.pctOf(p, 'converged').toFixed(1)}%) · 失败 ${p.failed} · 运行 ${p.running} · 排队 ${p.queued}`;
};
const hideCrosshair = () => {
crosshair.setAttribute('visibility', 'hidden');
readout.textContent = '';
ctx.sparkKbIdx = -1;
};
svg.addEventListener('mousemove', (e) => {
const c = ctx.sparkHoverCtx;
if (!c) return;
const rect = svg.getBoundingClientRect();
const rel = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
const t = c.tStart + rel * c.span;
let lo = 0, hi = c.n - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (c.tsMs[mid] < t) lo = mid + 1;
else hi = mid;
}
if (lo > 0 && Math.abs(c.tsMs[lo - 1] - t) <= Math.abs(c.tsMs[lo] - t)) lo -= 1;
ctx.sparkKbIdx = lo;
showIdx(lo);
});
svg.addEventListener('mouseleave', () => {
if (ctx.sparkKbIdx < 0) hideCrosshair();
});
svg.addEventListener('blur', hideCrosshair);
// 键盘交互:ArrowLeft/Right 逐步移动,Home/End 跳首尾,Escape 清除
svg.addEventListener('keydown', (e) => {
const c = ctx.sparkHoverCtx;
if (!c) return;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
ctx.sparkKbIdx = Math.min(c.n - 1, (ctx.sparkKbIdx < 0 ? 0 : ctx.sparkKbIdx + 1));
showIdx(ctx.sparkKbIdx);
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
ctx.sparkKbIdx = Math.max(0, (ctx.sparkKbIdx < 0 ? c.n - 1 : ctx.sparkKbIdx - 1));
showIdx(ctx.sparkKbIdx);
} else if (e.key === 'Home') {
e.preventDefault();
ctx.sparkKbIdx = 0;
showIdx(0);
} else if (e.key === 'End') {
e.preventDefault();
ctx.sparkKbIdx = c.n - 1;
showIdx(c.n - 1);
} else if (e.key === 'Escape') {
hideCrosshair();
}
});
}
// 原位更新 points + 时间轴标签
const area = wrap.querySelector('.spark-area');
const lineFailed = wrap.querySelector('.spark-failed');
const lineConverged = wrap.querySelector('.spark-converged');
if (area) area.setAttribute('points', areaPts);
if (lineFailed) lineFailed.setAttribute('points', line('failed'));
if (lineConverged) lineConverged.setAttribute('points', line('converged'));
wrap.querySelectorAll('.spark-axis-label').forEach((el, i) => {
if (el.textContent !== axisLabels[i]) el.textContent = axisLabels[i];
});
}
/** 速率行与停滞横幅(读 ctx.latestStats 计算 ETA/剩余量)。 */
export function renderRateLine(ctx, prog) {
const el = document.getElementById('wf-rate-line');
if (el) {
const parts = [];
const rate = prog.rate_per_hour;
const span = prog.rate_span_hours;
if (rate != null && Number.isFinite(rate)) {
// 速率是「最近 2 小时」子窗口上的平均(后端 rate_per_hour),rate_span_hours 为其
// 实际数据跨度(≤2h,数据不足时回退整窗)。展示真实跨度,避免误读为固定 2h 或整窗。
let spanTxt = '';
if (span != null && Number.isFinite(span) && span > 0) {
spanTxt = span >= 1
? `(近 ${span.toFixed(1)} 小时平均)`
: `(近 ${Math.max(1, Math.round(span * 60))} 分钟平均)`;
}
parts.push(`经验速率 ≈ +${Number(rate).toFixed(1)} 点/小时${spanTxt}`);
// ETA 用终态处理速率(converged+failed 的近 2h 平均):与「剩余 = total converged
// − failed」同口径,即队列实际清空速率。仅用收敛速率会在失败较多时高估剩余时间。
const doneRate = prog.done_rate_per_hour;
const etaRate = (doneRate != null && Number.isFinite(doneRate) && doneRate > 0)
? doneRate
: rate;
const remaining = ctx.latestStats ? ctx.latestStats.total - ctx.latestStats.converged - ctx.latestStats.failed : 0;
if (etaRate > 0 && remaining > 0) {
parts.push(`按当前处理速率剩余 ${remaining} 点约需 ${fmtDuration((remaining / etaRate) * 3600)}`);
}
} else {
parts.push('快照积累中,稍后给出经验速率');
}
el.textContent = parts.join(' · ');
}
const banner = document.getElementById('wf-stall-banner');
if (!banner) return;
const remaining = ctx.latestStats ? ctx.latestStats.total - ctx.latestStats.converged - ctx.latestStats.failed : 0;
const stalled = prog.stalled_minutes;
if (stalled != null && stalled > 10 && remaining > 0 && ctx.latestStats?.status === 'running') {
banner.querySelector('.stall-banner-text').textContent =
`进度已停滞约 ${Math.round(stalled)} 分钟(仍有 ${remaining} 点未完成)——请检查计算节点在线状态与任务超时设置`;
banner.classList.remove('hidden');
} else {
banner.classList.add('hidden');
}
}
+530
View File
@@ -0,0 +1,530 @@
/* 工作流详情页 · Tab3 收敛性分析(Parallel Sets 平行集合图:6 维类别轴 + 状态轴,色带宽度=点数)
*
* 为什么用 Parallel Sets 而非 PCP:网格是离散采样点(每维有限取值)+ 分类状态,
* PCP 用单条折线表示个体,离散维度上大量线重叠无法区分;Parallel Sets 用色带宽度
* 表示聚合数量,专为此场景设计——一眼看出「某维度某值的状态构成」与「跨维度流向」。
* 手写 SVG(零依赖,不碰 CSP),几何 = 按状态分段的堆叠条 + 条间贝塞尔色带。
*
* 全部可变状态在 ctx.psStatepoints/hover/layout/dimX/segY);浮层单例
* ctx.psTooltipEl / ctx.psPointListEl 随 unmount 由控制器清理。
*/
import { fetchWorkflowPointsApi } from '../../api.js';
import { escapeHtml, fmtClock } from '../../utils/format.js';
import { isAbortError, logError } from '../../utils/errors.js';
import { ICONS } from '../../utils/icons.js';
import { openPointPanel } from './pointPanel.js';
const PS_DIMS = ['teff', 'logg', 'loghe', 'logc', 'logn', 'logo'];
// ===== 平行集合图数据缓存(sessionStorage 持久化) =====
//
// 网格点全量数据量大(可达上千条),平行集合图布局/统计是 CPU 密集运算,默认策略是
// 「首次拉取后缓存、切 Tab / 轮询不重拉」。为让浏览器刷新(F5)后不白白重拉全量,
// 把拉到的 points 序列化到 sessionStorage——同标签页刷新保留、关标签页即清空,
// 生命周期与内存缓存同样安全。
//
// 缓存按工作流名隔离(key 含工作流名),**多个工作流的缓存并存、切换不删除彼此**;
// sessionStorage 容量上限(~5MB)由 QuotaExceededError 兜底:写不进去的工作流
// 静默降级为刷新后重新拉取,不影响其它工作流的缓存。
// 恢复的缓存带快照时间戳(图上标注「快照 HH:MM:SS」便于识别数据新旧);
// 「刷新数据」按钮仍可强制重拉。
const PS_CACHE_KEY = 'dcts_ps_points';
/** 写缓存:按工作流隔离,多缓存并存互不删除。容量超限时静默降级(见文件头注释)。 */
export function savePsCache(ctx) {
try {
sessionStorage.setItem(
PS_CACHE_KEY + '_' + ctx.name,
JSON.stringify({ ts: Date.now(), points: ctx.psState.points })
);
} catch (_) {
/* 持久化失败不影响内存缓存,静默降级为刷新后重拉 */
}
}
/** 读缓存:成功填充 ctx.psState.points 并返回 true;无缓存/内容损坏返回 false。 */
export function restorePsCache(ctx) {
try {
const raw = sessionStorage.getItem(PS_CACHE_KEY + '_' + ctx.name);
if (!raw) return false;
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.points)) return false;
ctx.psState.points = parsed.points;
ctx.psState.ts = parsed.ts || Date.now();
return true;
} catch (_) {
return false;
}
}
/** 状态轴档位(收敛拆冷启动/种子步进,物理意义不同:稳定区 vs 救回区)。
* 顺序即图例/轴布局/每值内分段/色带绘制层叠的唯一来源,语义约定:
* 已收敛(最受关注)置顶 → 进行中按任务进展方向(running→queued→pending)→ 失败沉底。
* 色带绘制按此顺序:收敛色带先画(底层),失败最后画(最上层,数量少且颜色醒目)。 */
const PS_STATUS = ['cold', 'seed', 'running', 'queued', 'pending', 'failed'];
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 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 都是整数采样
}
export function renderAnalysisTab(ctx) {
const el = document.getElementById('wf-tab-analysis');
if (!el) return;
if (!ctx.psBuilt) {
ctx.psBuilt = true;
el.innerHTML = `
<div class="panel card">
<div class="panel-header"><h2>收敛性联合分析(平行集合图)</h2></div>
<div class="panel-body">
<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>
<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>
<p id="ps-sr-summary" class="sr-only" aria-live="polite"></p>
</div>
</div>
`;
}
// 缓存策略:内存缓存 + sessionStorage 持久化——切 Tab / 轮询不自动拉;
// 本地无数据时先尝试恢复持久化缓存(刷新页面后免重拉),否则拉取全量。
// 唯一强制重拉入口是「刷新数据」按钮。
if (ctx.psState.points.length === 0) {
if (restorePsCache(ctx)) renderParSets(ctx);
else refreshPsData(ctx);
} else {
renderParSets(ctx);
}
}
/** 拉全量网格点(后端 limit 缺省即全量)。force=true 用于刷新按钮。 */
export async function refreshPsData(ctx, force = false) {
if (!ctx.name) return;
if (!force && ctx.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(ctx.name, {}, { signal: ctx.abortCtl?.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
ctx.psState.points = json.data?.points || [];
ctx.psState.ts = Date.now();
savePsCache(ctx); // 持久化:浏览器刷新后可直接恢复,免重拉全量
renderParSets(ctx);
} catch (err) {
if (isAbortError(err)) return;
logError(err, '获取收敛性分析数据失败');
if (container) container.innerHTML = `<p class="text-hint">加载失败:${escapeHtml(err.message)}(点「刷新数据」重试)</p>`;
} finally {
if (btn) { btn.disabled = false; btn.textContent = '刷新数据'; }
}
}
/** 维度的有序离散取值列表。 */
function psDimValues(points, dim) {
const set = new Set(points.map(p => Number(p[dim])));
return [...set].filter(Number.isFinite).sort((a, b) => a - b);
}
/**
* 核心渲染:Parallel Sets。
* 布局:7 条垂直类别轴(6 物理 + 状态),等间距分布。每轴内按取值纵向堆叠,
* 每个取值的色块再按状态分段(高度=点数占比)。相邻轴同状态间用贝塞尔色带连接。
* 关键数据结构:flows[i] = { from:{dim,val,seg}, to:{dim,val,seg}, count, slot }
* ——「seg」是该取值×状态在轴上的堆叠段,色带从 from.seg 的状态段连到 to.seg 的同状态段。
*/
function renderParSets(ctx) {
const container = document.getElementById('ps-container');
if (!container) return;
const points = ctx.psState.points;
if (points.length === 0) {
container.innerHTML = '<p class="text-hint">暂无网格点数据(启动工作流后生成)</p>';
return;
}
const dims = [...PS_DIMS, 'status'];
// 每维取值列表(status 用 PS_STATUS 顺序)
const dimValues = {};
PS_DIMS.forEach(d => { dimValues[d] = psDimValues(points, d); });
dimValues.status = PS_STATUS;
// 预计算每点的状态档位与各维数值(一次解析、多处复用)。
// 旧版在「统计」与「6 对相邻轴色带计数」里反复调用 psSlot()(多次字符串比较)
// 与 Number()(字符串解析),网格点上千时是主要 CPU 开销;现一次性物化到数组。
const nPts = points.length;
const ptSlotArr = new Array(nPts);
const ptNum = {};
PS_DIMS.forEach(d => { ptNum[d] = new Float64Array(nPts); });
for (let i = 0; i < nPts; i++) {
const p = points[i];
ptSlotArr[i] = psSlot(p);
for (const d of PS_DIMS) ptNum[d][i] = Number(p[d]);
}
// 统计:每维每值的总数 + 每维每值每状态计数
const dimValueTotal = {}; // dim -> val -> count
const dimValSlotCount = {}; // dim -> val -> slot -> count
dims.forEach(d => { dimValueTotal[d] = {}; dimValSlotCount[d] = {}; });
for (let i = 0; i < nPts; i++) {
const slot = ptSlotArr[i];
for (const d of PS_DIMS) {
const v = ptNum[d][i];
if (!Number.isFinite(v)) continue;
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;
}
// status 轴:取值即档位本身
dimValueTotal.status[slot] = (dimValueTotal.status[slot] || 0) + 1;
if (!dimValSlotCount.status[slot]) dimValSlotCount.status[slot] = {};
dimValSlotCount.status[slot][slot] = (dimValSlotCount.status[slot][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; // 色块间距
ctx.psState.layout = { W, H, top, bot, dims, dimValues, dimValueTotal, dimValSlotCount, colW, axisW, gap };
ctx.psState.dimX = (i) => i * colW + colW / 2; // 轴中心 x
// 计算每个轴上每个取值的 y 区间(按总值占比纵向铺满 [top,bot])
// segY: dim -> val -> slot -> {y0, y1}(该状态段在轴上的像素区间)
// 两个关键约束(历史回归):
// 1) 取值间的 gap 会随取值数累积,必须从高度预算中扣除(availH),否则底部
// 色块下探超出 bot,与 H-16 处的轴标签(参数名)重叠——teff 取值多、状态轴
// 6 个固定档位最明显。
// 2) 状态轴是固定档位数组,可能出现数量为 0 的状态(如 pending=0):cnt=0 的
// 取值不占位、不建 segY——否则渲染端 segList 为空会把标签回退到 top(图右上角)。
const segY = {};
dims.forEach((d, di) => {
segY[d] = {};
const vals = dimValues[d].filter(v => (dimValueTotal[d][v] || 0) > 0);
const totalAll = vals.reduce((s, v) => s + dimValueTotal[d][v], 0) || 1;
const availH = Math.max(1, (bot - top) - (vals.length - 1) * gap);
let yCursor = top;
vals.forEach(v => {
const cnt = dimValueTotal[d][v] || 0;
const h = (cnt / totalAll) * availH;
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;
});
});
ctx.psState.segY = segY;
// 渲染轴色块(每值一个 g,内含按状态分段的 rect)+ 取值标签
const axisHtml = dims.map((d, di) => {
const x = ctx.psState.dimX(di);
const rx = x - axisW / 2;
const vals = dimValues[d].filter(v => segY[d][v]); // 与布局过滤一致(cnt>0 才有 segY
const blocks = vals.map(v => {
const vSegs = segY[d][v];
const segs = PS_STATUS.map(sl => {
const seg = vSegs[sl];
if (!seg) return '';
const h = Math.max(0.5, seg.y1 - seg.y0);
const dimmed = ctx.psState.hover && ctx.psState.hover !== sl ? ' ps-dim' : '';
const hi = ctx.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)}" tabindex="0" role="button" aria-label="${d}=${psFmtDim(d, v)} ${PS_STATUS_LABEL[sl]}"></rect>`;
}).join('');
// 取值标签(标在色块右侧,垂直居中于该值整块)。状态轴用中文+对应状态色,
// 状态轴本身即图例(刻度=状态名、颜色=该状态色),不再单列底部图例。
const segList = PS_STATUS.map(s => vSegs[s]).filter(Boolean);
const yMid = (segList[0].y0 + segList[segList.length - 1].y1) / 2;
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('');
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 = ctx.psState.dimX(di) + axisW / 2;
const x1 = ctx.psState.dimX(di + 1) - axisW / 2;
const xc1 = x0 + (x1 - x0) * 0.5;
// 统计 fromVal×toVal×slot 计数(复用预计算的 ptSlotArr/ptNum,避免逐点重解析)
const triple = new Map(); // key "fv|tv|slot" -> count
const fromIsStatus = fromD === 'status';
const toIsStatus = toD === 'status';
for (let i = 0; i < nPts; i++) {
const slot = ptSlotArr[i];
const fv = fromIsStatus ? slot : ptNum[fromD][i];
const tv = toIsStatus ? slot : ptNum[toD][i];
if (!fromIsStatus && !Number.isFinite(fv)) continue;
if (!toIsStatus && !Number.isFinite(tv)) continue;
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 => {
// cnt=0 的取值不建 segY(见布局段),此处需防御性访问
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 = ctx.psState.hover && ctx.psState.hover !== slot ? ' ps-dim' : '';
const hi = ctx.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 = `
<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(ctx);
renderPsConclusion(ctx, points);
const sum = document.getElementById('ps-summary');
if (sum) {
// 快照时间:数据来自缓存(刷新后恢复)时提示新旧,便于判断是否需要手动刷新
sum.textContent = `${points.length}${ctx.psState.ts ? ` · 快照 ${fmtClock(ctx.psState.ts)}` : ''}`;
}
}
/** 窗口缩放后重算布局(控制器 resize 防抖后调用)。 */
export function reflowParSets(ctx) {
const c = document.getElementById('ps-container');
const prev = ctx.psState.layout?.W || 0;
if (c && Math.abs((c.clientWidth || 0) - prev) > 20) renderParSets(ctx);
}
/** 自动洞察:扫各维度取值,挑失败率显著高于均值的位置。 */
function renderPsConclusion(ctx, pts) {
const el = document.getElementById('ps-conclusion');
if (!el) return;
const total = pts.length;
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 failed = pts.filter(p => p.status === 'failed').length;
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('');
// 屏幕阅读器文本摘要(screen-reader-summary):平行集合图是 SVG role=img
// 读屏用户无法感知图形内容,关键洞察须以文本同步播报。
const sr = document.getElementById('ps-sr-summary');
if (sr) sr.textContent = `收敛性分析摘要:${lines.join('')}`;
}
/** 悬停交互:移到色带/色块 → 高亮该状态的全链路(色带 + 同状态色块),其余压暗。 */
function bindPsHover(ctx) {
const svg = document.querySelector('.ps');
if (!svg) return;
const apply = (slot) => {
ctx.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 = () => { ctx.psState.hover = null; apply(null); hidePsTooltip(ctx); };
svg.addEventListener('mousemove', (e) => {
const target = e.target.closest('[data-slot]');
if (!target) { if (ctx.psState.hover) clear(); return; }
const slot = target.getAttribute('data-slot');
if (ctx.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]}${(ctx.psState.dimValSlotCount[dim]?.[target.getAttribute('data-val')]?.[slot]) || 0}`;
showPsTooltip(ctx, e.clientX, e.clientY, label);
});
svg.addEventListener('mouseleave', clear);
// 键盘交互:focus 色块 → 高亮 + tooltipEnter/Space → drill-down
svg.addEventListener('focusin', (e) => {
const seg = e.target.closest('.ps-seg');
if (!seg) return;
const slot = seg.getAttribute('data-slot');
const dim = seg.getAttribute('data-dim');
const val = seg.getAttribute('data-val');
if (ctx.psState.hover !== slot) apply(slot);
const label = `${dim}=${psFmtDim(dim, val)}${PS_STATUS_LABEL[slot]}${(ctx.psState.dimValSlotCount[dim]?.[val]?.[slot]) || 0}`;
const rect = seg.getBoundingClientRect();
showPsTooltip(ctx, rect.left + rect.width / 2, rect.top, label);
});
svg.addEventListener('focusout', (e) => {
if (!svg.contains(e.relatedTarget)) clear();
});
svg.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const seg = e.target.closest('.ps-seg');
if (!seg) return;
e.preventDefault();
const dim = seg.getAttribute('data-dim');
const val = seg.getAttribute('data-val');
const slot = seg.getAttribute('data-slot');
const names = ctx.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(ctx, names[0]); return; }
const rect = seg.getBoundingClientRect();
showPsPointList(ctx, rect.left + rect.width / 2, rect.bottom, dim, val, slot, names);
});
// 点击色块 → 弹出该维度值下的网格点名列表(点点名开详情面板)
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 = ctx.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(ctx, names[0]); return; }
showPsPointList(ctx, e.clientX, e.clientY, dim, val, slot, names);
});
}
/** tooltip 单例(挂在 ctx 上,unmount 由控制器移除)。 */
function showPsTooltip(ctx, clientX, clientY, html) {
if (!ctx.psTooltipEl) {
ctx.psTooltipEl = document.createElement('div');
ctx.psTooltipEl.className = 'ps-tooltip';
document.body.appendChild(ctx.psTooltipEl);
}
ctx.psTooltipEl.textContent = html;
const tw = ctx.psTooltipEl.offsetWidth || 160, th = ctx.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;
ctx.psTooltipEl.style.left = x + 'px';
ctx.psTooltipEl.style.top = y + 'px';
ctx.psTooltipEl.style.display = 'block';
}
function hidePsTooltip(ctx) {
if (ctx.psTooltipEl) ctx.psTooltipEl.style.display = 'none';
}
/** 点击色块时,若该组有多个点,弹出点名列表浮层(点点名 → openPointPanel)。 */
function showPsPointList(ctx, clientX, clientY, dim, val, slot, names) {
closePsPointList(ctx);
ctx.psPointListEl = document.createElement('div');
ctx.psPointListEl.className = 'ps-pointlist card';
ctx.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="关闭">
${ICONS.x({ size: 14 })}
</button>
</div>
<div class="ps-pointlist-body">${names.map(n => `<button type="button" class="ps-pointlink" data-name="${escapeHtml(n)}" title="查看详情">${escapeHtml(n)}</button>`).join('')}</div>
`;
document.body.appendChild(ctx.psPointListEl);
// 定位(锚定点击处,限制在视口内)
const r = ctx.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);
ctx.psPointListEl.style.left = x + 'px';
ctx.psPointListEl.style.top = y + 'px';
ctx.psPointListEl.addEventListener('click', (e) => {
if (e.target.closest('.ps-pointlist-close')) { closePsPointList(ctx); return; }
const link = e.target.closest('[data-name]');
if (link) { openPointPanel(ctx, link.getAttribute('data-name')); closePsPointList(ctx); }
});
}
export function closePsPointList(ctx) {
if (ctx.psPointListEl) { ctx.psPointListEl.remove(); ctx.psPointListEl = null; }
}
+194
View File
@@ -0,0 +1,194 @@
/* 工作流详情页 · 网格点详情滑入面板(尝试历史 + 阶段链诊断)
*
* 从点表「查看」按钮、Parallel Sets 点名列表 / 单点 drill-down 进入。
* 面板 DOM 挂在 ctx.pointPanelElunmount 由控制器调用 closePointPanel 清理)。
*/
import { fetchPointDetailApi } from '../../api.js';
import { showToast } from '../../components/toast.js';
import { setupFocusTrap, releaseFocusTrap } from '../../components/modal.js';
import {
escapeHtml, pointStatusBadge, pointMethodBadge,
fmtRelc, fmtDur, fmtDateTime, fmtFullTs,
} from '../../utils/format.js';
import { isAbortError } from '../../utils/errors.js';
import { ICONS } from '../../utils/icons.js';
function buildPanelShell(ctx, name) {
const backdrop = document.createElement('div');
backdrop.className = 'point-panel-backdrop';
backdrop.innerHTML = `
<aside class="point-panel card" role="dialog" aria-modal="true" aria-label="网格点详情">
<div class="point-panel-header">
<div class="point-panel-title">
<h3 class="point-panel-name" title="${escapeHtml(name)}">${escapeHtml(name)}</h3>
<span class="point-panel-badges" id="point-panel-badges"></span>
</div>
<div class="point-panel-header-actions">
<button type="button" class="btn btn-secondary btn-sm" data-panel-copy>复制点名</button>
<button type="button" class="modal-close point-panel-close" aria-label="关闭">
${ICONS.x({ size: 16 })}
</button>
</div>
</div>
<div class="point-panel-body" id="point-panel-body">
<p class="text-hint">加载中…</p>
</div>
</aside>
`;
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) {
closePointPanel(ctx);
return;
}
if (e.target.closest('.point-panel-close')) {
closePointPanel(ctx);
return;
}
if (e.target.closest('[data-panel-copy]')) {
navigator.clipboard?.writeText(name).then(
() => showToast('点名已复制', 'success'),
() => showToast('复制失败,请手动复制', 'info')
);
return;
}
// 种子来源点名跳查:换载面板内容到该点
const seedLink = e.target.closest('[data-panel-point]');
if (seedLink) {
e.preventDefault();
openPointPanel(ctx, seedLink.getAttribute('data-panel-point'));
}
});
document.body.appendChild(backdrop);
setupFocusTrap(backdrop.querySelector('.point-panel'));
return backdrop;
}
export function closePointPanel(ctx) {
if (!ctx.pointPanelEl) return;
const panel = ctx.pointPanelEl.querySelector('.point-panel');
if (panel) releaseFocusTrap(panel);
ctx.pointPanelEl.remove();
ctx.pointPanelEl = null;
}
export async function openPointPanel(ctx, pointName) {
if (!ctx.name) return;
closePointPanel(ctx);
ctx.pointPanelEl = buildPanelShell(ctx, pointName);
const body = ctx.pointPanelEl.querySelector('#point-panel-body');
const badges = ctx.pointPanelEl.querySelector('#point-panel-badges');
try {
const res = await fetchPointDetailApi(ctx.name, pointName, { signal: ctx.abortCtl?.signal });
if (res.status === 404) {
body.innerHTML = '<p class="text-hint">该点不存在(可能已被删除)</p>';
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (!json.success || !json.data) throw new Error(json.message || '无数据');
const { point, attempts, conv } = json.data;
badges.innerHTML = `${pointStatusBadge(point.status)} ${pointMethodBadge(point.success_method)}`;
body.innerHTML = '';
body.appendChild(buildAttemptSection(attempts));
body.appendChild(buildConvSection(conv));
} catch (err) {
if (isAbortError(err)) return;
body.innerHTML = `<p class="text-hint">加载失败:${escapeHtml(err.message)}(请关闭面板后重试,或检查网络)</p>`;
}
}
function buildAttemptSection(attempts) {
const sec = document.createElement('section');
sec.className = 'point-section';
const list = attempts || [];
const rows = list.map((a, i) => {
const ok = a.status === 'completed';
const seedHtml = a.seed_point_name
? `<a href="#" data-panel-point="${escapeHtml(a.seed_point_name)}" class="seed-link" title="跳查种子来源点">${escapeHtml(a.seed_point_name)}</a>`
: '—';
const resultHtml = ok
? '<span class="activity-result ok">成功</span>'
: `<span class="activity-result fail">${escapeHtml(a.status)}</span>`;
return `
<tr>
<td class="tabular-num">${i + 1}</td>
<td>${pointMethodBadge(a.task_type)}</td>
<td>${seedHtml}</td>
<td class="tabular-num">${fmtRelc(a.max_relc)}</td>
<td>${resultHtml}</td>
<td>${escapeHtml(a.node_id || '—')}</td>
<td class="tabular-num" title="${escapeHtml(fmtFullTs(a.completed_at))}">${fmtDateTime(a.completed_at)}</td>
<td class="tabular-num">${fmtDur(a.elapsed_sec)}</td>
</tr>`;
}).join('') || '<tr><td colspan="8" class="empty-cell">尚无尝试记录(点未被派发)</td></tr>';
sec.innerHTML = `
<h3 class="point-section-title">尝试历史(${list.length}</h3>
<div class="table-responsive">
<table class="data-table attempts-table">
<thead>
<tr><th scope="col">#</th><th scope="col">方法</th><th scope="col">种子来源</th><th scope="col">max_relc</th><th scope="col">结果</th><th scope="col">节点</th><th scope="col">完成时间</th><th scope="col">耗时</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
// 最近一次失败尝试的错误文本:textContent 渲染,杜绝 XSS
const lastErr = [...list].reverse().find(a => a.error_message);
if (lastErr) {
const pre = document.createElement('pre');
pre.className = 'code-pre-wrap point-error';
pre.textContent = `最近错误:${lastErr.error_message}`;
sec.appendChild(pre);
}
return sec;
}
function buildConvSection(conv) {
const sec = document.createElement('section');
sec.className = 'point-section';
if (!conv) {
sec.innerHTML = `
<h3 class="point-section-title">阶段链诊断</h3>
<p class="text-hint">诊断文件不可用(conv.json 缺失或解析失败)</p>`;
return sec;
}
const stagesHtml = (conv.stages || []).map(st => {
// 阶段状态图标用 SVG(与全局图标族一致),不用 emoji——emoji 跨平台渲染不一、
// 无法由 CSS 控色,在暗色模式下尤其违和(no-emoji-icons)。
const icon = st.converged
? ICONS.checkCircle({ size: 14, cls: 'stage-icon stage-icon-ok' })
: ICONS.warn({ size: 14, cls: 'stage-icon stage-icon-warn' });
const relc = st.best_max_relc != null ? Number(st.best_max_relc).toExponential(2) : '—';
const iter = st.last_iter != null ? ` · ${st.last_iter} iter` : '';
const depth = st.worst_depth != null ? ` · 最差深度 ${st.worst_depth}/${st.n_depths ?? '?'}` : '';
const secs = st.elapsed_sec != null ? ` · ${Math.round(st.elapsed_sec)}s` : '';
return `
<div class="stage-chip ${st.converged ? 'stage-ok' : 'stage-warn'}">
<span class="stage-label">${icon} ${escapeHtml(st.label)}</span>
<span class="stage-meta tabular-num">relc ${relc} · chmax ${st.chmax ?? '—'}${iter}${depth}${secs}</span>
</div>`;
}).join('') || '<p class="text-hint">无阶段数据</p>';
const seedTxt = conv.seed ? escapeHtml(String(conv.seed).split('/').pop()) : '无(冷启动)';
// 半失败可见性(审查 #2 后续):大气已收敛但光谱失败时,converged 仍为 true 而
// synspec_rc/synspec_error 非空——必须把这两个量展示出来,否则半失败点无从排查。
const synspecRc = conv.synspec_rc != null ? `<span class="tabular-num">${conv.synspec_rc}</span>` : '—';
const synspecErrHtml = conv.synspec_error
? `<span class="activity-result fail" title="${escapeHtml(conv.synspec_error)}">${escapeHtml(conv.synspec_error)}</span>`
: '—';
sec.innerHTML = `
<h3 class="point-section-title">阶段链诊断</h3>
<div class="stage-chain">${stagesHtml}</div>
<dl class="conv-meta">
<div><dt>最终 max_relc</dt><dd class="tabular-num">${conv.final_max_relc != null ? Number(conv.final_max_relc).toExponential(3) : '—'}</dd></div>
<div><dt>收敛阈值 chmax</dt><dd class="tabular-num">${conv.final_chmax ?? '—'}</dd></div>
<div><dt>种子</dt><dd>${seedTxt}</dd></div>
<div><dt>总耗时</dt><dd class="tabular-num">${conv.elapsed_sec != null ? `${Math.round(conv.elapsed_sec)}s` : '—'}</dd></div>
<div><dt>synspec 耗时</dt><dd class="tabular-num">${conv.synspec_sec != null ? `${Math.round(conv.synspec_sec)}s` : '—'}</dd></div>
<div><dt>synspec 返回码</dt><dd>${synspecRc}</dd></div>
<div><dt>synspec 错误</dt><dd>${synspecErrHtml}</dd></div>
<div><dt>大气 NaN</dt><dd>${conv.atmosphere_has_nan ? '<span class="activity-result fail">是</span>' : '否'}</dd></div>
</dl>
`;
return sec;
}
+246
View File
@@ -0,0 +1,246 @@
/* 工作流详情页 · Tab2 网格点明细
*
* 行签名 diff:状态/方法/尝试数不变时只原位刷新 max_relc 等高频单元格,
* 不整行重建(避免悬停/聚焦丢失与表格抖动)。分页/过滤/排序状态在 ctx.pointsFilter。
*/
import { fetchWorkflowPointsApi } from '../../api.js';
import { showToast } from '../../components/toast.js';
import {
escapeHtml, pointStatusBadge, pointMethodBadge,
fmtRelc, fmtDur, fmtDateTime, fmtFullTs, csvEscape,
} from '../../utils/format.js';
import { isAbortError, logError } from '../../utils/errors.js';
import { ICONS } from '../../utils/icons.js';
/** 首次进入 Tab2 构建表格骨架与过滤器;其后轮询只走 refreshPoints 原位更新。 */
export function renderPointsTab(ctx) {
const el = document.getElementById('wf-tab-points');
if (!el) return;
if (ctx.pointsBuilt) {
refreshPoints(ctx);
return;
}
ctx.pointsBuilt = true;
const waveOpts = (ctx.latestStats?.waves || [])
.map(w => `<option value="${escapeHtml(w.wave)}">wave ${escapeHtml(w.wave)}</option>`)
.join('');
el.innerHTML = `
<div class="panel card">
<div class="panel-header">
<h2>网格点明细</h2>
<button type="button" class="btn btn-secondary btn-sm" data-wf-points-export title="按当前过滤条件导出全部匹配点为 CSV">
${ICONS.download({ size: 12 })}
导出 CSV
</button>
</div>
<div class="panel-body">
<div class="points-filters">
<select data-wf-filter="status" aria-label="按状态过滤">
<option value="">全部状态</option>
<option value="pending">未开始</option>
<option value="queued">排队中</option>
<option value="running">运行中</option>
<option value="converged">已收敛</option>
<option value="failed">失败</option>
</select>
<select data-wf-filter="method" aria-label="按收敛手段过滤">
<option value="">全部手段</option>
<option value="cold_run">冷启动</option>
<option value="seed_step">种子步进</option>
</select>
<select data-wf-filter="wave" aria-label="按波次过滤">
<option value="">全部波次</option>${waveOpts}
</select>
<input type="search" data-wf-filter="q" placeholder="搜索点名(回车生效)" aria-label="点名搜索" />
<button type="button" class="btn btn-secondary btn-sm" data-wf-points-refresh>刷新</button>
</div>
<div class="table-responsive">
<table class="data-table points-table">
<thead>
<tr>
<th class="col-point-name" scope="col">点名</th>
<th class="pt-param sortable" scope="col" data-wf-sort="teff" aria-sort="none" title="点击切换排序">Teff</th><th class="pt-param" scope="col">logg</th><th class="pt-param" scope="col">logHe</th><th class="pt-param" scope="col">CNO和</th><th class="pt-param sortable" scope="col" data-wf-sort="wave" aria-sort="ascending" title="点击切换排序">wave</th>
<th scope="col">状态</th><th scope="col">方法</th><th class="sortable" scope="col" data-wf-sort="max_relc" aria-sort="none" title="点击切换排序">max_relc</th><th class="sortable" scope="col" data-wf-sort="elapsed" aria-sort="none" title="点击切换排序">耗时</th><th class="sortable" scope="col" data-wf-sort="last_completed_at" aria-sort="none" title="点击切换排序">完成时间</th><th class="sortable" scope="col" data-wf-sort="attempts" aria-sort="none" title="点击切换排序">尝试</th><th scope="col">操作</th>
</tr>
</thead>
<tbody id="points-table-body">
<tr><td colspan="13" class="empty-cell">正在加载网格点数据…</td></tr>
</tbody>
</table>
</div>
<div class="points-pagination">
<button type="button" class="btn btn-secondary btn-sm" data-wf-page="prev"> 上一页</button>
<span id="points-page-info" class="tabular-num"></span>
<button type="button" class="btn btn-secondary btn-sm" data-wf-page="next">下一页 </button>
</div>
</div>
</div>
`;
updateSortIndicators(ctx);
refreshPoints(ctx);
}
export async function refreshPoints(ctx) {
if (!ctx.name) return;
const tbody = document.getElementById('points-table-body');
if (!tbody) return;
try {
const params = {
sort: ctx.pointsFilter.sort,
order: ctx.pointsFilter.order,
limit: ctx.pointsFilter.limit,
offset: ctx.pointsFilter.offset,
};
if (ctx.pointsFilter.status) params.status = ctx.pointsFilter.status;
if (ctx.pointsFilter.method) params.method = ctx.pointsFilter.method;
if (ctx.pointsFilter.wave !== '') params.wave = ctx.pointsFilter.wave;
if (ctx.pointsFilter.q) params.q = ctx.pointsFilter.q;
const res = await fetchWorkflowPointsApi(ctx.name, params, { signal: ctx.abortCtl?.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (!json.success || !json.data) return;
renderPointsRows(tbody, json.data.points);
renderPointsPagination(json.data.total, ctx);
} catch (err) {
if (isAbortError(err)) return;
logError(err, '获取网格点列表失败');
}
}
/** 关键行签名 diff:状态/方法/尝试数不变时只原位刷新 max_relc 等高频单元格。 */
function renderPointsRows(tbody, points) {
if (!points || points.length === 0) {
// 守卫:已有空行占位时不重复写 innerHTML(避免每 tick 无谓 DOM 写)
if (!tbody.querySelector('.empty-cell')) {
tbody.innerHTML = '<tr><td colspan="13" class="empty-cell">没有符合条件的网格点</td></tr>';
}
return;
}
const existing = new Map();
tbody.querySelectorAll('tr[data-point-row]').forEach(tr => {
existing.set(tr.getAttribute('data-point-row'), tr);
});
const updated = [];
points.forEach(p => {
const name = escapeHtml(p.name);
const sig = `${p.status}|${p.success_method || ''}|${p.attempt_count}`;
let tr = existing.get(p.name);
if (tr && tr.getAttribute('data-sig') === sig) {
const cell = tr.querySelector('.col-max-relc');
const relcHtml = fmtRelc(p.last_max_relc);
if (cell && cell.innerHTML !== relcHtml) cell.innerHTML = relcHtml;
} else {
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 pt-param">${escapeHtml(p.teff)}</td>
<td class="tabular-num pt-param">${escapeHtml(p.logg)}</td>
<td class="tabular-num pt-param">${escapeHtml(p.loghe)}</td>
<td class="tabular-num pt-param">${escapeHtml(p.cno_sum)}</td>
<td class="tabular-num pt-param">${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" title="${escapeHtml(fmtFullTs(p.last_completed_at))}">${fmtDateTime(p.last_completed_at)}</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>`;
if (!tr) {
tr = document.createElement('tr');
tr.setAttribute('data-point-row', p.name);
}
tr.innerHTML = html;
tr.setAttribute('data-sig', sig);
}
existing.delete(p.name);
updated.push(tr);
});
existing.forEach(tr => tr.remove());
updated.forEach((tr, i) => {
if (tbody.children[i] !== tr) tbody.insertBefore(tr, tbody.children[i] || null);
});
}
function renderPointsPagination(total, ctx) {
const info = document.getElementById('points-page-info');
if (!info) return;
const pages = Math.max(1, Math.ceil(total / ctx.pointsFilter.limit));
const cur = Math.floor(ctx.pointsFilter.offset / ctx.pointsFilter.limit) + 1;
info.textContent = total > 0 ? `${cur}/${pages} 页 · 共 ${total}` : `${total}`;
const prev = document.querySelector('[data-wf-page="prev"]');
const next = document.querySelector('[data-wf-page="next"]');
if (prev) prev.disabled = cur <= 1;
if (next) next.disabled = cur >= pages;
}
/** 同步表头 aria-sort 状态(sortable-table):当前排序列标升/降,其余列 none。
* 屏幕阅读器依赖 aria-sort 播报排序,视觉上用 ::after 箭头(见 style.css)。 */
export function updateSortIndicators(ctx) {
document.querySelectorAll('.points-table th[data-wf-sort]').forEach(th => {
const key = th.getAttribute('data-wf-sort');
th.setAttribute(
'aria-sort',
key === ctx.pointsFilter.sort
? (ctx.pointsFilter.order === 'desc' ? 'descending' : 'ascending')
: 'none'
);
});
}
/** 按当前过滤条件导出全量匹配网格点为 CSV(BOM 头兼容 Excel 中文)。
* 科学计算场景常需把点数据带离控制台做后续分析(export-option)。 */
export async function exportPointsCsv(ctx) {
if (!ctx.name) return;
const btn = document.querySelector('[data-wf-points-export]');
if (btn) {
btn.disabled = true;
btn.textContent = '导出中…';
}
try {
// limit 缺省即全量(后端语义),此处显式不传 limit/offset 以取全部匹配点。
const params = {};
if (ctx.pointsFilter.status) params.status = ctx.pointsFilter.status;
if (ctx.pointsFilter.method) params.method = ctx.pointsFilter.method;
if (ctx.pointsFilter.wave !== '') params.wave = ctx.pointsFilter.wave;
if (ctx.pointsFilter.q) params.q = ctx.pointsFilter.q;
const res = await fetchWorkflowPointsApi(ctx.name, params, { signal: ctx.abortCtl?.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
const pts = json.data?.points || [];
if (pts.length === 0) {
showToast('当前过滤条件下没有可导出的网格点', 'info');
return;
}
const header = ['name','teff','logg','loghe','logc','logn','logo','cno_sum','wave','status','success_method','attempt_count','last_max_relc','last_task_type','seed_point_name','node_id','last_completed_at','last_elapsed_sec','last_error'];
const rows = pts.map(p => [
p.name, p.teff, p.logg, p.loghe, p.logc, p.logn, p.logo, p.cno_sum, p.wave,
p.status, p.success_method ?? '', p.attempt_count ?? '',
p.last_max_relc ?? '', p.last_task_type ?? '', p.seed_point_name ?? '',
p.node_id ?? '', p.last_completed_at ?? '', p.last_elapsed_sec ?? '', p.last_error ?? '',
].map(csvEscape).join(','));
const csv = '\uFEFF' + [header.join(','), ...rows].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${ctx.name}_points.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
showToast(`已导出 ${pts.length} 个网格点`, 'success');
} catch (err) {
if (!isAbortError(err)) {
showToast(`导出失败:${err.message}(请检查网络后重试)`, 'error');
}
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = `${ICONS.download({ size: 12 })} 导出 CSV`;
}
}
}
+329 -79
View File
@@ -1,10 +1,10 @@
/* DCTS 控制台首页视图
*
* 内容:核心指标卡 + 计算节点集群与凭据管理大表 + 工作流卡片列表。
* 标记来自 index.html 的 #view-home-template克隆挂载,ID 与原版逐字一致,
* state.js 轮询渲染逻辑无需任何改动)。
* 模板原为 index.html 的 #view-home-templatetemplate 标签克隆),为统一视图渲染方式
* 现改为本文件的 HOME_TEMPLATE 字符串(ID 与原版逐字一致,state.js 轮询渲染无需改动)。
*
* 生命周期:mountHome 克隆模板 → 绑定首页作用域事件委托 → 骨架屏 → 启动全局轮询;
* 生命周期:mountHome 挂载模板 → 绑定首页作用域事件委托 → 骨架屏 → 启动全局轮询;
* unmountHome 停轮询 → 清空 #view-root(委托监听随 DOM 移除,无泄漏)。
*/
@@ -13,22 +13,134 @@ import {
rejectNodeApi,
reissueNodeApi,
disableNodeApi,
enableNodeApi
enableNodeApi,
setNodeQuotaApi,
fetchNodesList
} from '../api.js';
import { showToast } from '../components/toast.js';
import { showConfirm, setupFocusTrap } from '../components/modal.js';
import { showConfirm, setupFocusTrap, hideModal, releaseFocusTrap } from '../components/modal.js';
import { renderNodesTableSkeleton } from '../components/nodesTable.js';
import { renderWorkflowsSkeleton } from '../components/workflows.js';
import { startPolling, stopPolling, fetchAllData, fetchNodes } from '../state.js';
import { triggerRefreshAnimation } from '../utils.js';
import {
handleStartWorkflow,
handleStopWorkflow,
handleDeleteWorkflow,
handleViewWorkflow
} from '../components/wfActions.js';
import { friendlyError } from '../utils/errors.js';
import { ICONS } from '../utils/icons.js';
import { fmtFullTs } from '../utils/format.js';
// 注:工作流操作(启动/停止/删除/保存配置)已收敛至详情页内嵌配置面板
// wfEnginePanel.js),首页不再 import wfActions 处理器。
// ===== 首页 DOM 模板(原 index.html #view-home-template,统一为 JS 模板字符串) =====
const HOME_TEMPLATE = `
<section class="metrics-grid">
<div class="metric-card card">
<div class="metric-header">
<span class="metric-title">在线计算节点</span>
<div class="metric-icon nodes-icon">
${ICONS.server({ size: 20 })}
</div>
</div>
<div class="metric-body">
<span class="metric-value" id="val-active-nodes">0</span>
<span class="metric-sub" id="val-total-slots">0 / 0 CPU 槽位占用</span>
<div class="progress-bar-track" aria-hidden="true">
<div class="progress-bar-fill" id="slots-progress-fill"></div>
</div>
</div>
</div>
<div class="metric-card card">
<div class="metric-header">
<span class="metric-title">待计算网格点</span>
<div class="metric-icon pending-icon">
${ICONS.clock({ size: 20 })}
</div>
</div>
<div class="metric-body">
<span class="metric-value" id="val-pending-tasks">0</span>
<span class="metric-sub" id="val-running-tasks">0 个任务计算中</span>
</div>
</div>
<div class="metric-card card">
<div class="metric-header">
<span class="metric-title">已计算网格模型</span>
<div class="metric-icon success-icon">
${ICONS.checkCircle({ size: 20 })}
</div>
</div>
<div class="metric-body">
<span class="metric-value" id="val-completed-tasks">0</span>
<span class="metric-sub" id="val-completion-rate">网格总数: 0</span>
<div class="progress-bar-track" aria-hidden="true">
<div class="progress-bar-fill progress-fill-emerald" id="converged-progress-fill"></div>
</div>
</div>
</div>
<div class="metric-card card">
<div class="metric-header">
<span class="metric-title">计算工作流</span>
<div class="metric-icon workflow-icon">
${ICONS.layers({ size: 20 })}
</div>
</div>
<div class="metric-body">
<span class="metric-value" id="val-total-workflows">0</span>
<span class="metric-sub" id="val-active-wf">系统已就绪</span>
</div>
</div>
</section>
<div class="content-grid">
<div class="panel card">
<div class="panel-header">
<h2>计算节点集群与凭据管理</h2>
<div class="row-wrap" style="align-items: center; gap: 0.5rem;">
<span class="badge" id="node-count-badge">0 个节点</span>
<button id="btn-refresh-nodes" class="btn btn-secondary btn-sm">刷新</button>
</div>
</div>
<div class="panel-body table-responsive">
<table class="data-table nodes-table">
<thead>
<tr>
<th class="col-node-id" scope="col">节点 ID</th>
<th class="col-slots" scope="col">CPU 槽位</th>
<th class="col-usage" scope="col">CPU/内存</th>
<th class="col-status" scope="col">节点状态</th>
<th class="col-token" scope="col">Token 凭据</th>
<th class="col-heartbeat" scope="col">心跳时间</th>
<th class="col-actions" scope="col">管理操作</th>
</tr>
</thead>
<tbody id="nodes-table-body">
<tr>
<td colspan="7" class="empty-cell">正在连接服务端获取计算节点...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="stack-col stack-col-lg">
<div class="panel card">
<div class="panel-header">
<h2>恒星大气网格工作流</h2>
</div>
<div class="panel-body">
<div id="workflows-list" class="workflows-container">
<div class="empty-cell">正在获取工作流配置...</div>
</div>
</div>
</div>
</div>
</div>
`;
// ===== 节点授权管理交互处理(首页专属) =====
async function handleApprove(nodeId) {
const ok = await showConfirm('同意节点接入', `确定同意授权计算节点 '${nodeId}' 加入集群吗?\n系统将自动分配身份 Token 并在节点轮询时下发。`);
if (!ok) return;
@@ -40,10 +152,10 @@ async function handleApprove(nodeId) {
showToast(json.message || '节点接入成功已授权', 'success');
fetchAllData();
} else {
showToast(json.message || '审批失败', 'error');
showToast(json.message || '审批失败:请稍后重试,或检查服务端日志', 'error');
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
showToast(`审批请求失败:${friendlyError(err, '审批请求失败')}`, 'error');
}
}
@@ -58,90 +170,231 @@ async function handleReject(nodeId) {
showToast(json.message || '已拒绝节点接入申请', 'info');
fetchAllData();
} else {
showToast(json.message || '操作失败', 'error');
showToast(json.message || '拒绝操作失败:请稍后重试', 'error');
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
showToast(`拒绝请求失败:${friendlyError(err, '拒绝请求失败')}`, 'error');
}
}
async function handleDisable(nodeId) {
const ok = await showConfirm('停用节点', `确定停用节点 '${nodeId}' 吗?\n停用后该节点保持在线但不再被分发任务,worker 将空闲待命。可随时重新启用。`);
if (!ok) return;
// 启用 / 停用 / 重发 Token 的离散表格按钮已收敛至 #modal-node-manage 三点菜单
// (见 handleManage)。以下三个 handler 不再被表格委托直接调用,但其 API 调用逻辑
// 已内联到 handleManage 的对应按钮,故此处移除独立函数避免死代码与 lint 未使用告警。
// 拉取最新节点详情并填充 #modal-node-manage 后打开。
// 内部按钮(保存配额/清除配额/启停/重发)每次打开时重新绑定:先 cloneNode 移除旧监听,
// 再 addEventListener,避免重复打开导致监听累积。
async function handleManage(nodeId) {
const modal = document.getElementById('modal-node-manage');
if (!modal) return;
// 拉最新节点数据(避免用到过期的 active_slots/状态)
let node = null;
try {
const res = await disableNodeApi(nodeId);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '节点已停用', 'info');
fetchAllData();
} else {
showToast(json.message || '停用失败', 'error');
// fetchNodesList 内部已 return res.json()(解析后的 JSON),不要再对其调 .json()——
// 否则在普通对象上调 .json() 抛 TypeError,被 friendlyError 误报为「网络连接失败」。
const json = await fetchNodesList();
if (json.success && Array.isArray(json.data)) {
node = json.data.find((n) => (n.node_id || n.id) === nodeId) || null;
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
showToast(`获取节点详情失败:${friendlyError(err, '获取节点详情失败')}`, 'error');
return;
}
if (!node) {
showToast(`未找到节点 '${nodeId}',可能已被移除`, 'error');
return;
}
}
async function handleEnable(nodeId) {
const ok = await showConfirm('启用节点', `确定重新启用节点 '${nodeId}' 吗?\n节点将在下一次心跳后恢复分发任务。`);
if (!ok) return;
// 填充基础信息
const idEl = document.getElementById('node-manage-id');
if (idEl) idEl.textContent = nodeId;
const isOnline = node.status === 'online' || node.status === 'active';
const isDisabled = node.status === 'disabled';
const statusEl = document.getElementById('node-manage-status');
if (statusEl) {
let badge = '<span class="status-badge offline">离线</span>';
if (isDisabled) badge = '<span class="status-badge secondary">已停用</span>';
else if (isOnline) badge = '<span class="status-badge online">在线</span>';
statusEl.innerHTML = badge;
}
const slotsEl = document.getElementById('node-manage-slots');
if (slotsEl) {
const active = Number(node.active_slots || 0);
// 缺省 4 与表格列(nodesTable volatileValues)口径一致,避免 0/0 误导(审查修复 m9)
const max = Number(node.max_slots || 4);
slotsEl.textContent =
node.admin_max_slots != null
? `${active} / ${Number(node.admin_max_slots)} / ${max}`
: `${active} / ${max}`;
}
const usageEl = document.getElementById('node-manage-usage');
if (usageEl) {
usageEl.textContent = `${Number(node.cpu_usage || 0).toFixed(1)}% / ${Number(node.memory_usage || 0).toFixed(1)}%`;
}
const hbEl = document.getElementById('node-manage-heartbeat');
if (hbEl) hbEl.textContent = fmtFullTs(node.last_heartbeat) || '—';
try {
const res = await enableNodeApi(nodeId);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '节点已重新启用', 'success');
fetchAllData();
} else {
showToast(json.message || '启用失败', 'error');
// 配额输入框:当前配额回填(无配额留空)
const quotaInput = document.getElementById('node-manage-quota-input');
if (quotaInput) {
quotaInput.value = node.admin_max_slots != null ? String(node.admin_max_slots) : '';
}
// 内部按钮:cloneNode 清旧监听后重新绑定(每次打开节点不同,闭包需捕获当前 nodeId/node
const rebind = (id, handler) => {
const old = document.getElementById(id);
if (!old) return;
const fresh = old.cloneNode(true);
old.replaceWith(fresh);
fresh.addEventListener('click', handler);
};
rebind('btn-node-save-quota', async () => {
const raw = (quotaInput?.value || '').trim();
// 留空 → 与「清除配额」按钮同语义。为避免想改数没输全就点保存导致配额被静默清除,
// 留空时走确认弹窗(审查修复 m1),两条清除路径的确认机制保持一致。
if (raw === '') {
const ok = await showConfirm('清除配额限制', `输入框留空且点击了保存。确定清除节点 '${nodeId}' 的并发槽位配额吗?\n清除后将恢复该节点的物理最大槽位上限。`);
if (!ok) return;
try {
const res = await setNodeQuotaApi(nodeId, null);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '已清除配额限制', 'success');
hideModal(modal);
fetchAllData();
} else {
showToast(json.message || '清除配额失败:请稍后重试', 'error');
}
} catch (err) {
showToast(`清除配额失败:${friendlyError(err, '清除配额失败')}`, 'error');
}
return;
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
async function handleReissue(nodeId) {
const ok = await showConfirm('重发节点 Token', `确定为节点 '${nodeId}' 重新颁发专属 Token 吗?旧 Token 将立即失效,请随后把新 Token 同步到该节点。`);
if (!ok) return;
try {
const res = await reissueNodeApi(nodeId);
const json = await res.json();
if (json.success && json.node_token) {
// 弹窗展示新 token 明文(仅此一次):管理员须手动复制并写入节点本地 .node_token
// 后重启节点。服务端不持久化明文,故必须在此处交给管理员。
const modal = document.getElementById('modal-reissue-token');
document.getElementById('reissue-token-content').textContent = json.node_token;
modal?.classList.remove('hidden');
setupFocusTrap(modal);
showToast('已重新生成专属 Token,请复制并同步到节点', 'success');
fetchAllData();
} else {
showToast(json.message || '重发失败', 'error');
// 解析为非负整数;上限校验防止超出后端 i32 范围(审查修复 m2)
const val = Number(raw);
if (!Number.isInteger(val) || val < 0) {
showToast('配额必须是非负整数(0=暂停接新任务,留空=无限制)', 'error');
return;
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
if (val > 1024) {
showToast('配额上限过大(最大 1024', 'error');
return;
}
try {
const res = await setNodeQuotaApi(nodeId, val);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '配额已保存', 'success');
hideModal(modal);
fetchAllData();
} else {
showToast(json.message || '保存配额失败:请稍后重试', 'error');
}
} catch (err) {
showToast(`保存配额失败:${friendlyError(err, '保存配额失败')}`, 'error');
}
});
rebind('btn-node-clear-quota', async () => {
const ok = await showConfirm('清除配额限制', `确定清除节点 '${nodeId}' 的并发槽位配额吗?\n清除后将恢复该节点的物理最大槽位上限。`);
if (!ok) return;
try {
const res = await setNodeQuotaApi(nodeId, null);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '已清除配额限制', 'success');
hideModal(modal);
fetchAllData();
} else {
showToast(json.message || '清除配额失败:请稍后重试', 'error');
}
} catch (err) {
showToast(`清除配额失败:${friendlyError(err, '清除配额失败')}`, 'error');
}
});
rebind('btn-node-disable', async () => {
const ok = await showConfirm('停用节点', `确定停用节点 '${nodeId}' 吗?\n停用后保持在线但不再分发任务,可随时重新启用。`);
if (!ok) return;
try {
const res = await disableNodeApi(nodeId);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '节点已停用', 'info');
hideModal(modal);
fetchAllData();
} else {
showToast(json.message || '停用失败:请稍后重试', 'error');
}
} catch (err) {
showToast(`停用请求失败:${friendlyError(err, '停用请求失败')}`, 'error');
}
});
rebind('btn-node-enable', async () => {
const ok = await showConfirm('启用节点', `确定重新启用节点 '${nodeId}' 吗?\n节点将在下一次心跳后恢复分发任务。`);
if (!ok) return;
try {
const res = await enableNodeApi(nodeId);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '节点已重新启用', 'success');
hideModal(modal);
fetchAllData();
} else {
showToast(json.message || '启用失败:请稍后重试', 'error');
}
} catch (err) {
showToast(`启用请求失败:${friendlyError(err, '启用请求失败')}`, 'error');
}
});
rebind('btn-node-reissue', async () => {
const ok = await showConfirm('重发节点 Token', `确定为节点 '${nodeId}' 重新颁发专属 Token 吗?旧 Token 将立即失效。`);
if (!ok) return;
try {
const res = await reissueNodeApi(nodeId);
const json = await res.json();
if (res.ok && json.success && json.node_token) {
// 先**同步**关闭管理 Modal,再弹 Token 展示窗(审查修复 M1):
// hideModal 走 220ms 退出动画,期间两个 backdrop 会同时存在(双重暗化)且
// 旧 Modal 的焦点陷阱会在动画结束时强制把焦点拽回表格按钮——导致新 Token 窗
// 的 focus trap 失效。改为跳过动画同步隐藏:释放 trap → 移除动画类 → 立即 hidden。
releaseFocusTrap(modal);
modal.classList.remove('modal-closing');
modal.classList.add('hidden');
const tokenModal = document.getElementById('modal-reissue-token');
document.getElementById('reissue-token-content').textContent = json.node_token;
tokenModal?.classList.remove('hidden');
setupFocusTrap(tokenModal);
showToast('已重新生成专属 Token,请复制并同步到节点', 'success');
fetchAllData();
} else {
showToast(json.message || '重发失败:请稍后重试', 'error');
}
} catch (err) {
showToast(`重发请求失败:${friendlyError(err, '重发请求失败')}`, 'error');
}
});
modal.classList.remove('hidden');
setupFocusTrap(modal);
}
export function mountHome() {
const root = document.getElementById('view-root');
const tpl = document.getElementById('view-home-template');
if (!root || !tpl) return;
root.replaceChildren(tpl.content.cloneNode(true));
if (!root) return;
root.innerHTML = HOME_TEMPLATE;
// 工作流卡片事件委托(随 DOM 移除而销毁,无需手动解绑)
// 工作流卡片事件委托:卡片操作按钮已全部移入详情页内嵌配置面板
// (见 wfEnginePanel.js),首页仅保留「注册第一个工作流」空态入口。
document.getElementById('workflows-list')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-wf-action]');
if (!btn) return;
const action = btn.getAttribute('data-wf-action');
const name = btn.getAttribute('data-wf-name');
if (action === 'start') handleStartWorkflow(name);
else if (action === 'stop') handleStopWorkflow(name);
else if (action === 'delete') handleDeleteWorkflow(name);
else if (action === 'view') handleViewWorkflow(name);
else if (action === 'create-first') document.getElementById('btn-create-wf')?.click();
if (action === 'create-first') document.getElementById('btn-create-wf')?.click();
});
// 节点大表事件委托
@@ -152,16 +405,13 @@ export function mountHome() {
const nodeId = btn.getAttribute('data-node-id');
if (action === 'approve') handleApprove(nodeId);
else if (action === 'reject') handleReject(nodeId);
else if (action === 'reissue') handleReissue(nodeId);
else if (action === 'disable') handleDisable(nodeId);
else if (action === 'enable') handleEnable(nodeId);
else if (action === 'manage') handleManage(nodeId);
});
// 节点面板刷新按钮(header 的全局刷新按钮仍由 main.js 管理)
document.getElementById('btn-refresh-nodes')?.addEventListener('click', (e) => {
triggerRefreshAnimation(e.currentTarget);
showToast('正在刷新节点列表...', 'info');
fetchNodes();
triggerRefreshAnimation(e.currentTarget, () => fetchNodes());
});
renderNodesTableSkeleton();
File diff suppressed because it is too large Load Diff