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:
@@ -0,0 +1,194 @@
|
||||
/* 工作流详情页 · 网格点详情滑入面板(尝试历史 + 阶段链诊断)
|
||||
*
|
||||
* 从点表「查看」按钮、Parallel Sets 点名列表 / 单点 drill-down 进入。
|
||||
* 面板 DOM 挂在 ctx.pointPanelEl(unmount 由控制器调用 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;
|
||||
}
|
||||
Reference in New Issue
Block a user