Files
DCTS/dashboard/src/views/detail/pointPanel.js
T
fmq cd370d88e7 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 笔误
2026-08-04 23:40:52 +08:00

195 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 工作流详情页 · 网格点详情滑入面板(尝试历史 + 阶段链诊断)
*
* 从点表「查看」按钮、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;
}