将 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 笔误
174 lines
6.6 KiB
JavaScript
174 lines
6.6 KiB
JavaScript
/* DCTS Dashboard Workflows Component */
|
||
|
||
import { escapeHtml } from '../utils/format.js';
|
||
import { ICONS } from '../utils/icons.js';
|
||
|
||
/** 工作流卡片骨架:纯展示卡(名称/描述/状态/进度)。
|
||
*
|
||
* 卡片下方的「YAML配置/启动/暂停/删除」按钮已移除(见 docs/task_engine_decoupling_design.md
|
||
* §6.1)。所有操作(启动/停止/删除/保存配置)收敛至工作流详情页顶部的内嵌阶段配置面板
|
||
* (wfEnginePanel.js)。首页卡片仅作状态概览与入口。 */
|
||
function buildCardSkeleton({ name, href, desc, statusCn, statusClass, hasProgress, pct, countsText }) {
|
||
return `
|
||
<a class="wf-card-link" href="${href}" title="进入工作流详情">
|
||
<div class="workflow-header">
|
||
<div>
|
||
<h3 class="workflow-name">${name}</h3>
|
||
<p class="workflow-desc">${desc}</p>
|
||
</div>
|
||
<span class="status-badge ${statusClass}" data-wf-badge>${statusCn}</span>
|
||
</div>
|
||
<div class="wf-card-progress${hasProgress ? '' : ' hidden'}" data-wf-progress>
|
||
<div class="progress-bar-track" aria-hidden="true">
|
||
<div class="progress-bar-fill progress-fill-emerald" data-wf-fill style="width: ${pct}%"></div>
|
||
</div>
|
||
<span class="wf-card-counts tabular-num" data-wf-counts>${countsText}</span>
|
||
</div>
|
||
</a>
|
||
`;
|
||
}
|
||
|
||
/** 原位更新卡片易变部分:状态徽章 / 进度条宽度 / 计数文本。 */
|
||
function updateCardInPlace(card, { statusCn, statusClass, hasProgress, pct, countsText }) {
|
||
// 状态徽章
|
||
const badge = card.querySelector('[data-wf-badge]');
|
||
if (badge) {
|
||
if (badge.textContent !== statusCn) badge.textContent = statusCn;
|
||
if (!badge.classList.contains(statusClass)) {
|
||
badge.className = `status-badge ${statusClass}`;
|
||
badge.setAttribute('data-wf-badge', '');
|
||
}
|
||
}
|
||
// 进度条
|
||
const progWrap = card.querySelector('[data-wf-progress]');
|
||
if (progWrap) {
|
||
const shouldShow = hasProgress;
|
||
const isHidden = progWrap.classList.contains('hidden');
|
||
if (shouldShow && isHidden) progWrap.classList.remove('hidden');
|
||
else if (!shouldShow && !isHidden) progWrap.classList.add('hidden');
|
||
if (shouldShow) {
|
||
const fill = progWrap.querySelector('[data-wf-fill]');
|
||
const wStr = `${pct}%`;
|
||
if (fill && fill.style.width !== wStr) fill.style.width = wStr;
|
||
const counts = progWrap.querySelector('[data-wf-counts]');
|
||
if (counts && counts.textContent !== countsText) counts.textContent = countsText;
|
||
}
|
||
}
|
||
}
|
||
|
||
export function renderWorkflowsSkeleton() {
|
||
const container = document.getElementById('workflows-list');
|
||
if (!container) return;
|
||
|
||
container.innerHTML = Array.from({ length: 2 }).map(() => `
|
||
<div class="workflow-card skeleton-card">
|
||
<div class="workflow-header">
|
||
<div>
|
||
<div class="skeleton-shimmer skeleton-w-140 skeleton-h-18"></div>
|
||
<div class="skeleton-shimmer skeleton-w-220 skeleton-h-14"></div>
|
||
</div>
|
||
<div class="skeleton-shimmer skeleton-badge"></div>
|
||
</div>
|
||
<div class="wf-card-progress">
|
||
<div class="skeleton-shimmer skeleton-w-full skeleton-h-8"></div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
export function renderWorkflows(workflows) {
|
||
const container = document.getElementById('workflows-list');
|
||
const wfMetric = document.getElementById('val-total-workflows');
|
||
const wfSub = document.getElementById('val-active-wf');
|
||
if (!container) return;
|
||
|
||
if (wfMetric) wfMetric.textContent = workflows ? workflows.length : 0;
|
||
|
||
if (!workflows || workflows.length === 0) {
|
||
if (wfSub) wfSub.textContent = '暂无已注册工作流';
|
||
const emptyHtml = `
|
||
<div class="empty-state-box">
|
||
${ICONS.layers({ size: 36, cls: 'empty-state-icon' })}
|
||
<p>暂无恒星大气网格工作流配置</p>
|
||
<button type="button" class="btn btn-primary btn-sm" data-wf-action="create-first">
|
||
${ICONS.plus({ size: 14 })}
|
||
注册第一个工作流
|
||
</button>
|
||
</div>
|
||
`;
|
||
if (container.innerHTML.trim() !== emptyHtml.trim()) {
|
||
container.innerHTML = emptyHtml;
|
||
}
|
||
return;
|
||
}
|
||
|
||
const runningCount = workflows.filter(w => w.status === 'running').length;
|
||
if (wfSub) {
|
||
wfSub.textContent = runningCount > 0 ? `${runningCount} 个工作流运行中` : '集群待命';
|
||
}
|
||
|
||
const existingCards = new Map();
|
||
container.querySelectorAll('.workflow-card[data-wf-card-name]').forEach(card => {
|
||
existingCards.set(card.getAttribute('data-wf-card-name'), card);
|
||
});
|
||
|
||
if (container.querySelector('.skeleton-card, .empty-cell')) {
|
||
container.innerHTML = '';
|
||
existingCards.clear();
|
||
}
|
||
|
||
const updatedCards = [];
|
||
|
||
workflows.forEach(wf => {
|
||
// 状态徽章全映射:后端可发 idle/initializing/running/paused/completed
|
||
let statusCn = '闲置';
|
||
let statusClass = 'secondary';
|
||
if (wf.status === 'running') {
|
||
statusCn = '运行中';
|
||
statusClass = 'online';
|
||
} else if (wf.status === 'completed') {
|
||
statusCn = '已完成';
|
||
statusClass = 'completed';
|
||
} else if (wf.status === 'initializing') {
|
||
statusCn = '初始化中';
|
||
statusClass = 'warning';
|
||
} else if (wf.status === 'paused') {
|
||
statusCn = '已暂停';
|
||
statusClass = 'secondary';
|
||
}
|
||
|
||
const name = escapeHtml(wf.name);
|
||
const href = `#/workflows/${encodeURIComponent(wf.name)}`;
|
||
const desc = escapeHtml(wf.description || '无描述');
|
||
|
||
// 内联进度(来自列表接口内联 stats;未启动的工作流 stats=null → 不渲染)
|
||
const s = wf.stats;
|
||
const hasProgress = s && s.total > 0;
|
||
const pct = hasProgress ? Math.min(100, Math.max(0, Math.round((s.converged / s.total) * 100))) : 0;
|
||
const countsText = hasProgress
|
||
? `${s.converged}/${s.total} 收敛 · ${s.seed_step_converged} 种子步进 · ${s.failed} 失败 · ${s.running} 运行`
|
||
: '';
|
||
|
||
let card = existingCards.get(name);
|
||
if (card) {
|
||
// 原位更新:状态徽章 / 进度条 / 计数文本——各自 diff,避免整卡 innerHTML 重建
|
||
updateCardInPlace(card, { statusCn, statusClass, hasProgress, pct, countsText });
|
||
existingCards.delete(name);
|
||
} else {
|
||
card = document.createElement('div');
|
||
card.className = 'workflow-card';
|
||
card.setAttribute('data-wf-card-name', name);
|
||
card.innerHTML = buildCardSkeleton({ name, href, desc, statusCn, statusClass, hasProgress, pct, countsText });
|
||
}
|
||
updatedCards.push(card);
|
||
});
|
||
|
||
existingCards.forEach(card => card.remove());
|
||
|
||
updatedCards.forEach((card, idx) => {
|
||
if (container.children[idx] !== card) {
|
||
container.insertBefore(card, container.children[idx] || null);
|
||
}
|
||
});
|
||
}
|