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
+100 -137
View File
@@ -6,7 +6,9 @@
<title>DCTS — 分布式恒星大气网格计算控制台</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
<!-- 字重精简:Inter 去掉未使用的 300,Space Grotesk 去掉未使用的 500(仅 600/700 用于标题),
减少 2 个字体文件下载。display=swap 避免 FOIT。 -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@600;700&display=swap" rel="stylesheet">
<script>
/* 首帧前落主题,避免 prefers-color-scheme 先绘制导致深/浅闪变 (FOUC) */
(function () {
@@ -20,18 +22,25 @@
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<a href="#view-root" class="skip-link">跳到主内容</a>
<div id="app">
<!-- ===== 登录遮罩(鉴权) ===== -->
<div id="login-overlay" class="login-overlay">
<div id="login-overlay" class="login-overlay" role="dialog" aria-modal="true" aria-labelledby="login-title" aria-describedby="login-subtitle">
<div class="login-card card">
<div class="login-header">
<h2>DCTS 控制台登录</h2>
<p>请输入管理员密码以访问管理控制台</p>
<h2 id="login-title">DCTS 控制台登录</h2>
<p id="login-subtitle">请输入管理员密码以访问管理控制台</p>
</div>
<form id="form-login" class="stack-col stack-col-md">
<div class="form-group">
<label for="input-admin-token">管理员密码</label>
<input type="password" id="input-admin-token" class="form-input" placeholder="输入管理员密码..." required autocomplete="current-password" />
<div class="input-with-toggle">
<input type="password" id="input-admin-token" class="form-input" placeholder="输入管理员密码..." required autocomplete="current-password" />
<button type="button" class="btn-toggle-password" aria-label="显示密码" title="显示/隐藏密码">
<svg class="icon-eye-open" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<svg class="icon-eye-closed hidden" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
</button>
</div>
</div>
<div id="login-error-msg" class="login-error-msg alert-error hidden"></div>
<button type="submit" id="btn-submit-login" class="btn btn-primary">登录</button>
@@ -111,133 +120,6 @@
<div id="view-root"></div>
</main>
<!-- 首页视图模板:router 挂载时克隆(见 src/views/home.js),ID 为渲染锚点,保持原样 -->
<template id="view-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">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/>
<line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
</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">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</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">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</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">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
<polyline points="2 17 12 22 22 17"/>
<polyline points="2 12 12 17 22 12"/>
</svg>
</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">
<thead>
<tr>
<th class="col-node-id">节点 ID</th>
<th class="col-slots">CPU 槽位</th>
<th class="col-usage">CPU/内存</th>
<th class="col-status">节点状态</th>
<th class="col-token">Token 凭据</th>
<th class="col-heartbeat">心跳时间</th>
<th class="col-actions">管理操作</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>
</template>
<!-- 创建 / 编辑工作流 模态弹窗 -->
<div id="modal-create-wf" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="modal-title">
@@ -250,8 +132,12 @@
</div>
<form id="form-create-wf" class="stack-col stack-col-md">
<div class="form-group">
<label for="input-wf-name">工作流名称 (Identifier)</label>
<input type="text" id="input-wf-name" class="form-input" placeholder="例如: sdB_cno" required />
<label for="input-wf-name">工作流名称 (Identifier) <span class="req-mark" aria-hidden="true">*</span></label>
<input type="text" id="input-wf-name" class="form-input" placeholder="例如: sdB_cno" required
pattern="[A-Za-z0-9._\-]+" maxlength="64" autocomplete="off"
aria-describedby="wf-name-hint" />
<small id="wf-name-hint" class="field-hint">仅支持字母、数字、点、下划线与连字符,最长 64 字符</small>
<small id="wf-name-error" class="field-error hidden" role="alert"></small>
</div>
<div class="form-group">
<label for="input-wf-desc">描述信息</label>
@@ -259,10 +145,11 @@
</div>
<div class="form-group">
<div class="row-wrap" style="justify-content: space-between; align-items: center;">
<label for="input-wf-yaml">YAML 配置内容 (GridConfig)</label>
<label for="input-wf-yaml">YAML 配置内容 (GridConfig) <span class="req-mark" aria-hidden="true">*</span></label>
<button type="button" id="btn-load-preset-yaml" class="btn btn-secondary btn-sm">填入默认示例</button>
</div>
<textarea id="input-wf-yaml" class="form-textarea" required placeholder="请粘贴工作流 YAML 参数网格定义..."></textarea>
<textarea id="input-wf-yaml" class="form-textarea" required placeholder="请粘贴工作流 YAML 参数网格定义..." aria-describedby="wf-yaml-hint"></textarea>
<small id="wf-yaml-hint" class="field-hint">定义 grid 参数网格与 chain 计算链,保存前服务端会校验 YAML 结构</small>
</div>
<div class="modal-footer">
<button type="button" id="cancel-create-wf" class="btn btn-secondary">取消</button>
@@ -304,6 +191,10 @@
<div class="modal-footer yaml-editor-footer">
<span id="yaml-editor-meta" class="yaml-editor-meta tabular-num"></span>
<span id="yaml-editor-lock" class="yaml-editor-lock hidden"></span>
<button type="button" id="btn-export-yaml" class="btn btn-secondary btn-sm" title="下载导出当前 YAML 配置文件">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
导出配置
</button>
<button type="button" id="btn-edit-yaml" class="btn btn-primary btn-sm">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
编辑
@@ -340,13 +231,85 @@
</div>
</div>
<!-- 节点详情与管理模态弹窗(三点菜单入口:配额调整 / 启停 / 重发 Token) -->
<div id="modal-node-manage" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="node-manage-title">
<div class="modal-card">
<div class="modal-header">
<h3 id="node-manage-title">节点详情与管理</h3>
<button id="node-manage-close-btn" class="modal-close" aria-label="关闭">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-body">
<!-- 基础信息 + 槽位展示 -->
<div id="node-manage-overview" class="node-manage-section">
<div class="form-group">
<label>节点 ID</label>
<div id="node-manage-id" class="node-manage-value mono"></div>
</div>
<div class="node-manage-grid">
<div class="form-group">
<label>节点状态</label>
<div id="node-manage-status"></div>
</div>
<div class="form-group">
<label>CPU 槽位 (占用/配额/最大)</label>
<div id="node-manage-slots" class="node-manage-value tabular-num"></div>
</div>
<div class="form-group">
<label>CPU / 内存</label>
<div id="node-manage-usage" class="node-manage-value tabular-num"></div>
</div>
<div class="form-group">
<label>心跳时间</label>
<div id="node-manage-heartbeat" class="node-manage-value"></div>
</div>
</div>
</div>
<!-- 修改配额 (CPU 核数) -->
<div class="node-manage-section">
<h4 class="node-manage-section-title">并发槽位配额</h4>
<p class="text-hint-mb">限制该节点最大并发领用的任务数(CPU 核数)。正在运行的任务不会被中断,等其结束后自然回落到新配额以下;设为 0 可暂停接收新任务,留空清除限制恢复物理上限。</p>
<div class="row-wrap" style="gap: 0.5rem; align-items: flex-end;">
<div class="form-group" style="flex: 1; min-width: 120px;">
<label for="node-manage-quota-input">配额上限</label>
<input type="number" id="node-manage-quota-input" class="form-input" min="0" step="1" placeholder="留空 = 无限制" />
</div>
<button type="button" id="btn-node-save-quota" class="btn btn-primary btn-sm">保存配额</button>
<button type="button" id="btn-node-clear-quota" class="btn btn-secondary btn-sm">清除配额</button>
</div>
</div>
<!-- 节点调度控制 -->
<div class="node-manage-section">
<h4 class="node-manage-section-title">调度控制</h4>
<div class="row-wrap" style="gap: 0.5rem;">
<button type="button" id="btn-node-disable" class="btn btn-secondary btn-sm">停用节点(暂停分发)</button>
<button type="button" id="btn-node-enable" class="btn btn-primary btn-sm">启用节点(恢复分发)</button>
</div>
</div>
<!-- 安全管理 -->
<div class="node-manage-section">
<h4 class="node-manage-section-title">安全管理</h4>
<p class="text-hint-mb">重发后旧 Token 立即失效,该节点下次心跳/领用将 401 退出。请随后把新 Token 同步到节点。</p>
<button type="button" id="btn-node-reissue" class="btn btn-secondary btn-sm">重新颁发专属 Token</button>
</div>
</div>
<div class="modal-footer">
<button type="button" id="btn-close-node-manage" class="btn btn-secondary">关闭</button>
</div>
</div>
</div>
<!-- 页脚 -->
<footer class="footer">
<p>DCTS 分布式恒星大气计算系统 &copy; 2026 TLUSTY/SYNSPEC Project</p>
</footer>
<!-- ===== 全局 Toast 提示容器 ===== -->
<div id="toast-container" class="toast-container"></div>
<div id="toast-container" class="toast-container" aria-live="polite" aria-atomic="true"></div>
<!-- ===== 通用自定义 Confirm 模态弹窗 ===== -->
<div id="modal-confirm" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title" aria-describedby="confirm-modal-msg">
+2 -1
View File
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "node --test \"test/*.test.js\""
},
"devDependencies": {
"vite": "^5.4.0"
+100 -16
View File
@@ -1,13 +1,91 @@
/* DCTS Dashboard API & Authentication Module */
/* DCTS Dashboard API & Authentication Module
*
* 集中所有后端调用与鉴权逻辑。类型契约以 JSDoc 标注,供 IDE 提示与重构参考
* (项目保持原生 ESM,不引入 TypeScript)。
*
* 安全:token 存储在 sessionStorage 而非 localStorage。
* sessionStorage 生命周期仅限当前标签页会话(关闭即失效),缩小了 XSS 窃取后的利用窗口;
* localStorage 会跨标签页/重启长期持久化,一旦泄露(配合 CSP 的 'unsafe-inline')即等于
* 长期账号接管。服务端 session 仍有 24h 过期 + 容量淘汰,前端不再持有永久凭据。
*/
import { showToast } from './components/toast.js';
// 安全:token 存储在 sessionStorage 而非 localStorage。
// sessionStorage 生命周期仅限当前标签页会话(关闭即失效),缩小了 XSS 窃取后的利用窗口;
// localStorage 会跨标签页/重启长期持久化,一旦泄露(配合 CSP 的 'unsafe-inline')即等于
// 长期账号接管。服务端 session 仍有 24h 过期 + 容量淘汰,前端不再持有永久凭据。
// escapeHtml 的权威定义已迁至 utils/format.js;此处 re-export 保持向后兼容
// (nodesTable/workflows 等历史 import 自 api.js,改动成本高于收益)。
export { escapeHtml } from './utils/format.js';
const TOKEN_KEY = 'dcts_admin_token';
/**
* @typedef {Object} ApiResponse 通用响应包装
* @property {boolean} success
* @property {string} [message] 失败时的错误描述
* @property {*} [data] 业务数据
*/
/**
* @typedef {Object} ClusterStatus GET /api/status 返回的集群总体状态
* @property {number} [nodes_online]
* @property {number} [active_nodes_count]
* @property {number} [total_active_slots]
* @property {number} [occupied_slots]
* @property {number} [total_max_slots]
* @property {Array} [nodes]
* @property {object} [grid_stats] - { pending, queued, running, converged, failed }
* @property {number} [pending_points]
* @property {number} [running_points]
* @property {number} [converged_points]
* @property {number} [failed_points]
*/
/**
* @typedef {Object} NodeInfo GET /api/admin/nodes 列表项
* @property {string} node_id
* @property {string} [id] - node_id 的别名(后端历史字段)
* @property {string} status - online/active/disabled/pending_approval/offline
* @property {string} token_status - active/inactive
* @property {number} active_slots
* @property {number} max_slots
* @property {number} cpu_usage
* @property {number} memory_usage
* @property {string} last_heartbeat
* @property {number|null} admin_max_slots - 管理员强制并发槽位上限(null=无限制)
*/
/**
* @typedef {Object} Workflow GET /api/workflows 列表项
* @property {string} name
* @property {string} [description]
* @property {string} status - idle/initializing/running/paused/completed
* @property {string} [config_yaml]
* @property {WorkflowStats} [stats] 列表接口内联的统计概要
*/
/**
* @typedef {Object} WorkflowStats GET /api/workflows/:name/stats
* @property {string} status
* @property {number} total
* @property {number} pending
* @property {number} queued
* @property {number} running
* @property {number} converged
* @property {number} failed
* @property {number} [eta_sec]
* @property {Array<{label:string,count:number}>} [waves]
*/
/**
* @typedef {Object} WorkflowPoint GET /api/workflows/:name/points 列表项
* @property {string} name
* @property {string} status
* @property {string} [method]
* @property {number} [relc]
* @property {number} [runtime_sec]
* @property {number} [attempts]
* @property {string} [last_completed_at]
*/
export function getAdminToken() {
try {
return sessionStorage.getItem(TOKEN_KEY);
@@ -32,17 +110,6 @@ export function clearAdminToken() {
}
}
// 安全 HTML 转义,防止存储型 XSS
export function escapeHtml(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// 401 处理去重:并发请求(如详情页 Promise.allSettled 三路拉取)可能各自命中 401 并
// 各触发一次 location.reload,竞态下不规范。用一个模块级 guard 保证只处理一次。
let handling401 = false;
@@ -132,24 +199,28 @@ export async function logoutAdmin() {
clearAdminToken();
}
/** 获取集群总体状态(在线节点、槽位占用、网格点统计)。 */
export async function fetchClusterStatus() {
const res = await apiFetch('/api/status');
if (!res.ok) throw new Error(`HTTP 错误代码: ${res.status}`);
return res.json();
}
/** 获取工作流列表(含内联 stats 概要)。 */
export async function fetchWorkflowsList() {
const res = await apiFetch('/api/workflows');
if (!res.ok) throw new Error(`HTTP 错误代码: ${res.status}`);
return res.json();
}
/** 获取计算节点列表(管理员视角,含 token_status/cpu_usage 等)。 */
export async function fetchNodesList() {
const res = await apiFetch('/api/admin/nodes');
if (!res.ok) throw new Error(`HTTP 错误代码: ${res.status}`);
return res.json();
}
/** 创建工作流。 */
export async function createWorkflow(workflowData) {
return apiFetch('/api/workflows', {
method: 'POST',
@@ -235,3 +306,16 @@ export async function disableNodeApi(nodeId) {
export async function enableNodeApi(nodeId) {
return apiFetch(`/api/admin/nodes/${encodeURIComponent(nodeId)}/enable`, { method: 'POST' });
}
/**
* 设置节点并发槽位配额(动态调整 CPU 核数)。
* @param {string} nodeId
* @param {number|null} adminMaxSlots - null=清除限制(恢复物理 max_slots)number=配额上限(0=暂停接新任务)
*/
export async function setNodeQuotaApi(nodeId, adminMaxSlots) {
return apiFetch(`/api/admin/nodes/${encodeURIComponent(nodeId)}/quota`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ admin_max_slots: adminMaxSlots }),
});
}
+36
View File
@@ -22,6 +22,10 @@ export function setupFocusTrap(modalEl) {
if (closeBtn && typeof closeBtn.click === 'function') {
e.preventDefault();
closeBtn.click();
// 阻止 Escape 冒泡到 document 级全局关闭处理器(审查修复 M2):
// 否则 confirm 堆叠在 node-manage 之上时,一次 Escape 会「取消确认」+
// 「顺带关闭下层管理弹窗」。本 trap 已处理该 Modal 的 Escape,全局兜底无需再触发。
e.stopPropagation();
return;
}
}
@@ -66,6 +70,38 @@ export function releaseFocusTrap(modalEl) {
}
}
/**
* 带退出动画的模态关闭(state-transition / exit-faster-than-enter):
* 先加 .modal-closing 触发 CSS 退出动画(遮罩淡出 + 卡片下滑,150ms),
* 动画结束后(animationend 或兜底超时)才真正隐藏并释放焦点陷阱。
* prefers-reduced-motion 下跳过动画直接隐藏。重复调用安全(hidden 守卫)。
*/
export function hideModal(modalEl) {
if (!modalEl || modalEl.classList.contains('hidden')) return;
const finish = () => {
if (modalEl.classList.contains('hidden')) return; // 已关闭(防 animationend 与超时双触发)
modalEl.classList.remove('modal-closing');
modalEl.classList.add('hidden');
releaseFocusTrap(modalEl);
};
const reduce =
window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
finish();
return;
}
modalEl.classList.add('modal-closing');
const card = modalEl.querySelector('.modal-card, .login-card');
if (card) {
card.addEventListener('animationend', finish, { once: true });
}
// 兜底:animationend 未触发(如卡片不存在或动画被覆盖)时仍能关闭。
setTimeout(finish, 220);
}
export function setupModalDismiss(modalEl, onClose) {
if (!modalEl) return;
+19 -33
View File
@@ -1,6 +1,7 @@
/* DCTS Dashboard Nodes Table Component (增量 DOM 更新与 Skeleton) */
import { escapeHtml } from '../api.js';
import { escapeHtml, fmtClock } from '../utils/format.js';
import { ICONS } from '../utils/icons.js';
export function renderNodesTableSkeleton() {
const tbody = document.getElementById('nodes-table-body');
@@ -21,16 +22,10 @@ export function renderNodesTableSkeleton() {
tbody.innerHTML = skeletonRows;
}
// 心跳时间容错:字段缺失或非法时间戳一律显示占位符,避免渲染出 "Invalid Date"
function formatHeartbeat(ts) {
if (!ts) return '—';
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleTimeString('zh-CN');
}
// 行重建签名:仅状态/凭据决定徽章与操作按钮。槽位、负载、心跳是秒级高频字段,
// 行重建签名:状态/凭据决定徽章与操作按钮。槽位占用、负载、心跳是秒级高频字段,
// 走 textContent 原位刷新——整行 innerHTML 重建会销毁用户正悬停/聚焦的按钮。
// 注:表格槽位列按设计文档 §3.3.1 保持「占用 / 最大」两段式(不展示配额);
// 配额的三段式展示(占用/配额/最大)只在节点详情管理 Modal 内呈现(§3.3.3)。
function rowSignature(node) {
return `${node.status || ''}|${node.token_status || ''}`;
}
@@ -39,7 +34,7 @@ function volatileValues(node) {
return {
slots: `${Number(node.active_slots || 0)} / ${Number(node.max_slots || 4)}`,
usage: `${Number(node.cpu_usage || 0).toFixed(1)}% / ${Number(node.memory_usage || 0).toFixed(1)}%`,
heartbeat: formatHeartbeat(node.last_heartbeat),
heartbeat: fmtClock(node.last_heartbeat),
};
}
@@ -60,7 +55,7 @@ function buildRowHtml(node, nodeId) {
let statusBadge;
if (isPending) {
statusBadge = '<span class="status-badge warning"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="badge-icon-space"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>待审批</span>';
statusBadge = `<span class="status-badge warning">${ICONS.clock({ size: 11, cls: 'badge-icon-space' })}待审批</span>`;
} else if (isDisabled) {
statusBadge = '<span class="status-badge secondary">已停用</span>';
} else if (isOnline) {
@@ -83,29 +78,20 @@ function buildRowHtml(node, nodeId) {
actionBtns = `
<div class="action-cell-wrap">
<button class="btn-action-icon btn-action-approve" data-node-action="approve" data-node-id="${nodeId}" title="同意节点接入申请" aria-label="同意节点接入申请">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
${ICONS.check({ size: 14 })}
</button>
<button class="btn-action-icon btn-action-danger" data-node-action="reject" data-node-id="${nodeId}" title="拒绝节点接入申请" aria-label="拒绝节点接入申请">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
${ICONS.x({ size: 14, sw: 2.5 })}
</button>
</div>
`;
} else {
// 启停切换:disabled 时显示「启用」(绿 ▶),否则显示「停用」(琥珀 ⏸)。
// 停用用琥珀+暂停符号,表达"可恢复的软暂停"——节点保持存活待命,与「重发 Token」
// (蓝 🔄,轮换凭据、旧 token 立即失效)是两类不同运维操作,颜色与图标刻意区分。
const toggleBtn = isDisabled
? `<button class="btn-action-icon btn-action-approve" data-node-action="enable" data-node-id="${nodeId}" title="重新启用节点(恢复分发任务)" aria-label="重新启用节点">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg>
</button>`
: `<button class="btn-action-icon btn-action-warn" data-node-action="disable" data-node-id="${nodeId}" title="停用节点(不再分发任务,保持空闲待命)" aria-label="停用节点">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></svg>
</button>`;
// 已审批节点:收敛至统一「三点菜单」入口,点击弹出节点详情管理 Modal
// (配额调整 / 启用·停用 / 重发 Token)。设计依据见 docs/dynamic_cpu_slots_design.md §3.3。
actionBtns = `
<div class="action-cell-wrap">
${toggleBtn}
<button class="btn-action-icon btn-action-reissue" data-node-action="reissue" data-node-id="${nodeId}" title="重新颁发专属 Token(旧 Token 失效)" aria-label="重新颁发专属 Token">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>
<button class="btn-action-icon btn-action-manage" data-node-action="manage" data-node-id="${nodeId}" title="节点详情与管理(配额 / 启停 / 重发 Token" aria-label="节点详情与管理">
${ICONS.moreVertical({ size: 18, fill: true })}
</button>
</div>
`;
@@ -115,12 +101,12 @@ function buildRowHtml(node, nodeId) {
return `
<td class="col-node-id node-id" title="${nodeId}">${nodeId}</td>
<td class="col-slots tabular-num">${v.slots}</td>
<td class="col-usage tabular-num">${v.usage}</td>
<td class="col-status">${statusBadge}</td>
<td class="col-token">${tokenBadge}</td>
<td class="col-heartbeat tabular-num">${v.heartbeat}</td>
<td class="col-actions">${actionBtns}</td>
<td class="col-slots tabular-num" data-label="CPU 槽位">${v.slots}</td>
<td class="col-usage tabular-num" data-label="CPU/内存">${v.usage}</td>
<td class="col-status" data-label="节点状态">${statusBadge}</td>
<td class="col-token" data-label="Token 凭据">${tokenBadge}</td>
<td class="col-heartbeat tabular-num" data-label="心跳时间">${v.heartbeat}</td>
<td class="col-actions" data-label="管理操作">${actionBtns}</td>
`;
}
+21 -3
View File
@@ -1,9 +1,11 @@
/* DCTS Dashboard Toast Component */
import { ICONS } from '../utils/icons.js';
const TOAST_ICONS = {
success: `<svg class="toast-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>`,
error: `<svg class="toast-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>`,
info: `<svg class="toast-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`
success: ICONS.checkCircle({ cls: 'toast-icon', sw: 2.5 }),
error: ICONS.alert({ cls: 'toast-icon', sw: 2.5 }),
info: ICONS.info({ cls: 'toast-icon', sw: 2.5 }),
};
export function showToast(message, type = 'info') {
@@ -29,11 +31,27 @@ export function showToast(message, type = 'info') {
textEl.textContent = message;
toast.appendChild(textEl);
// 关闭按钮:键盘可达(Tab 聚焦 + Enter/Space 触发),aria-label 供读屏播报。
// 此前仅支持点击关闭,键盘/读屏用户无法主动消除(尤其 role=alert 的错误提示)。
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'toast-close';
closeBtn.setAttribute('aria-label', '关闭提示');
closeBtn.innerHTML = ICONS.x({ size: 14, sw: 2.2 });
toast.appendChild(closeBtn);
let dismissed = false;
const dismiss = () => {
if (dismissed) return;
dismissed = true;
toast.classList.add('toast-fadeOut');
setTimeout(() => toast.remove(), 250);
};
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
dismiss();
});
toast.addEventListener('click', dismiss);
container.appendChild(toast);
-78
View File
@@ -1,78 +0,0 @@
/* DCTS 工作流交互处理器(首页卡片与工作流详情页共用)
*
* 统一模式:showConfirm 二次确认 → 调用 API → toast 反馈 → fetchAllData 立即刷新。
* 详情页调用方可传回调:afterRefresh(启动/暂停后刷新本页统计)、
* afterDelete(删除成功后返回上一视图)。
*/
import {
startWorkflowApi,
stopWorkflowApi,
deleteWorkflowApi
} from '../api.js';
import { showToast } from './toast.js';
import { showConfirm } from './modal.js';
import { fetchAllData } from '../state.js';
import { openYamlEditor } from './yamlEditor.js';
export async function handleStartWorkflow(name, afterRefresh = null) {
const ok = await showConfirm('启动计算网格', `确定要启动工作流 '${name}' 展开网格计算任务吗?`);
if (!ok) return;
try {
const res = await startWorkflowApi(name);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '工作流已成功启动', 'success');
fetchAllData();
if (afterRefresh) afterRefresh();
} else {
showToast(json.message || '启动工作流失败', 'error');
}
} catch (err) {
showToast(`启动失败: ${err.message}`, 'error');
}
}
export async function handleStopWorkflow(name, afterRefresh = null) {
const ok = await showConfirm('暂停工作流', `确定要暂停工作流 '${name}' 的后续任务派发吗?`);
if (!ok) return;
try {
const res = await stopWorkflowApi(name);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '工作流已暂停', 'info');
fetchAllData();
if (afterRefresh) afterRefresh();
} else {
showToast(json.message || '暂停失败', 'error');
}
} catch (err) {
showToast(`暂停失败: ${err.message}`, 'error');
}
}
export async function handleDeleteWorkflow(name, afterDelete = null) {
const ok = await showConfirm('删除工作流', `确定要彻底删除工作流 '${name}' 及其关联的网格点数据吗?此操作无法撤销。`);
if (!ok) return;
try {
const res = await deleteWorkflowApi(name);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '工作流删除成功', 'success');
fetchAllData();
if (afterDelete) afterDelete();
} else {
showToast(json.message || '删除失败', 'error');
}
} catch (err) {
showToast(`删除失败: ${err.message}`, 'error');
}
}
/** 查看/编辑工作流 YAML:统一入口,双模式编辑器见 yamlEditor.js。 */
export async function handleViewWorkflow(name) {
await openYamlEditor(name);
}
+585
View File
@@ -0,0 +1,585 @@
/* DCTS 工作流执行引擎内嵌配置面板(见 docs/task_engine_decoupling_design.md §6.2
*
* 替代旧版卡片下方「YAML配置/启动/暂停/删除」按钮组与 YAML 编辑模态的工作流操作入口。
* 内嵌在工作流详情页顶部,直接展示 TLUSTY / SYNSPEC 双阶段的正交三维配置:
* - enabled(启用开关)
* - policy(执行策略:skip_converged / force_recompute / skip_failed
* - strategies(策略链队列:可增删、上下排序)
*
* 数据流:
* 读:GET /api/workflows/:name → config_yaml(正则提取 tlusty/synspec_stage 块)
* 写:把编辑后的 tlusty/synspec_stage 块写回 config_yaml → PUT /api/workflows/:name
* 操作:启动/停止/删除复用 api.js 既有端点。
*
* 安全:所有用户输入与来自服务端的 YAML 片段一律经 escapeHtml 注入;策略名走白名单
* 校验后才能进入配置,杜绝任意字符串拼入 YAML。
*/
import {
getWorkflowDetailApi,
updateWorkflowApi,
startWorkflowApi,
stopWorkflowApi,
deleteWorkflowApi,
} from '../api.js';
import { showToast } from './toast.js';
import { showConfirm } from './modal.js';
// 完整 YAML 查看/编辑弹窗(yamlEditor.js,独立于本面板的结构化阶段编辑)。
import { openYamlEditor } from './yamlEditor.js';
import { escapeHtml } from '../utils/format.js';
import { friendlyError } from '../utils/errors.js';
import { ICONS } from '../utils/icons.js';
import { fetchAllData } from '../state.js';
// YAML 阶段块解析/序列化纯函数(无浏览器依赖,可单测覆盖)。
import {
defaultStage,
extractTopBlock,
parseStageFromYaml,
resolveTlustyFromYaml,
serializeStageBlock,
applyStageBlocks,
validateStageConfigs,
} from '../utils/yamlStage.js';
// 策略白名单:仅这些值可进入策略链(防注入 / 防拼写错误)。
const TLUSTY_STRATEGIES = [
{ value: 'cold_run', label: '冷启动 (Cold Run)' },
{ value: 'seed_step', label: '种子步进 (Seed Step)' },
];
const SYNSPEC_STRATEGIES = [
{ value: 'standard', label: '标准 (Standard)' },
];
const POLICIES = [
{ value: 'skip_converged', label: '增量(跳过收敛·重试失败)' },
{ value: 'force_recompute', label: '强制重算(全量重置)' },
{ value: 'skip_failed', label: '跳过收敛及失败' },
];
// 面板状态(每次挂载新建,卸载即丢弃)。
const panelState = {
name: '',
status: 'idle',
description: null, // 工作流描述(回显保存,避免 null 覆盖既有描述)
configYaml: '', // 原始 YAML(未经编辑)
tlusty: defaultStage('tlusty'),
synspec: defaultStage('synspec'),
wired: false,
dirty: false,
collapsed: true, // 默认折叠成一行(标题+操作按钮),点「配置 ▾」展开阶段卡
};
// ===== YAML 阶段块的解析/序列化见 utils/yamlStage.js(纯函数,可单测覆盖) =====
// 此处仅保留渲染层逻辑。
// ===== 渲染 =====
function policyOptions(selected) {
return POLICIES.map(p =>
`<option value="${p.value}"${p.value === selected ? ' selected' : ''}>${escapeHtml(p.label)}</option>`
).join('');
}
function strategyOptions(kind, selected) {
const opts = kind === 'tlusty' ? TLUSTY_STRATEGIES : SYNSPEC_STRATEGIES;
return opts.map(o =>
`<option value="${o.value}"${o.value === selected ? ' selected' : ''}>${escapeHtml(o.label)}</option>`
).join('');
}
/** 单个阶段卡片(TLUSTY 或 SYNSPEC)。 */
function stageCardHtml(kind, label, stage) {
const idPrefix = kind === 'tlusty' ? 'tlusty' : 'synspec';
const stratRows = stage.strategies.map((s, i) => {
const opts = strategyOptions(kind, s);
return `
<li class="strat-row" data-strat-idx="${i}" data-strat-kind="${kind}">
<span class="strat-idx">${i + 1}.</span>
<select class="strat-select" data-engine-field="strat" data-strat-kind="${kind}" data-strat-idx="${i}">
${opts}
</select>
<button type="button" class="btn-icon strat-up" data-engine-field="strat-up" data-strat-kind="${kind}" data-strat-idx="${i}" title="上移" ${i === 0 ? 'disabled' : ''}>↑</button>
<button type="button" class="btn-icon strat-down" data-engine-field="strat-down" data-strat-kind="${kind}" data-strat-idx="${i}" title="下移" ${i === stage.strategies.length - 1 ? 'disabled' : ''}>↓</button>
<button type="button" class="btn-icon btn-icon-danger strat-remove" data-engine-field="strat-remove" data-strat-kind="${kind}" data-strat-idx="${i}" title="移除">✕</button>
</li>`;
}).join('');
return `
<div class="engine-stage-card${stage.enabled ? '' : ' stage-disabled'}" data-stage-kind="${kind}">
<label class="stage-enable-toggle">
<input type="checkbox" data-engine-field="enabled" data-strat-kind="${idPrefix}" ${stage.enabled ? 'checked' : ''}>
<span class="stage-title">${label}</span>
</label>
<div class="stage-body${stage.enabled ? '' : ' hidden'}">
<div class="stage-row">
<label class="stage-field-label">执行策略</label>
<select class="stage-policy-select" data-engine-field="policy" data-strat-kind="${idPrefix}">
${policyOptions(stage.policy)}
</select>
</div>
<div class="stage-row">
<label class="stage-field-label">策略链(按回退优先级排序)</label>
<ul class="strategy-chain" data-strat-list="${idPrefix}">
${stratRows || '<li class="strat-empty">策略链为空(将无法执行该阶段)</li>'}
</ul>
<button type="button" class="btn btn-secondary btn-xs" data-engine-field="strat-add" data-strat-kind="${idPrefix}">
${ICONS.plus({ size: 11 })} 添加回退策略
</button>
</div>
${kind === 'synspec'
? '<p class="text-hint engine-stage-note">光谱数值参数(波长范围、展宽等)在 YAML 中配置,请用「导出 YAML」编辑后重新导入。</p>'
: ''}
</div>
</div>
`;
}
/** 折叠切换按钮文案:折叠+空闲 →「启动配置 ▾」(展开核对后启动);折叠+运行中 →「配置 ▾」;展开 →「配置 ▴」。 */
function toggleBtnLabel() {
if (!panelState.collapsed) return '配置 ▴';
const isRunning = panelState.status === 'running' || panelState.status === 'initializing';
return isRunning ? '配置 ▾' : '启动配置 ▾';
}
/** 折叠切换按钮悬浮提示。 */
function toggleBtnTitle() {
if (!panelState.collapsed) return '收起阶段配置';
const isRunning = panelState.status === 'running' || panelState.status === 'initializing';
return isRunning ? '展开阶段配置(运行中不可启动)' : '展开阶段配置,核对 TLUSTY/SYNSPEC 设置后启动工作流';
}
/** 整个引擎面板骨架(含操作按钮)。 */
function panelHtml() {
const isRunning = panelState.status === 'running' || panelState.status === 'initializing';
const startDisabled = isRunning;
const stopDisabled = !isRunning;
return `
<section class="wf-engine-panel card">
<div class="wf-engine-panel-header">
<h2 class="wf-engine-title">执行引擎配置 · ${escapeHtml(panelState.name)}</h2>
<span class="wf-engine-dirty hidden" id="wf-engine-dirty">未保存</span>
</div>
<div class="wf-engine-stages${panelState.collapsed ? ' hidden' : ''}">
${stageCardHtml('tlusty', 'TLUSTY 大气结构计算', panelState.tlusty)}
${stageCardHtml('synspec', 'SYNSPEC 光谱合成', panelState.synspec)}
</div>
<div class="wf-engine-actions">
<button type="button" class="btn btn-secondary btn-sm" data-engine-action="yaml" title="查看 / 编辑完整 YAML 配置">
YAML 配置
</button>
<button type="button" class="btn btn-secondary btn-sm" data-engine-action="export" title="导出完整 YAML">
导出 YAML
</button>
<span class="wf-engine-actions-spacer"></span>
<button type="button" class="btn btn-primary btn-sm${panelState.collapsed ? ' hidden' : ''}" data-engine-action="start" ${startDisabled ? 'disabled' : ''}>
${ICONS.start({ size: 12 })} 启动
</button>
<button type="button" class="btn btn-secondary btn-sm${panelState.collapsed && !isRunning ? ' hidden' : ''}" data-engine-action="stop" ${stopDisabled ? 'disabled' : ''}>
${ICONS.stop({ size: 12 })} 暂停
</button>
<button type="button" class="btn btn-danger btn-sm" data-engine-action="delete">
${ICONS.trash({ size: 12 })} 删除
</button>
<button type="button" class="btn btn-primary btn-sm${panelState.collapsed && !panelState.dirty ? ' hidden' : ''}" data-engine-action="save">
保存配置
</button>
<button type="button" class="btn btn-secondary btn-sm" data-engine-action="toggle-config"
title="${toggleBtnTitle()}">
${toggleBtnLabel()}
</button>
</div>
<div class="wf-engine-error hidden" id="wf-engine-error" role="alert"></div>
</section>
`;
}
// ===== 状态读写 =====
function setDirty(dirty) {
panelState.dirty = dirty;
document.getElementById('wf-engine-dirty')?.classList.toggle('hidden', !dirty);
// 折叠态下保存按钮可见性随 dirty 联动(有未保存修改时折叠也能直接保存)。
syncActionVisibility(document.querySelector('.wf-engine-panel'));
}
function showError(msg) {
const el = document.getElementById('wf-engine-error');
if (!el) return;
el.textContent = msg;
el.classList.toggle('hidden', !msg);
}
/** 重新渲染某阶段的策略链列表(增删/排序后调用)。 */
function rerenderStrategyList(kind) {
const stage = kind === 'tlusty' ? panelState.tlusty : panelState.synspec;
const idPrefix = kind === 'tlusty' ? 'tlusty' : 'synspec';
const ul = document.querySelector(`[data-strat-list="${idPrefix}"]`);
if (!ul) return;
ul.innerHTML = stage.strategies.map((s, i) => {
const opts = strategyOptions(kind, s);
return `
<li class="strat-row" data-strat-idx="${i}" data-strat-kind="${kind}">
<span class="strat-idx">${i + 1}.</span>
<select class="strat-select" data-engine-field="strat" data-strat-kind="${kind}" data-strat-idx="${i}">
${opts}
</select>
<button type="button" class="btn-icon strat-up" data-engine-field="strat-up" data-strat-kind="${kind}" data-strat-idx="${i}" title="上移" ${i === 0 ? 'disabled' : ''}>↑</button>
<button type="button" class="btn-icon strat-down" data-engine-field="strat-down" data-strat-kind="${kind}" data-strat-idx="${i}" title="下移" ${i === stage.strategies.length - 1 ? 'disabled' : ''}>↓</button>
<button type="button" class="btn-icon btn-icon-danger strat-remove" data-engine-field="strat-remove" data-strat-kind="${kind}" data-strat-idx="${i}" title="移除">✕</button>
</li>`;
}).join('') || '<li class="strat-empty">策略链为空(将无法执行该阶段)</li>';
setDirty(true);
}
// ===== 事件处理 =====
function handleFieldChange(e) {
const t = e.target;
const field = t.getAttribute('data-engine-field');
const kindRaw = t.getAttribute('data-strat-kind');
// strat-kind 在 enabled/policy 用 tlusty/synspec;在 strat* 用 tlusty/synspec。
const kind = kindRaw === 'tlusty' ? 'tlusty' : 'synspec';
const stage = kind === 'tlusty' ? panelState.tlusty : panelState.synspec;
if (field === 'enabled') {
stage.enabled = t.checked;
// 切换 stage-body 显隐 + 卡片 disabled 样式。
const card = t.closest('.engine-stage-card');
card?.classList.toggle('stage-disabled', !stage.enabled);
card?.querySelector('.stage-body')?.classList.toggle('hidden', !stage.enabled);
setDirty(true);
} else if (field === 'policy') {
stage.policy = t.value;
setDirty(true);
} else if (field === 'strat') {
const idx = parseInt(t.getAttribute('data-strat-idx'), 10);
stage.strategies[idx] = t.value;
setDirty(true);
}
}
function handleClick(e) {
const btn = e.target.closest('[data-engine-field], [data-engine-action]');
if (!btn) return;
const field = btn.getAttribute('data-engine-field');
const action = btn.getAttribute('data-engine-action');
if (field === 'strat-add') {
const kind = btn.getAttribute('data-strat-kind') === 'tlusty' ? 'tlusty' : 'synspec';
const stage = kind === 'tlusty' ? panelState.tlusty : panelState.synspec;
const opts = kind === 'tlusty' ? TLUSTY_STRATEGIES : SYNSPEC_STRATEGIES;
// 默认添加白名单首项(若已含则添加下一项)。
const next = opts.find(o => !stage.strategies.includes(o.value)) || opts[0];
stage.strategies.push(next.value);
rerenderStrategyList(kind);
return;
}
if (field && field.startsWith('strat-')) {
const kind = btn.getAttribute('data-strat-kind') === 'tlusty' ? 'tlusty' : 'synspec';
const stage = kind === 'tlusty' ? panelState.tlusty : panelState.synspec;
const idx = parseInt(btn.getAttribute('data-strat-idx'), 10);
if (field === 'strat-remove') {
stage.strategies.splice(idx, 1);
rerenderStrategyList(kind);
} else if (field === 'strat-up' && idx > 0) {
[stage.strategies[idx - 1], stage.strategies[idx]] = [stage.strategies[idx], stage.strategies[idx - 1]];
rerenderStrategyList(kind);
} else if (field === 'strat-down' && idx < stage.strategies.length - 1) {
[stage.strategies[idx + 1], stage.strategies[idx]] = [stage.strategies[idx], stage.strategies[idx + 1]];
rerenderStrategyList(kind);
}
return;
}
if (action === 'save') {
saveConfig();
} else if (action === 'start') {
doStart();
} else if (action === 'stop') {
doStop();
} else if (action === 'delete') {
doDelete();
} else if (action === 'toggle-config') {
// 折叠/展开:仅切 DOM 显隐,不重渲染(保留编辑中状态)。
panelState.collapsed = !panelState.collapsed;
const panelEl = btn.closest('.wf-engine-panel');
panelEl?.querySelector('.wf-engine-stages')?.classList.toggle('hidden', panelState.collapsed);
syncActionVisibility(panelEl);
btn.textContent = toggleBtnLabel();
btn.title = toggleBtnTitle();
return;
} else if (action === 'yaml') {
// 完整 YAML 查看/编辑弹窗(独立于结构化阶段编辑,二者保存互不感知,
// 弹窗保存后由详情页轮询刷新本面板的 configYaml)。
// 传入 onSaved 回调:YAML 编辑器保存成功后立即重拉并重渲染面板(2026-08-04 修复,
// 否则面板停留在挂载时的旧配置)。
openYamlEditor(panelState.name, refreshEnginePanel);
} else if (action === 'export') {
exportYaml();
}
}
// ===== 操作动作 =====
async function saveConfig() {
if (!panelState.dirty) {
showToast('配置未变更', 'info');
return;
}
// 阶段配置合法性校验(与服务端 save_workflow 同口径,修复审查 #4/#5):
// 双阶段全关、或启用阶段空策略链 → 拦截保存,避免保存出必然失败的任务配置。
const validationErr = validateStageConfigs(panelState.tlusty, panelState.synspec);
if (validationErr) {
showError(validationErr);
return;
}
const newYaml = applyStageBlocks(panelState.configYaml, panelState.tlusty, panelState.synspec);
showError('');
try {
// 回显既有描述(修复审查 #6:旧实现硬编码 description:null 会清空工作流描述)。
const res = await updateWorkflowApi(panelState.name, {
name: panelState.name,
description: panelState.description,
config_yaml: newYaml,
});
const json = await res.json().catch(() => ({}));
if (res.ok && json.success) {
panelState.configYaml = newYaml;
setDirty(false);
showToast(`工作流 '${panelState.name}' 阶段配置已保存`, 'success');
} else {
showError(json.message || `保存失败(HTTP ${res.status}`);
}
} catch (err) {
showError(`请求异常:${friendlyError(err, '保存失败')}`);
}
}
async function doStart() {
// 启动前若有未保存改动,提示先保存。
if (panelState.dirty) {
const ok = await showConfirm('启动前保存', '当前阶段配置有未保存的修改,启动将使用上一次保存的配置。是否先保存?');
if (ok) {
await saveConfig();
if (panelState.dirty) return; // 保存失败则中止
}
}
const ok = await showConfirm('启动计算网格', `确定要启动工作流 '${panelState.name}' 展开网格计算任务吗?`);
if (!ok) return;
try {
const res = await startWorkflowApi(panelState.name);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '工作流已成功启动', 'success');
refreshEnginePanel();
} else {
showError(json.message || '启动失败:请确认有在线计算节点且配置有效');
}
} catch (err) {
showError(`启动失败:${friendlyError(err, '启动失败')}`);
}
}
async function doStop() {
const ok = await showConfirm('暂停工作流', `确定要暂停工作流 '${panelState.name}' 的后续任务派发吗?`);
if (!ok) return;
try {
const res = await stopWorkflowApi(panelState.name);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '工作流已暂停', 'info');
refreshEnginePanel();
} else {
showError(json.message || '暂停失败:请稍后重试');
}
} catch (err) {
showError(`暂停失败:${friendlyError(err, '暂停失败')}`);
}
}
async function doDelete() {
const ok = await showConfirm('删除工作流', `确定要彻底删除工作流 '${panelState.name}' 及其关联的网格点数据吗?此操作无法撤销。`);
if (!ok) return;
try {
const res = await deleteWorkflowApi(panelState.name);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '工作流删除成功', 'success');
location.hash = '#/';
} else {
showError(json.message || '删除失败:请确认工作流未在运行后重试');
}
} catch (err) {
showError(`删除失败:${friendlyError(err, '删除失败')}`);
}
}
function exportYaml() {
const yaml = applyStageBlocks(panelState.configYaml, panelState.tlusty, panelState.synspec);
if (!yaml.trim()) {
showToast('配置内容为空,无法导出', 'warning');
return;
}
const filename = `${panelState.name || 'workflow'}.yaml`;
try {
const blob = new Blob([yaml], { type: 'text/yaml;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast(`已导出 ${filename}`, 'success');
} catch (err) {
showToast(`导出失败:${friendlyError(err, '文件生成异常')}`, 'error');
}
}
// ===== 刷新与挂载 =====
/** 把一次拉取的详情数据填入 panelStatefetch + parse 的公共逻辑,不含渲染)。
* mount 与 poll 共用此逻辑;mount 调用后强制重渲染,poll 仅在状态变化时更新按钮。 */
async function fetchAndApplyDetail({ forceConfig }) {
const res = await getWorkflowDetailApi(panelState.name);
const json = await res.json();
if (!json.success || !json.data) return false;
const prevStatus = panelState.status;
const prevConfigYaml = panelState.configYaml;
panelState.status = json.data.status || 'idle';
// 始终回填描述(保存时回显,避免 null 覆盖既有描述)。
panelState.description = json.data.description ?? null;
// mountforceConfig=true)或非编辑中(dirty=false)时同步服务端配置到本地。
if (forceConfig || !panelState.dirty) {
panelState.configYaml = json.data.config_yaml || '';
// TLUSTY 生效配置:无 `tlusty:` 块时按旧 `seed_step_fallback` 推断(与服务端
// resolve_tlusty_config 同口径),避免保存时把 `seed_step_fallback: false` 静默改写。
panelState.tlusty = resolveTlustyFromYaml(panelState.configYaml);
const s = parseStageFromYaml(panelState.configYaml, 'synspec_stage');
// 无 synspec_stage 块 → 兜底默认(与服务端 resolve_synspec_config 同口径),
// 避免面板停留在上一次的旧值。
panelState.synspec = s || defaultStage('synspec');
}
if (prevStatus !== panelState.status) {
updateActionButtons();
}
// 2026-08-04 修复:此前 poll 只把服务端新配置同步进 panelState、**不重渲染面板 DOM**
// 用户在 YAML 编辑器保存配置后,面板仍显示挂载时的旧值(必须刷新页面/离开再进才更新)。
// 现检测到服务端配置在非编辑中发生变化 → 用新 panelState 重渲染面板(保持折叠状态)。
if (!forceConfig && !panelState.dirty && prevConfigYaml !== panelState.configYaml) {
rerenderPanel();
}
return true;
}
/** 拉取最新工作流详情,更新面板状态与操作按钮可用性(不改编辑中的字段值)。
* 供详情页轮询调用:仅在非 dirty 时同步配置,状态变化时更新按钮可用性。 */
export async function refreshEnginePanel() {
try {
await fetchAndApplyDetail({ forceConfig: false });
} catch (err) {
// 静默:详情页主轮询会处理 404。
}
}
/** 用当前 panelState 重渲染面板 DOM(保持折叠/展开状态),供服务端配置变更后同步。
* 事件委托一次性绑定在挂载容器上(wireOnce),替换 innerHTML 不丢监听;
* 折叠状态/编辑内容由 panelState 承载,重渲染天然保持(2026-08-04 修复)。 */
function rerenderPanel() {
const mount = document.getElementById('wf-engine-panel-mount');
if (!mount) return;
mount.innerHTML = panelHtml();
// wireOnce 已 wired,此调用为 no-op(委托监听在容器上,innerHTML 替换后仍有效)。
wireOnce(mount);
}
/** 依据(collapsed, isRunning)同步 启动/暂停 按钮的显隐与禁用:
* 启动 仅展开时可见;暂停 仅运行中或展开时可见(折叠空闲态不出现冗余的禁用按钮)。 */
function syncActionVisibility(panelEl) {
const isRunning = panelState.status === 'running' || panelState.status === 'initializing';
const startBtn = panelEl?.querySelector('[data-engine-action="start"]');
const stopBtn = panelEl?.querySelector('[data-engine-action="stop"]');
const saveBtn = panelEl?.querySelector('[data-engine-action="save"]');
if (startBtn) {
startBtn.classList.toggle('hidden', panelState.collapsed);
startBtn.disabled = isRunning;
}
if (stopBtn) {
stopBtn.classList.toggle('hidden', panelState.collapsed && !isRunning);
stopBtn.disabled = !isRunning;
}
// 保存:折叠态仅在有未保存修改时显示(折叠后无法编辑,无修改时保存无意义);
// 展开态始终显示。保存成功后 dirty=false 时若仍折叠则自动隐藏。
if (saveBtn) saveBtn.classList.toggle('hidden', panelState.collapsed && !panelState.dirty);
}
function updateActionButtons() {
syncActionVisibility(document.querySelector('.wf-engine-panel'));
// 折叠态下切换按钮文案随运行状态联动(空闲「启动配置 ▾」/ 运行中「配置 ▾」)。
const toggleBtn = document.querySelector('[data-engine-action="toggle-config"]');
if (toggleBtn) {
toggleBtn.textContent = toggleBtnLabel();
toggleBtn.title = toggleBtnTitle();
}
}
/** 挂载引擎面板到指定容器,并拉取配置填充。
*
* 修复(二次审查 critical):旧版的 mounting 守卫会阻塞 mount 自身调用的
* refreshEnginePanelguard 早于 fetch 置位 → fetch 被跳过 → panelState 永远是默认值,
* 面板显示默认配置且 poll 的 refreshEnginePanel 不会重渲染输入控件 → 用户保存会覆写
* 服务端真实配置)。现 mount 直接调用 fetchAndApplyDetailforceConfig=true),
* 不经 refreshEnginePanel,故无需 mounting 守卫;fetch 成功后单次渲染服务端真实配置。 */
export async function mountEnginePanel(name, container) {
panelState.name = name;
panelState.status = 'idle';
panelState.description = null;
panelState.configYaml = '';
panelState.tlusty = defaultStage('tlusty');
panelState.synspec = defaultStage('synspec');
panelState.dirty = false;
panelState.wired = false;
panelState.collapsed = true;
// 先渲染骨架(默认配置),让用户立即看到面板;事件委托绑定一次。
container.innerHTML = panelHtml();
wireOnce(container);
// 直接拉取并填充 panelStateforceConfig=true 覆盖默认值),不依赖 refreshEnginePanel。
try {
await fetchAndApplyDetail({ forceConfig: true });
} catch (err) {
// 拉取失败:保留默认配置骨架,不阻断详情页其余部分;poll 会持续重试。
}
// fetch 完成后单次重建面板内容(reflect 服务端真实配置 + 描述)。
container.innerHTML = panelHtml();
wireOnce(container);
setDirty(false);
}
/** 绑定事件(仅一次)。 */
function wireOnce(container) {
if (panelState.wired) return;
panelState.wired = true;
container.addEventListener('change', handleFieldChange);
container.addEventListener('click', handleClick);
}
/** 卸载:清理引用(容器由详情页 unmount 整体清空,这里只重置状态)。 */
export function unmountEnginePanel() {
panelState.wired = false;
panelState.dirty = false;
}
// 导出纯函数供单元测试(YAML 解析/序列化的注释处理与边界场景回归覆盖)。
// 实际定义在 utils/yamlStage.js;此处仅 re-export 便于按需 import。
export {
defaultStage,
extractTopBlock,
parseStageFromYaml,
serializeStageBlock,
applyStageBlocks,
validateStageConfigs,
} from '../utils/yamlStage.js';
+67 -54
View File
@@ -1,6 +1,60 @@
/* DCTS Dashboard Workflows Component */
import { escapeHtml } from '../api.js';
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');
@@ -15,9 +69,8 @@ export function renderWorkflowsSkeleton() {
</div>
<div class="skeleton-shimmer skeleton-badge"></div>
</div>
<div class="workflow-actions wf-actions-mt">
<div class="skeleton-shimmer skeleton-btn"></div>
<div class="skeleton-shimmer skeleton-btn"></div>
<div class="wf-card-progress">
<div class="skeleton-shimmer skeleton-w-full skeleton-h-8"></div>
</div>
</div>
`).join('');
@@ -35,14 +88,10 @@ export function renderWorkflows(workflows) {
if (wfSub) wfSub.textContent = '暂无已注册工作流';
const emptyHtml = `
<div class="empty-state-box">
<svg class="empty-state-icon" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
<polyline points="2 17 12 22 22 17"/>
<polyline points="2 12 12 17 22 12"/>
</svg>
${ICONS.layers({ size: 36, cls: 'empty-state-icon' })}
<p>暂无恒星大气网格工作流配置</p>
<button type="button" class="btn btn-primary btn-sm" data-wf-action="create-first">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
${ICONS.plus({ size: 14 })}
注册第一个工作流
</button>
</div>
@@ -91,61 +140,25 @@ export function renderWorkflows(workflows) {
const name = escapeHtml(wf.name);
const href = `#/workflows/${encodeURIComponent(wf.name)}`;
const desc = escapeHtml(wf.description || '无描述');
const isRunning = wf.status === 'running';
// 内联进度(来自列表接口内联 stats;未启动的工作流 stats=null → 不渲染)
const s = wf.stats;
let progressHtml = '';
if (s && s.total > 0) {
const pct = Math.min(100, Math.max(0, Math.round((s.converged / s.total) * 100)));
progressHtml = `
<div class="wf-card-progress">
<div class="progress-bar-track" aria-hidden="true">
<div class="progress-bar-fill progress-fill-emerald" style="width: ${pct}%"></div>
</div>
<span class="wf-card-counts tabular-num">${s.converged}/${s.total} 收敛 · ${s.seed_step_converged} 种子步进 · ${s.failed} 失败 · ${s.running} 运行</span>
</div>`;
}
const cardInnerHtml = `
<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}">${statusCn}</span>
</div>
${progressHtml}
</a>
<div class="workflow-actions">
<button class="btn btn-secondary btn-sm" data-wf-action="view" data-wf-name="${name}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="m16 13-2 2 2 2"/><path d="m8 13 2 2-2 2"/></svg>
YAML 配置
</button>
${
isRunning
? `<button class="btn btn-secondary btn-sm" data-wf-action="stop" data-wf-name="${name}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg> 暂停</button>`
: `<button class="btn btn-primary btn-sm" data-wf-action="start" data-wf-name="${name}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg> 启动</button>`
}
<button class="btn btn-danger btn-sm" data-wf-action="delete" data-wf-name="${name}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
删除
</button>
</div>
`;
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) {
if (card.innerHTML.trim() !== cardInnerHtml.trim()) {
card.innerHTML = cardInnerHtml;
}
// 原位更新:状态徽章 / 进度条 / 计数文本——各自 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 = cardInnerHtml;
card.innerHTML = buildCardSkeleton({ name, href, desc, statusCn, statusClass, hasProgress, pct, countsText });
}
updatedCards.push(card);
});
+43 -23
View File
@@ -13,20 +13,14 @@
import { getWorkflowDetailApi, updateWorkflowApi } from '../api.js';
import { showToast } from './toast.js';
import { showConfirm, setupFocusTrap, releaseFocusTrap } from './modal.js';
import { fetchAllData } from '../state.js';
import { showConfirm, setupFocusTrap, hideModal } from './modal.js';
import { fetchAllData, isAppPolling } from '../state.js';
import { statusBadge } from '../utils/format.js';
import { friendlyError } from '../utils/errors.js';
const LOCKED_STATUSES = ['running', 'initializing'];
const RESET_WARN_STATUSES = ['paused', 'completed'];
const STATUS_META = {
running: ['运行中', 'online'],
completed: ['已完成', 'completed'],
initializing: ['初始化中', 'warning'],
paused: ['已暂停', 'secondary'],
idle: ['闲置', 'secondary'],
};
const editorState = {
name: '',
description: '',
@@ -35,6 +29,8 @@ const editorState = {
dirty: false,
mode: 'view',
wired: false,
// 保存成功后的回调(由调用方注入,如引擎面板传 refreshEnginePanel 以立即同步配置)。
onSaved: null,
};
// ===== 语法高亮(轻量 YAML 分词:键/数字/布尔/字符串/注释/列表符) =====
@@ -136,11 +132,6 @@ function renderYamlView(container, text) {
// ===== 面板填充与模式切换 =====
function statusBadge(status) {
const [cn, cls] = STATUS_META[status] || ['闲置', 'secondary'];
return `<span class="status-badge ${cls}">${cn}</span>`;
}
function setDirty(dirty) {
editorState.dirty = dirty;
document.getElementById('yaml-editor-dirty')?.classList.toggle('hidden', !dirty);
@@ -223,10 +214,8 @@ function hideEditError() {
}
function closeModal() {
const modal = document.getElementById('modal-view-wf');
if (!modal) return;
modal.classList.add('hidden');
releaseFocusTrap(modal);
// 走统一的带动画关闭(modal.js hideModal)。
hideModal(document.getElementById('modal-view-wf'));
}
/** 带脏状态守卫的关闭:编辑中且有未保存修改时先确认放弃。 */
@@ -238,6 +227,32 @@ async function requestClose() {
closeModal();
}
// ===== 导出配置文件 =====
function exportConfig() {
const area = document.getElementById('yaml-edit-area');
const content = editorState.mode === 'edit' && area ? area.value : editorState.original;
if (!content || !content.trim()) {
showToast('配置内容为空,无法导出', 'warning');
return;
}
const filename = `${editorState.name || 'workflow'}.yaml`;
try {
const blob = new Blob([content], { type: 'text/yaml;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast(`成功导出工作流配置文件 ${filename}`, 'success');
} catch (err) {
showToast(`导出文件失败:${friendlyError(err, '文件生成异常')}`, 'error');
}
}
// ===== 保存 =====
async function saveConfig() {
@@ -267,12 +282,15 @@ async function saveConfig() {
switchToView();
const statusEl = document.getElementById('yaml-editor-status');
if (statusEl) statusEl.innerHTML = statusBadge(editorState.status);
fetchAllData();
if (isAppPolling()) fetchAllData();
// 2026-08-04 修复:通知调用方(引擎面板)立即拉取并重渲染新配置,
// 否则面板显示挂载时的旧配置、需手动刷新页面才更新。
editorState.onSaved?.();
} else {
showEditError(json.message || `保存失败(HTTP ${res.status}`);
}
} catch (err) {
showEditError(`请求异常:${err.message}`);
showEditError(`请求异常:${friendlyError(err, '保存请求失败')}`);
} finally {
if (btnSave) btnSave.disabled = false;
}
@@ -286,6 +304,7 @@ function wireOnce() {
const modal = document.getElementById('modal-view-wf');
if (!modal) return;
document.getElementById('btn-export-yaml')?.addEventListener('click', exportConfig);
document.getElementById('btn-edit-yaml')?.addEventListener('click', switchToEdit);
document.getElementById('btn-close-view-wf')?.addEventListener('click', requestClose);
document.getElementById('close-view-wf')?.addEventListener('click', requestClose);
@@ -339,7 +358,7 @@ function wireOnce() {
// ===== 入口 =====
export async function openYamlEditor(name) {
export async function openYamlEditor(name, onSaved) {
try {
const res = await getWorkflowDetailApi(name);
const json = await res.json();
@@ -352,6 +371,7 @@ export async function openYamlEditor(name) {
editorState.description = data.description || '';
editorState.status = data.status;
editorState.original = data.config_yaml || '';
editorState.onSaved = onSaved || null;
wireOnce();
@@ -365,6 +385,6 @@ export async function openYamlEditor(name) {
modal?.classList.remove('hidden');
setupFocusTrap(modal);
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
showToast(`请求异常: ${friendlyError(err, '获取配置失败')}`, 'error');
}
}
+83 -12
View File
@@ -16,9 +16,10 @@ import {
} from './api.js';
import { showToast } from './components/toast.js';
import { showConfirm, setupFocusTrap, releaseFocusTrap } from './components/modal.js';
import { showConfirm, setupFocusTrap, hideModal } from './components/modal.js';
import { fetchAllData } from './state.js';
import { triggerRefreshAnimation } from './utils.js';
import { friendlyError } from './utils/errors.js';
import { startRouter } from './router.js';
const defaultYamlConfig = `# DCTS 恒星大气网格配置 (GridConfig)
@@ -62,6 +63,43 @@ function showLoginError(msg) {
}
}
// 工作流名称规则与服务端 is_valid_workflow_name 对齐:字母数字与 . _ -,长度 ≤64。
const WF_NAME_RE = /^[A-Za-z0-9._-]+$/;
/** 工作流名称内联校验(inline-validation):blur 时校验、错误内联到字段下方。
* 返回 true 表示通过。showError=false 时仅静默返回结果(用于提交前判断)。 */
function validateWfName(showError = true) {
const input = document.getElementById('input-wf-name');
const errEl = document.getElementById('wf-name-error');
if (!input) return true;
const v = input.value.trim();
let msg = '';
if (!v) msg = '工作流名称不能为空';
else if (v.length > 64) msg = '名称最长 64 字符';
else if (!WF_NAME_RE.test(v)) msg = '仅支持字母、数字、点、下划线与连字符';
if (showError) {
input.classList.toggle('input-invalid', !!msg);
if (errEl) {
errEl.textContent = msg;
errEl.classList.toggle('hidden', !msg);
}
}
return !msg;
}
/** 清空创建工作流表单的校验态(打开弹窗时调用)。 */
function resetCreateWfValidation() {
const input = document.getElementById('input-wf-name');
const errEl = document.getElementById('wf-name-error');
const yamlInput = document.getElementById('input-wf-yaml');
input?.classList.remove('input-invalid');
yamlInput?.classList.remove('input-invalid');
if (errEl) {
errEl.textContent = '';
errEl.classList.add('hidden');
}
}
async function handleLoginSubmit(password) {
const errorEl = document.getElementById('login-error-msg');
const btnSubmit = document.getElementById('btn-submit-login');
@@ -91,7 +129,7 @@ async function handleLoginSubmit(password) {
showLoginError(errMsg);
}
} catch (err) {
showLoginError(`登录请求异常:${err.message}`);
showLoginError(`登录请求异常:${friendlyError(err, '登录请求异常')}`);
} finally {
if (btnSubmit) btnSubmit.disabled = false;
}
@@ -104,15 +142,15 @@ document.addEventListener('DOMContentLoaded', async () => {
const modalReissueToken = document.getElementById('modal-reissue-token');
function closeModal(modal) {
if (!modal) return;
modal.classList.add('hidden');
releaseFocusTrap(modal);
// 走带动画的统一关闭(modal.js hideModal):先播退出动画再隐藏并释放焦点陷阱。
hideModal(modal);
}
btnCreateWf?.addEventListener('click', () => {
document.getElementById('input-wf-name').value = '';
document.getElementById('input-wf-desc').value = '';
document.getElementById('input-wf-yaml').value = defaultYamlConfig;
resetCreateWfValidation();
modalCreateWf?.classList.remove('hidden');
setupFocusTrap(modalCreateWf);
});
@@ -143,6 +181,11 @@ document.addEventListener('DOMContentLoaded', async () => {
closeModal(modalReissueToken);
});
// 节点详情管理 Modal 关闭(背景点击 / Escape 由全局监听统一处理,此处只接关闭按钮)
const modalNodeManage = document.getElementById('modal-node-manage');
document.getElementById('node-manage-close-btn')?.addEventListener('click', () => closeModal(modalNodeManage));
document.getElementById('btn-close-node-manage')?.addEventListener('click', () => closeModal(modalNodeManage));
// 点击 Backdrop 背景关闭弹窗(YAML 编辑器自带脏守卫关闭,除外)
document.querySelectorAll('.login-overlay, .modal-backdrop').forEach(overlay => {
overlay.addEventListener('click', (e) => {
@@ -167,17 +210,34 @@ document.addEventListener('DOMContentLoaded', async () => {
}
});
// 创建工作流:名称 blur 内联校验 + 输入时实时复检(仅已处于错误态时)
const wfNameInput = document.getElementById('input-wf-name');
wfNameInput?.addEventListener('blur', () => validateWfName(true));
wfNameInput?.addEventListener('input', () => {
if (wfNameInput.classList.contains('input-invalid')) validateWfName(true);
});
// 创建工作流提交处理
document.getElementById('form-create-wf')?.addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('input-wf-name').value.trim();
const nameInput = document.getElementById('input-wf-name');
const yamlInput = document.getElementById('input-wf-yaml');
const name = nameInput.value.trim();
const description = document.getElementById('input-wf-desc').value.trim();
const config_yaml = document.getElementById('input-wf-yaml').value;
const config_yaml = yamlInput.value;
if (!name || !config_yaml) {
showToast('请填写工作流名称及 YAML 配置内容!', 'error');
// 逐字段校验并聚焦第一个无效字段(focus-management
if (!validateWfName(true)) {
nameInput?.focus();
return;
}
if (!config_yaml.trim()) {
yamlInput?.classList.add('input-invalid');
showToast('请填写 YAML 配置内容,或点「填入默认示例」', 'error');
yamlInput?.focus();
return;
}
yamlInput?.classList.remove('input-invalid');
try {
const res = await createWorkflow({ name, description, config_yaml });
@@ -190,7 +250,7 @@ document.addEventListener('DOMContentLoaded', async () => {
showToast(`保存失败: ${json.message}`, 'error');
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
showToast(`请求异常: ${friendlyError(err, '保存请求失败')}`, 'error');
}
});
@@ -208,9 +268,8 @@ document.addEventListener('DOMContentLoaded', async () => {
// header 全局刷新 & 登出
document.getElementById('refresh-btn')?.addEventListener('click', (e) => {
triggerRefreshAnimation(e.currentTarget);
showToast('正在刷新集群全量数据...', 'info');
fetchAllData();
triggerRefreshAnimation(e.currentTarget, () => fetchAllData());
});
document.getElementById('btn-logout')?.addEventListener('click', async () => {
@@ -259,6 +318,18 @@ document.addEventListener('DOMContentLoaded', async () => {
handleLoginSubmit(pass);
});
// 密码显示/隐藏切换
document.querySelector('.btn-toggle-password')?.addEventListener('click', () => {
const input = document.getElementById('input-admin-token');
const btn = document.querySelector('.btn-toggle-password');
if (!input || !btn) return;
const show = input.type === 'password';
input.type = show ? 'text' : 'password';
btn.setAttribute('aria-label', show ? '隐藏密码' : '显示密码');
btn.querySelector('.icon-eye-open')?.classList.toggle('hidden', show);
btn.querySelector('.icon-eye-closed')?.classList.toggle('hidden', !show);
});
// 初始化鉴权判断:校验 sessionStorage 里的 token 是否仍被服务端认可。
// 旧实现仅凭 token 存在就直接进入,过期/被淘汰的 token 会"假登录"一瞬后被轮询 401 闪回。
// 现改为先调 /api/auth/check 验证,失败则清 token 显示登录遮罩,成功才 startRouter。
+38 -8
View File
@@ -4,13 +4,21 @@
* 视图挂载/卸载成对调用:各视图在 mount 里启动自己的轮询、在 unmount 里彻底清理,
* 保证切换视图不留定时器/监听器泄漏。登录门禁:无 token 时不渲染任何视图
* (登录遮罩由 main.js 管理,登录成功后调用 startRouter())。
*
* 路由级代码分割(bundle-splitting):首页与工作流详情页(含 Parallel Sets 收敛
* 分析,体积最大)改为动态 import 按需加载——首次进入首页不再下载/解析详情页代码。
* 模块引用在首次加载后缓存,后续切换零开销。
*/
import { getAdminToken } from './api.js';
import { mountHome, unmountHome } from './views/home.js';
import { mountWorkflowDetail, unmountWorkflowDetail } from './views/workflowDetail.js';
let current = null; // { view: 'home' | 'workflow', name?: string }
let savedScrollY = 0; // 视图切换时保留滚动位置
let renderToken = 0; // 渲染竞态守卫:快速连续 hashchange 时只有最后一次生效
// 动态加载的视图模块缓存(首次 import 后复用)。
let homeMod = null;
let detailMod = null;
export function parseHash() {
const h = location.hash.replace(/^#/, '');
@@ -30,18 +38,40 @@ export function parseHash() {
return { view: 'home' };
}
function render() {
async function render() {
const token = ++renderToken;
if (!getAdminToken()) return;
const next = parseHash();
if (current && current.view === next.view && current.name === next.name) return;
// 离开首页时保存滚动位置,返回时恢复
if (current?.view === 'home') savedScrollY = window.scrollY;
if (current) {
if (current.view === 'home') unmountHome();
else unmountWorkflowDetail();
if (current.view === 'home') homeMod?.unmountHome();
else detailMod?.unmountWorkflowDetail();
}
if (next.view === 'workflow') {
if (!detailMod) detailMod = await import('./views/workflowDetail.js');
if (token !== renderToken) return; // 加载期间又发生导航,放弃本次渲染
// 先把 current 标记为 nextmount 是 async(含网络 fetch),期间若发生二次导航,
// 其 render 会据此 unmount 正在挂载的视图(unmountWorkflowDetail 幂等),
// 避免 ctx/abort 句柄泄漏与双轮询(二次审查 major 修复)。
current = next;
await detailMod.mountWorkflowDetail(next.name);
if (token !== renderToken) return; // mount 期间又发生导航,放弃后续(滚动恢复等)
} else {
if (!homeMod) homeMod = await import('./views/home.js');
if (token !== renderToken) return;
current = next;
homeMod.mountHome();
}
// 回到首页时恢复滚动位置
if (next.view === 'home' && savedScrollY > 0) {
requestAnimationFrame(() => window.scrollTo(0, savedScrollY));
}
if (next.view === 'workflow') mountWorkflowDetail(next.name);
else mountHome();
current = next;
}
/** 注册 hashchange 并按当前 hash 首次渲染(登录成功后调用)。 */
+19 -48
View File
@@ -1,65 +1,36 @@
/* DCTS Dashboard Application State & Polling Scheduler */
/* DCTS Dashboard Application State & Polling Scheduler
*
* 首页全局轮询:5s 基准、指数退避上限 60s、Tab 切入后台自动暂停——
* 均由 utils/polling.js 的 createPoller 统一承担(与详情页作用域轮询共用一套实现)。
*/
import { fetchClusterStatus, fetchWorkflowsList, fetchNodesList } from './api.js';
import { renderNodesTable } from './components/nodesTable.js';
import { renderWorkflows } from './components/workflows.js';
import { createPoller } from './utils/polling.js';
import { logError } from './utils/errors.js';
let isPolling = false;
let pollTimer = null;
// 连续失败退避计数:服务端持续不可达时拉长轮询间隔(指数退避,上限 60s),
// 避免空轮询刷请求;恢复正常即重置为基准 5s。
let pollFailCount = 0;
// 最近一次 cluster 状态拉取是否成功(由 updateServerStatus 维护,供 fetchAllData 判定退避)。
let serverOnline = false;
// 监听浏览器 Tab 标签页切换,切入后台时暂停轮询,切回前台时恢复并立即刷新
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
} else if (isPolling) {
scheduleFetchData();
}
});
// 全局轮询:fetchAllData 返回布尔(以 cluster 状态能否获取为整体健康判定)。
const poller = createPoller(fetchAllData, { baseMs: 5000, maxMs: 60000 });
export function isAppPolling() {
return isPolling;
return poller.isRunning();
}
export function startPolling() {
if (isPolling) return;
isPolling = true;
scheduleFetchData();
poller.start();
}
export function stopPolling() {
isPolling = false;
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
poller.stop();
}
export async function scheduleFetchData() {
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
if (document.hidden) return;
const ok = await fetchAllData();
if (ok) {
pollFailCount = 0;
} else {
pollFailCount = Math.min(pollFailCount + 1, 4);
}
// 指数退避:5s * 2^failCountclamp 到 [5s, 60s]
const delay = Math.min(5000 * Math.pow(2, pollFailCount), 60000);
if (isPolling && !document.hidden) {
pollTimer = setTimeout(scheduleFetchData, delay);
}
/** 手动立即触发一次全量拉取(兼容旧调用方:header 刷新 / 建工作流后 / wfActions 已改由
* isAppPolling 守卫,本函数保留给需要无条件刷新的调用点)。 */
export function scheduleFetchData() {
poller.triggerNow();
}
// 返回主状态拉取是否成功(用于轮询退避计数)。三项并行拉取,
@@ -80,7 +51,7 @@ export async function fetchStatus() {
updateUI(data);
updateServerStatus(true);
} catch (err) {
console.warn('获取 DCTS 状态失败:', err);
logError(err, '获取 DCTS 状态失败');
updateServerStatus(false);
}
}
@@ -92,7 +63,7 @@ export async function fetchWorkflows() {
renderWorkflows(json.data);
}
} catch (err) {
console.warn('获取工作流列表失败:', err);
logError(err, '获取工作流列表失败');
}
}
@@ -103,7 +74,7 @@ export async function fetchNodes() {
renderNodesTable(json.data);
}
} catch (err) {
console.warn('获取计算节点列表失败:', err);
logError(err, '获取计算节点列表失败');
}
}
+642 -110
View File
File diff suppressed because it is too large Load Diff
+21 -6
View File
@@ -1,16 +1,31 @@
/* DCTS 前端小工具 */
/**
* 刷新按钮旋转动画:svg 加 .spin 类800ms 后恢复并解禁按钮。
* header 全局刷新与首页节点面板刷新共用。
* 刷新按钮旋转动画:svg 加 .spin 类并禁用按钮,**等待传入的异步任务真正完成**
* (或失败)后才停转解禁。旧版固定 800ms 后解禁,与网络实际耗时脱钩——慢网时
* 按钮已可再点但数据未到,快网时数据已到按钮仍空转。现以 asyncFn 的生命周期为准。
*
* @param {HTMLElement} btn 触发按钮(内含待旋转的 svg)
* @param {() => Promise<unknown>} [asyncFn] 可选的异步任务;缺省时回退到最短 600ms 的视觉反馈
*/
export function triggerRefreshAnimation(btn) {
if (!btn) return;
export async function triggerRefreshAnimation(btn, asyncFn) {
if (!btn) {
if (asyncFn) await asyncFn();
return;
}
const svg = btn.querySelector('svg');
if (svg) svg.classList.add('spin');
btn.disabled = true;
setTimeout(() => {
// 最短旋转时长:避免极快请求下动画一闪而过(也避免 0ms 状态突变)。
const minSpin = new Promise((r) => setTimeout(r, 600));
try {
if (asyncFn) {
await Promise.all([Promise.resolve(asyncFn()), minSpin]);
} else {
await minSpin;
}
} finally {
if (svg) svg.classList.remove('spin');
btn.disabled = false;
}, 800);
}
}
+125
View File
@@ -0,0 +1,125 @@
/* DCTS 错误分类与统一处理
*
* 历史上各 catch 块都 toast(`失败:${err.message}`),不区分网络/业务/服务端,
* 用户体验差(网络挂起时提示「undefined 失败」)。本模块按错误来源分类,
* 给出对应的中文提示与重试建议。
*
* 错误分类:
* - network: fetch 抛错(AbortError/TypeError),通常是断网/超时/DNS
* - auth: HTTP 401(已在 apiFetch 顶层处理,这里仅归类)
* - business: HTTP 4xx(除 401),服务端返回 json.message
* - server: HTTP 5xx,服务端异常
* - unknown: 其他
*/
/**
* @param {Error} [err] catch 块的 error 对象
* @param {Response} [res] fetch Response(可能为 null,如网络层抛错)
* @param {object} [json] 解析后的响应体(可能为 null)
* @returns {{kind: 'network'|'auth'|'business'|'server'|'unknown', message: string, retryable: boolean, httpStatus: number|null}}
*/
export function classifyError(err, res, json) {
const httpStatus = res ? res.status : null;
// 网络层错误:fetch 直接抛错(AbortError/超时/TypeError 断网)
if (!res && err) {
const isAbort = err.name === 'AbortError';
return {
kind: 'network',
message: isAbort ? '请求已取消' : (err.message || '网络请求失败'),
retryable: !isAbort,
httpStatus: null,
};
}
if (httpStatus === 401) {
return { kind: 'auth', message: '会话已过期,请重新登录', retryable: false, httpStatus };
}
if (httpStatus >= 500 && httpStatus < 600) {
return {
kind: 'server',
message: json?.message || `服务端异常 (HTTP ${httpStatus})`,
retryable: true,
httpStatus,
};
}
if (httpStatus >= 400 && httpStatus < 500) {
return {
kind: 'business',
message: json?.message || `请求失败 (HTTP ${httpStatus})`,
retryable: false,
httpStatus,
};
}
// res.ok 但走到了 catch,或 res 为 undefined 且无 err:未知
return {
kind: 'unknown',
message: err?.message || json?.message || '未知错误',
retryable: false,
httpStatus,
};
}
/** 是否为「主动取消」类错误(AbortError),这类错误应静默,不提示用户。 */
export function isAbortError(err) {
return err && err.name === 'AbortError';
}
/**
* 面向用户的轻量错误文案(catch 块只有 err、无 res/json 时用)。
* 网络层 fetch 抛 TypeError('Failed to fetch'),直接展示原文对用户无意义,
* 映射为可读提示;其余保留 err.message,缺省回退 fallback。
* @param {Error} [err]
* @param {string} [fallback='请求失败']
* @returns {string}
*/
export function friendlyError(err, fallback = '请求失败') {
if (!err) return fallback;
if (err instanceof TypeError) {
// fetch 网络失败在 Chromium/Firefox 的标准文案是 "Failed to fetch"(无有用信息),
// 映射为可读提示;其余 TypeError 多为代码缺陷(如对已解析对象再调 .json()),
// 保留真实 message,避免用「网络连接失败」掩盖代码 bug(2026-08 节点管理误报排查)。
if (!err.message || err.message === 'Failed to fetch') {
return '网络连接失败,请检查服务端是否在线后重试';
}
return err.message || fallback;
}
return err.message || fallback;
}
/**
* 统一 console 出口。生产构建可在 vite 配置中 drop console,这里集中便于 strip。
* AbortError 静默不打扰(多为视图卸载主动 abort)。
* @param {Error} [err]
* @param {string} [context] 上下文描述,如「获取工作流统计」
*/
export function logError(err, context = '') {
if (isAbortError(err)) return;
const prefix = context ? `[${context}]` : '[DCTS]';
// eslint-disable-next-line no-console
console.warn(prefix, err);
}
/**
* 生成面向用户的错误提示文案(配合 toast 使用)。
* @param {{kind: string, message: string, retryable: boolean}} info classifyError 的返回值
* @param {string} [action] 操作描述,如「启动工作流」「保存配置」
* @returns {string}
*/
export function userMessage(info, action = '操作') {
switch (info.kind) {
case 'network':
return `${action}失败:网络异常,请检查连接后重试`;
case 'auth':
return info.message; // 401 已由 apiFetch 顶层处理,这里仅兜底
case 'server':
return `${action}失败:服务端异常,请稍后重试`;
case 'business':
return info.message; // 后端业务错误,直接显示 message
default:
return `${action}失败:${info.message}`;
}
}
+157
View File
@@ -0,0 +1,157 @@
/* DCTS 格式化与转义工具(权威定义)
*
* 集中所有纯函数:HTML 转义、时间/时长格式化、状态徽章。
* 历史上 escapeHtml 定义在 api.js、各 fmt* 散落在 workflowDetail.js,改一处要全局搜,
* 现统一到本模块。api.js 通过 re-export 保持向后兼容。
*
* SQLite 约定:datetime('now') 形如 'YYYY-MM-DD HH:MM:SS'UTC,无时区标记),
* 解析前需规范化为 ISO。所有时间解析函数共用 parseIso/parseTsMs。
*
* 语义与后端契约对齐(crates/server/src/db.rs):
* - 网格点状态:pending/queued/running/converged/failed/canceled
* - success_method(收敛途径):cold_run / seed_step
*/
/**
* 安全 HTML 转义,防止存储型 XSS。
* 顺序敏感:& 必须先转义,否则后续转义产生的 &amp; 会被二次转义。
* @param {unknown} s
* @returns {string}
*/
export function escapeHtml(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** 规范化为 Date(解析失败返回 null)。SQLite 'YYYY-MM-DD HH:MM:SS' 视为 UTC。 */
export function parseIso(iso) {
if (!iso) return null;
const normalized = iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z';
const d = new Date(normalized);
return Number.isNaN(d.getTime()) ? null : d;
}
/** 同 parseIso 但返回 epoch 毫秒(解析失败为 NaN)。供进度曲线按真实时间铺横轴。 */
export function parseTsMs(iso) {
if (!iso) return NaN;
const normalized = iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z';
return new Date(normalized).getTime();
}
/** SQLite datetime('now') 为 'YYYY-MM-DD HH:MM:SS'UTC 无时区标记),规范化后解析。 */
export function fmtTime(iso) {
if (!iso) return '—';
const normalized = iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z';
const d = new Date(normalized);
if (Number.isNaN(d.getTime())) return escapeHtml(iso);
return d.toLocaleTimeString('zh-CN');
}
/** 紧凑日期时间「MM-DD HH:MM」:带日期(跨天运行不至于看不出是哪天完成的),
* 隐去年份与秒(活跃网格数据不跨年,秒级精度放 title)。定宽 11 字符,列内对齐。 */
export function fmtDateTime(iso) {
const d = parseIso(iso);
if (!d) return iso ? escapeHtml(iso) : '—';
const p = (n) => String(n).padStart(2, '0');
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
/** 完整时间戳「YYYY-MM-DD HH:MM:SS」,供单元格 title 悬停查看精确时刻。 */
export function fmtFullTs(iso) {
const d = parseIso(iso);
if (!d) return iso || '';
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
/** 工作流级时长格式化:「N 分钟」或「N 小时 M 分」(控制条 ETA 用)。null/非有限值/≤0 返回占位符。 */
export function fmtDuration(sec) {
// ≤0 视为无数据(与 fmtDur 同口径):失败任务上报的 last_elapsed_sec 可能是 0.0
// runner 无 summary 时),显示"0 分钟"会误导,应展示占位符。
if (sec == null || !Number.isFinite(sec) || Number(sec) <= 0) return '—';
const m = Math.round(sec / 60);
if (m < 60) return `${m} 分钟`;
const h = Math.floor(m / 60);
return `${h} 小时 ${m % 60}`;
}
/** 网格点级紧凑时长(表格单元格用):42s / 12m 30s / 1h 20m。
* ≤0 视为无数据(tlusty 单点不可能 0 秒完成)。 */
export function fmtDur(sec) {
if (sec == null || sec === '') return '—';
const n = Number(sec);
if (!Number.isFinite(n) || n <= 0) return '—';
const s = Math.round(n);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m${s % 60 ? ` ${s % 60}s` : ''}`;
const h = Math.floor(m / 60);
return `${h}h${m % 60 ? ` ${m % 60}m` : ''}`;
}
/** 相对收敛度指数记数(点表/尝试表 max_relc 单元格)。空值/非法值返回占位符。 */
export function fmtRelc(v) {
if (v == null || v === '') return '—';
const n = Number(v);
return Number.isFinite(n) ? n.toExponential(2) : '—';
}
/** CSV 字段转义:含逗号/引号/换行时用双引号包裹并翻倍内部引号。 */
export function csvEscape(v) {
const s = String(v ?? '');
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
/** 心跳时间容错:字段缺失或非法时间戳一律显示占位符,避免渲染出 "Invalid Date"。 */
export function fmtClock(ts) {
if (!ts) return '—';
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleTimeString('zh-CN');
}
// ===== 状态徽章(返回 HTML 字符串,供 innerHTML 注入) =====
/** 工作流状态映射:[中文标签, badge class]。后端可发 idle/initializing/running/paused/completed。 */
export const STATUS_MAP = {
running: ['运行中', 'online'],
completed: ['已完成', 'completed'],
initializing: ['初始化中', 'warning'],
paused: ['已暂停', 'secondary'],
idle: ['闲置', 'secondary'],
};
/** 工作流状态徽章 HTML。 */
export function statusBadge(status) {
const [cn, cls] = STATUS_MAP[status] || ['闲置', 'secondary'];
return `<span class="status-badge ${cls}">${cn}</span>`;
}
/** 网格点状态映射:[中文标签, badge class]。与后端 grid_points.status 对齐。 */
export const POINT_STATUS_MAP = {
converged: ['已收敛', 'online'],
failed: ['失败', 'danger'],
running: ['运行中', 'warning'],
queued: ['排队中', 'info'],
pending: ['未开始', 'secondary'],
};
/** 网格点状态徽章 HTML。 */
export function pointStatusBadge(status) {
const [cn, cls] = POINT_STATUS_MAP[status] || ['未知', 'secondary'];
return `<span class="status-badge ${cls}">${cn}</span>`;
}
/** 网格点收敛途径徽章 HTMLsuccess_methodcold_run / seed_step / 策略名)。 */
export function pointMethodBadge(method) {
if (method === 'cold_run') return '<span class="method-badge cold">冷启动</span>';
if (method === 'seed_step') return '<span class="method-badge seed">种子步进</span>';
// 其它策略名(如 synspec-only 任务归因的 "standard")原样展示,让光谱归因可见。
if (method) return `<span class="method-badge syn">${escapeHtml(method)}</span>`;
return '<span class="text-hint">—</span>';
}
+82
View File
@@ -0,0 +1,82 @@
/* DCTS 图标常量库
*
* 统一管理 JS 字符串模板里用到的内联 SVG,消除跨文件复制粘贴。
* 设计:每个图标是一个 (opts?) => string 的函数,返回完整 <svg> 字符串。
* ICONS.trash() → 默认尺寸/线宽
* ICONS.trash({ size: 14 }) → 自定义尺寸
* ICONS.check({ cls: 'x' }) → 附加 class
*
* 注:index.html 中被 JS 通过 class 查询的静态 SVG(logo / eye / sun / moon /
* metric icons / modal close)不在此处管理——它们是静态 HTML,非 JS 模板注入。
*/
/**
* 包裹 SVG inner 为完整标签字符串。
* @param {string} inner SVG 子元素 markup
* @param {object} [o]
* @param {number} [o.size=16] width/height
* @param {number} [o.sw=2] stroke-width
* @param {string} [o.cls] 附加 class
* @param {boolean} [o.linecap=true] 是否加 stroke-linecap/linejoin=round
* @param {boolean} [o.fill=false] 是否用 currentColor 填充(实心图标,如三点菜单)
*/
function svg(inner, { size = 16, sw = 2, cls = '', linecap = true, fill = false } = {}) {
const lc = linecap ? ' stroke-linecap="round" stroke-linejoin="round"' : '';
const c = cls ? ` class="${cls}"` : '';
const f = fill ? ' fill="currentColor"' : ' fill="none"';
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" ${f} stroke="currentColor" stroke-width="${sw}"${lc}${c} aria-hidden="true">${inner}</svg>`;
}
export const ICONS = {
// ===== 通用 =====
/** 勾选(nodesTable 审批通过) */
check: (o) => svg(`<polyline points="20 6 9 17 4 12"/>`, { sw: 2.5, ...o }),
/** 关闭叉(modal/toast 关闭、节点拒绝) */
x: (o) => svg(`<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>`, { sw: 2.2, ...o }),
/** 信息圆圈(toast info) */
info: (o) => svg(`<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>`, { sw: 2.5, ...o }),
/** 错误圆圈(toast error) */
alert: (o) => svg(`<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>`, { sw: 2.5, ...o }),
/** 圆中带勾(toast success、指标卡「已计算」) */
checkCircle: (o) => svg(`<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>`, { sw: 2.5, ...o }),
/** 警告三角(YAML 重置警示) */
warn: (o) => svg(`<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>`, { sw: 2.5, ...o }),
/** 加号(创建工作流、空状态注册) */
plus: (o) => svg(`<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>`, { ...o }),
/** 刷新(header 刷新、节点重发 Token) */
refresh:(o) => svg(`<path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>`, { ...o }),
/** 返回箭头(详情页返回控制台) */
back: (o) => svg(`<line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/>`, { ...o }),
/** 下载/导出(点表导出 CSV) */
download: (o) => svg(`<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>`, { ...o }),
// ===== 工作流操作 =====
/** 启动/播放 */
start: (o) => svg(`<polygon points="5 3 19 12 5 21 5 3"/>`, { ...o }),
/** 暂停(双竖条) */
stop: (o) => svg(`<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>`, { ...o }),
/** 删除/垃圾桶 */
trash: (o) => svg(`<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>`, { ...o }),
/** 文档/YAML 查看 */
view: (o) => svg(`<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="m16 13-2 2 2 2"/><path d="m8 13 2 2-2 2"/>`, { ...o }),
/** 编辑/铅笔 */
edit: (o) => svg(`<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>`, { ...o }),
/** 保存/软盘 */
save: (o) => svg(`<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>`, { ...o }),
// ===== 节点操作 =====
/** 电源/启用 */
power: (o) => svg(`<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/>`, { sw: 2.5, ...o }),
/** 停用/双竖条(圆角) */
pause: (o) => svg(`<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>`, { sw: 2.5, ...o }),
/** 时钟(节点待审批徽章、待计算指标) */
clock: (o) => svg(`<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>`, { sw: 2.5, ...o }),
/** 服务器/节点(指标卡「在线计算节点」) */
server: (o) => svg(`<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>`, { ...o }),
/** 竖排三点(节点管理「更多操作」入口) */
moreVertical: (o) => svg(`<circle cx="12" cy="5" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="12" cy="19" r="1.6"/>`, { sw: 0, fill: true, ...o }),
// ===== 容器/图层(空状态、指标卡) =====
/** 堆叠图层(工作流指标卡、空状态) */
layers: (o) => svg(`<polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/>`, { ...o }),
};
+100
View File
@@ -0,0 +1,100 @@
/* DCTS 通用轮询调度器
*
* 消除 state.js(首页全局)与 workflowDetail.js(详情页作用域)各写一遍的
* 「指数退避 + visibilitychange 暂停 + failCount」逻辑。
*
* 用法:
* const poller = createPoller(() => fetchAllData(), { baseMs: 5000 });
* poller.start(); // 启动(立即跑一次)
* await poller.triggerNow(); // 手动立即触发一次(用于刷新按钮)
* poller.stop(); // 停止并清 timer
*
* fn 返回值约定:falsy 视为失败(计入退避),truthy 视为成功(重置 failCount)。
* 抛错同样视为失败。fn 可以是 async。
*
* visibilitychange:Tab 切入后台暂停轮询,切回前台立即恢复并跑一次。
*/
/**
* @param {() => (boolean | Promise<boolean>)} fn 每轮调用的函数,返回 truthy=falsy 决定退避
* @param {object} [opts]
* @param {number} [opts.baseMs=5000] 基准间隔(成功时)
* @param {number} [opts.maxMs=60000] 退避上限
* @param {number} [opts.maxFails=4] failCount clamp 上限(对应 baseMs * 2^maxFails)
* @returns {{start: () => void, stop: () => void, triggerNow: () => Promise<void>, isRunning: () => boolean}}
*/
export function createPoller(fn, { baseMs = 5000, maxMs = 60000, maxFails = 4 } = {}) {
let timer = null;
let running = false;
let failCount = 0;
let onVisibility = null;
function clearTimer() {
if (timer) {
clearTimeout(timer);
timer = null;
}
}
async function schedule() {
clearTimer();
if (document.hidden) return;
let ok = true;
try {
const r = await fn();
ok = !!r;
} catch (_) {
ok = false;
}
if (ok) {
failCount = 0;
} else {
failCount = Math.min(failCount + 1, maxFails);
}
// 指数退避:baseMs * 2^failCount,clamp 到 [baseMs, maxMs]
const delay = Math.min(baseMs * Math.pow(2, failCount), maxMs);
if (running && !document.hidden) {
timer = setTimeout(schedule, delay);
}
}
function start() {
if (running) return;
running = true;
if (!onVisibility) {
onVisibility = () => {
if (document.hidden) {
clearTimer();
} else if (running) {
schedule();
}
};
document.addEventListener('visibilitychange', onVisibility);
}
schedule();
}
function stop() {
running = false;
clearTimer();
if (onVisibility) {
document.removeEventListener('visibilitychange', onVisibility);
onVisibility = null;
}
}
/** 立即触发一次(不影响 running 状态与退避计数,用于手动刷新)。 */
async function triggerNow() {
clearTimer();
await schedule();
}
return {
start,
stop,
triggerNow,
isRunning: () => running,
};
}
+160
View File
@@ -0,0 +1,160 @@
/* 工作流阶段配置(tlusty / synspec_stage)的轻量 YAML 解析与序列化。
*
* 无依赖纯函数模块——刻意从 wfEnginePanel.js 拆出,使其可在 Node 原生 test
* 运行器下被单测覆盖(wfEnginePanel.js 导入了浏览器侧的 toast/modal/api,无法
* 在 Node 直接 import)。
*
* 不引入完整 YAML 库:这两个块结构固定(enabled/policy/strategies 三字段),
* 用正则在顶层缩进定位块的起止行,解析其内联或块式表达。strategies 既可能是
* flow 序列 `[cold_run, seed_step]`,也可能是块式列表;统一在写入时序列化为
* flow 序列(与设计文档示例一致)。
*/
/** stage 配置默认值。 */
export function defaultStage(kind) {
if (kind === 'tlusty') {
return { enabled: true, policy: 'skip_converged', strategies: ['cold_run', 'seed_step'] };
}
return { enabled: true, policy: 'skip_converged', strategies: ['standard'] };
}
/** 阶段配置合法性校验(与服务端 save_workflow 口径一致,见 docs/task_engine_decoupling_design.md §3)。
* - 至少一个阶段启用(双关 → 无任何计算可执行,任务必失败);
* - 启用的阶段必须配置 ≥1 个策略(空链 → 调度无顺位可派,任务必失败)。
* 返回错误文案;校验通过返回 null。 */
export function validateStageConfigs(tlusty, synspec) {
if (!tlusty.enabled && !synspec.enabled) {
return 'TLUSTY 与 SYNSPEC 阶段均被禁用:至少应启用一个计算阶段';
}
if (tlusty.enabled && (!tlusty.strategies || tlusty.strategies.length === 0)) {
return 'TLUSTY 阶段已启用但策略链为空:至少需要 1 个策略(如 cold_run';
}
if (synspec.enabled && (!synspec.strategies || synspec.strategies.length === 0)) {
return 'SYNSPEC 阶段已启用但策略链为空:至少需要 1 个策略(如 standard';
}
return null;
}
/** 提取顶层某键的块(从 `^key:` 行到下一个顶层键或顶层注释之前)。
*
* 顶层注释(列 0 的 `#`,无缩进)必须终止块——它是独立的顶层结构,不属于上一块。
* 否则 `tlusty:` 与 `synspec_stage:` 之间的顶层注释会被吞进 tlusty 块,
* 随后 replaceOrAppendBlock 覆盖时丢失/破坏 synspec_stage 块(审查 #4)。
* 缩进的注释(` # ...`)仍属块体(块内行内注释)。 */
export function extractTopBlock(yaml, key) {
const lines = yaml.split('\n');
const startIdx = lines.findIndex(l => new RegExp(`^${key}:\\s*(\\S.*)?$`).test(l));
if (startIdx < 0) return null;
// 块的缩进为 0(顶层键);块体为后续缩进 > 0 的连续行。
const blockLines = [lines[startIdx]];
for (let i = startIdx + 1; i < lines.length; i++) {
const l = lines[i];
// 空行属块体(块间分隔)。
if (l.trim() === '') {
blockLines.push(l);
continue;
}
// 顶层键或顶层注释(无缩进)→ 块结束。
if (!/^\s/.test(l)) break;
blockLines.push(l);
}
return { startIdx, lines: blockLines };
}
/** 剥离单行的 `#...` 行内注释(保留字符串内的 #;本配置场景足够)。
* 整行注释(`#...`)由调用方在拼接前跳过(审查 #5)。 */
function stripComment(line) {
const m = line.match(/(^|\s)(#.*)$/);
if (!m) return line;
return line.slice(0, m.index + m[1].length);
}
/** 从 YAML 文本解析单个 stage 配置(enabled/policy/strategies)。
* 先剥离每行注释再拼接,避免注释里的 enabled:/policy:/strategies: 干扰正则。 */
export function parseStageFromYaml(yaml, key) {
const block = extractTopBlock(yaml, key);
if (!block) return null;
const text = block.lines
.map(l => (l.trim().startsWith('#') ? '' : stripComment(l)))
.join('\n');
const enabled = /enabled:\s*(true|false)/i.test(text)
? /enabled:\s*(true|false)/i.exec(text)[1].toLowerCase() === 'true'
: true;
const policyMatch = /policy:\s*([a-z_]+)/i.exec(text);
const policy = policyMatch ? policyMatch[1] : 'skip_converged';
// strategiesflow 序列 `[a, b]` 或块式 `- a\n - b`。
let strategies = [];
const flowMatch = /strategies:\s*\[([^\]]*)\]/i.exec(text);
if (flowMatch) {
strategies = flowMatch[1].split(',').map(s => s.trim()).filter(Boolean);
} else {
const blockMatch = /strategies:\s*\n((?:\s*-\s*.+\n?)+)/i.exec(text);
if (blockMatch) {
strategies = blockMatch[1].split('\n')
.map(l => l.replace(/^\s*-\s*/, '').trim())
.filter(Boolean);
}
}
return { enabled, policy, strategies };
}
/** 解析 TLUSTY 阶段**生效**配置:优先顶层 `tlusty:` 块;无块时按旧字段
* `seed_step_fallback` 推断(与服务端 `GridConfig::resolve_tlusty_config` 同口径):
* - `seed_step_fallback: false` → `[cold_run]`(不回退种子步进);
* - `true` / 缺省 → 默认链 `[cold_run, seed_step]`。
*
* 用途(审查 #2 修复):面板编辑保存会物化 `tlusty:` 块。若无此推断而直接回退到
* `defaultStage`(恒 `[cold_run, seed_step]`),会把旧 `seed_step_fallback: false`
* 的工作流静默改写为启用种子回退。 */
export function resolveTlustyFromYaml(yaml) {
const block = parseStageFromYaml(yaml, 'tlusty');
if (block) return block;
// 行级查找顶层键(`^\s*seed_step_fallback` 天然排除 `#` 注释行)。
const hit = (yaml || '').split('\n')
.find(l => /^\s*seed_step_fallback\s*:\s*(true|false)\b/i.test(l));
if (hit && /false\b/i.test(hit)) {
return { enabled: true, policy: 'skip_converged', strategies: ['cold_run'] };
}
return defaultStage('tlusty');
}
/** 把 stage 配置序列化为 YAML 块文本(flow 序列风格)。 */
export function serializeStageBlock(key, stage) {
const strat = stage.strategies.length > 0
? `[${stage.strategies.join(', ')}]`
: '[]';
return `${key}:\n enabled: ${stage.enabled}\n policy: ${stage.policy}\n strategies: ${strat}`;
}
/** 把编辑后的 tlusty/synspec_stage 块写回原始 config_yaml
* 已存在该块 → 替换;不存在 → 追加到末尾。 */
export function applyStageBlocks(yaml, tlusty, synspec) {
let out = yaml;
out = replaceOrAppendBlock(out, 'tlusty', serializeStageBlock('tlusty', tlusty));
out = replaceOrAppendBlock(out, 'synspec_stage', serializeStageBlock('synspec_stage', synspec));
return out;
}
/** 替换顶层块;不存在则追加。
*
* 保留块间空行分隔:extractTopBlock 会把块体后的空行吸收入 block.lines
* 替换时若直接用 block.lines.length 作 endIdx 会把这些空行一并删除,导致相邻块
* 在多次保存后逐渐粘连。改为在替换后的块末尾补一个空行分隔(除非已是文件末尾
* 或后续已是空行),保证块间视觉分隔稳定(二次审查 minor 修复)。 */
function replaceOrAppendBlock(yaml, key, blockText) {
const block = extractTopBlock(yaml, key);
if (!block) {
const sep = yaml.endsWith('\n') ? '' : '\n';
return yaml + sep + '\n' + blockText + '\n';
}
const lines = yaml.split('\n');
const startIdx = block.startIdx;
const endIdx = startIdx + block.lines.length; // 不含(含被吸收的尾部空行)
const before = lines.slice(0, startIdx);
let after = lines.slice(endIdx);
// 若后续非空且不以空行开头,补一个空行作块间分隔(恢复被 extractTopBlock 吞掉的空行)。
const needSep = after.length > 0 && after[0].trim() !== '';
const blockLines = blockText.split('\n');
const joined = needSep ? [...blockLines, '', ...after] : [...blockLines, ...after];
return [...before, ...joined].join('\n');
}
+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
+165
View File
@@ -0,0 +1,165 @@
/* DCTS 纯函数单测(node:test,零依赖,无需 npm install
*
* 覆盖 utils/format.js 的格式化/转义/徽章逻辑——这些是最容易出回归 bug 的纯函数
* (尤其 SQLite UTC 时间解析、指数记数、CSV 转义)。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
escapeHtml, parseIso, parseTsMs, fmtTime, fmtDateTime, fmtFullTs,
fmtDuration, fmtDur, fmtRelc, csvEscape, fmtClock,
statusBadge, pointStatusBadge, pointMethodBadge,
} from '../src/utils/format.js';
// ===== escapeHtml =====
test('escapeHtml 转义五个特殊字符', () => {
assert.equal(escapeHtml('<script>&"\'</script>'), '&lt;script&gt;&amp;&quot;&#39;&lt;/script&gt;');
});
test('escapeHtml 顺序敏感:& 先转义,不二次转义', () => {
assert.equal(escapeHtml('a & b'), 'a &amp; b');
assert.equal(escapeHtml('&amp;'), '&amp;amp;'); // 输入本身是实体时也如实转义(不做解码)
});
test('escapeHtml 空值/null 返回空串', () => {
assert.equal(escapeHtml(null), '');
assert.equal(escapeHtml(undefined), '');
assert.equal(escapeHtml(''), '');
});
// ===== 时间解析(SQLite 'YYYY-MM-DD HH:MM:SS' 视为 UTC =====
test('parseIso 解析 SQLite UTC 无时区格式', () => {
const d = parseIso('2026-08-02 15:30:00');
assert.ok(d instanceof Date);
assert.equal(d.toISOString(), '2026-08-02T15:30:00.000Z');
});
test('parseIso 解析带 T 的 ISO 原样', () => {
const d = parseIso('2026-08-02T15:30:00Z');
assert.equal(d.toISOString(), '2026-08-02T15:30:00.000Z');
});
test('parseIso 非法输入返回 null', () => {
assert.equal(parseIso('not-a-date'), null);
assert.equal(parseIso(null), null);
assert.equal(parseIso(''), null);
});
test('parseTsMs 返回 epoch 毫秒,非法输入为 NaN', () => {
assert.equal(parseTsMs('2026-08-02 00:00:00'), Date.UTC(2026, 7, 2));
assert.ok(Number.isNaN(parseTsMs('garbage')));
assert.ok(Number.isNaN(parseTsMs(null)));
});
test('fmtDateTime 输出 MM-DD HH:MM 定宽(按浏览器本地时区)', () => {
// 服务器存 UTC、前端按本地时区显示(既有行为)。断言值用同一 Date 计算,与时区无关。
const d = parseIso('2026-08-02 09:05:07');
const p = (n) => String(n).padStart(2, '0');
assert.equal(
fmtDateTime('2026-08-02 09:05:07'),
`${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
);
assert.equal(fmtDateTime('bad'), 'bad'); // 解析失败原样返回
assert.equal(fmtDateTime(null), '—');
});
test('fmtFullTs 输出完整时间戳(按浏览器本地时区)', () => {
const d = parseIso('2026-08-02 09:05:07');
const p = (n) => String(n).padStart(2, '0');
assert.equal(
fmtFullTs('2026-08-02 09:05:07'),
`${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
);
assert.equal(fmtFullTs(null), '');
});
test('fmtTime 非法时间戳回退为转义原文', () => {
assert.equal(fmtTime('garbage'), 'garbage');
assert.equal(fmtTime(null), '—');
});
// ===== 时长格式化 =====
test('fmtDuration(控制条 ETA):分钟粒度', () => {
assert.equal(fmtDuration(30), '1 分钟'); // 30s → round(0.5)=1 分钟(沿用原版算法)
assert.equal(fmtDuration(3600), '1 小时 0 分');
assert.equal(fmtDuration(5400), '1 小时 30 分');
assert.equal(fmtDuration(null), '—');
assert.equal(fmtDuration(NaN), '—');
// ≤0 视为无数据(L3 修复:失败任务 last_elapsed_sec=0.0 不应显示"0 分钟"
assert.equal(fmtDuration(0), '—');
assert.equal(fmtDuration(-5), '—');
});
test('fmtDur(表格单元格):42s / 12m 30s / 1h 20m', () => {
assert.equal(fmtDur(42), '42s');
assert.equal(fmtDur(750), '12m 30s');
assert.equal(fmtDur(4800), '1h 20m');
assert.equal(fmtDur(0), '—'); // ≤0 视为无数据
assert.equal(fmtDur(-5), '—');
assert.equal(fmtDur(''), '—');
assert.equal(fmtDur(null), '—');
});
test('fmtRelc 指数记数(max_relc 相对收敛度)', () => {
assert.equal(fmtRelc(0.00123), '1.23e-3');
assert.equal(fmtRelc(''), '—');
assert.equal(fmtRelc(null), '—');
assert.equal(fmtRelc('abc'), '—');
});
test('fmtClock 心跳时间容错', () => {
assert.match(fmtClock('2026-08-02T09:05:07Z'), /^\d{2}:\d{2}:\d{2}$/);
assert.equal(fmtClock('invalid'), '—');
assert.equal(fmtClock(null), '—');
});
// ===== CSV 转义 =====
test('csvEscape 逗号/引号/换行包裹并翻倍引号', () => {
assert.equal(csvEscape('plain'), 'plain');
assert.equal(csvEscape('a,b'), '"a,b"');
assert.equal(csvEscape('say "hi"'), '"say ""hi"""');
assert.equal(csvEscape('line1\nline2'), '"line1\nline2"');
assert.equal(csvEscape('a\rb'), '"a\rb"');
assert.equal(csvEscape(null), '');
assert.equal(csvEscape(123), '123');
});
// ===== 状态徽章 =====
test('statusBadge 覆盖全部后端工作流状态', () => {
assert.equal(statusBadge('running'), '<span class="status-badge online">运行中</span>');
assert.equal(statusBadge('completed'), '<span class="status-badge completed">已完成</span>');
assert.equal(statusBadge('initializing'), '<span class="status-badge warning">初始化中</span>');
assert.equal(statusBadge('paused'), '<span class="status-badge secondary">已暂停</span>');
assert.equal(statusBadge('idle'), '<span class="status-badge secondary">闲置</span>');
assert.equal(statusBadge('unknown-status'), '<span class="status-badge secondary">闲置</span>'); // 兜底
});
test('pointStatusBadge 覆盖网格点状态', () => {
assert.equal(pointStatusBadge('converged'), '<span class="status-badge online">已收敛</span>');
assert.equal(pointStatusBadge('failed'), '<span class="status-badge danger">失败</span>');
assert.equal(pointStatusBadge('running'), '<span class="status-badge warning">运行中</span>');
assert.equal(pointStatusBadge('queued'), '<span class="status-badge info">排队中</span>');
assert.equal(pointStatusBadge('pending'), '<span class="status-badge secondary">未开始</span>');
assert.equal(pointStatusBadge('???'), '<span class="status-badge secondary">未知</span>');
});
test('pointMethodBadge 识别收敛途径(与后端 success_method 契约对齐)', () => {
assert.equal(pointMethodBadge('cold_run'), '<span class="method-badge cold">冷启动</span>');
assert.equal(pointMethodBadge('seed_step'), '<span class="method-badge seed">种子步进</span>');
// 其它策略名(synspec-only 任务归因的 success_method,如 "standard")原样展示
assert.equal(pointMethodBadge('standard'), '<span class="method-badge syn">standard</span>');
// 含 HTML 元字符须转义(XSS 防护)
assert.equal(
pointMethodBadge('<img src=x onerror=alert(1)>'),
'<span class="method-badge syn">&lt;img src=x onerror=alert(1)&gt;</span>'
);
assert.equal(pointMethodBadge(null), '<span class="text-hint">—</span>');
assert.equal(pointMethodBadge(''), '<span class="text-hint">—</span>');
});
+135
View File
@@ -0,0 +1,135 @@
/* DCTS 轮询器单测(node:test,零依赖)
*
* utils/polling.js 的 createPoller:指数退避、failCount 重置/增长、stop 清理。
* 通过 stubbing setTimeout / clearTimeout 捕获调度延迟,不真等 5s+。
*
* 时序说明:start() 触发 schedule() 时 fn 是同步调用的(calls 立即生效),
* 后续的 failCount 更新与 setTimeout 排期发生在微任务里——`await p.start()`
* 之后测试续体排在其后,故断言时调度已完成(确定性)。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createPoller } from '../src/utils/polling.js';
// 最小 document stubnode 环境无 DOM,轮询器只在 visibilitychange 处触碰 document。
function installDocStub() {
const doc = { hidden: false, addEventListener() {}, removeEventListener() {} };
const prev = globalThis.document;
globalThis.document = doc;
return { doc, restore() { globalThis.document = prev; } };
}
// 捕获 setTimeout 调用。fire(n) 手动执行第 n 次已排定的回调(await 其完整完成)。
function captureTimers() {
const realSet = globalThis.setTimeout;
const realClear = globalThis.clearTimeout;
const scheduled = [];
globalThis.setTimeout = (fn, ms) => {
const id = { fn, ms };
scheduled.push(id);
return id;
};
globalThis.clearTimeout = (id) => {
const i = scheduled.indexOf(id);
if (i >= 0) scheduled.splice(i, 1);
};
return {
delays: () => scheduled.map(t => t.ms),
fire: async (n) => { await scheduled[n].fn(); },
restore() {
globalThis.setTimeout = realSet;
globalThis.clearTimeout = realClear;
},
};
}
test('start() 立即执行一次 fn', async () => {
const stub = installDocStub();
const timers = captureTimers();
let calls = 0;
const p = createPoller(() => { calls++; return true; }, { baseMs: 5000 });
await p.start();
assert.equal(calls, 1); // 立即跑了一次(fn 同步调用)
assert.equal(p.isRunning(), true);
// 成功后下一轮间隔为 baseMs
assert.deepEqual(timers.delays(), [5000]);
p.stop();
timers.restore();
stub.restore();
});
test('fn 返回 false 触发指数退避,成功后重置', async () => {
const stub = installDocStub();
const timers = captureTimers();
let ok = false;
const p = createPoller(() => ok, { baseMs: 5000, maxMs: 60000 });
await p.start();
// 第一次失败 → failCount=1 → 5k*2^1
assert.deepEqual(timers.delays(), [10000]);
await timers.fire(0); // 第二轮仍失败 → failCount=2
assert.deepEqual(timers.delays().slice(-1), [20000]);
ok = true;
await timers.fire(0); // 第三轮成功 → failCount 重置
assert.deepEqual(timers.delays().slice(-1), [5000]);
p.stop();
timers.restore();
stub.restore();
});
test('fn 抛错同样计为失败', async () => {
const stub = installDocStub();
const timers = captureTimers();
const p = createPoller(() => { throw new Error('boom'); }, { baseMs: 5000 });
await p.start();
assert.deepEqual(timers.delays().slice(-1), [10000]);
p.stop();
timers.restore();
stub.restore();
});
test('stop() 清除挂起定时器且不再调度', async () => {
const stub = installDocStub();
const timers = captureTimers();
let calls = 0;
const p = createPoller(() => { calls++; return true; }, { baseMs: 5000 });
await p.start(); // calls=1,已排下一轮
assert.equal(timers.delays().length, 1);
p.stop();
assert.equal(timers.delays().length, 0); // 挂起定时器已被清除
assert.equal(calls, 1); // fn 未再被调用
assert.equal(p.isRunning(), false);
timers.restore();
stub.restore();
});
test('triggerNow() 立即触发一次并续排', async () => {
const stub = installDocStub();
const timers = captureTimers();
let calls = 0;
const p = createPoller(() => { calls++; return true; }, { baseMs: 5000 });
await p.start(); // calls=1
await p.triggerNow(); // calls=2
assert.equal(calls, 2);
assert.equal(p.isRunning(), true);
p.stop();
timers.restore();
stub.restore();
});
test('退避上限 maxMs 生效(长时间连续失败不无限增长)', async () => {
const stub = installDocStub();
const timers = captureTimers();
const p = createPoller(() => false, { baseMs: 5000, maxMs: 60000, maxFails: 4 });
await p.start();
let maxDelay = 0;
for (let i = 0; i < 10; i++) {
maxDelay = Math.max(maxDelay, timers.delays()[timers.delays().length - 1] || 0);
await timers.fire(0);
}
assert.ok(maxDelay <= 60000, `maxDelay=${maxDelay} 不应超过 60s`);
assert.equal(maxDelay, 60000); // 5k*2^4 clamp 到 60s
p.stop();
timers.restore();
stub.restore();
});
+85
View File
@@ -0,0 +1,85 @@
/* DCTS 平行集合图 sessionStorage 缓存单测(node:test,零依赖)
*
* 覆盖 parSets.js 的 savePsCache / restorePsCache:刷新页面后恢复数据、单槽位清理、
* 损坏数据回退。node 无 sessionStorage,测试前用内存 Map stub。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
// 内存 sessionStorage stubsetItem 可注入配额错误用于 QuotaExceeded 分支)
let store = new Map();
let quotaExceeded = false;
globalThis.sessionStorage = {
get length() { return store.size; },
key: (i) => [...store.keys()][i] ?? null,
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => {
if (quotaExceeded) throw new Error('QuotaExceededError');
store.set(k, String(v));
},
removeItem: (k) => { store.delete(k); },
};
const { savePsCache, restorePsCache } = await import('../src/views/detail/parSets.js');
function ctx(name, points = [], ts = null) {
return { name, psState: { points, ts } };
}
test('savePsCache 写入 JSON 并可由 restorePsCache 恢复', () => {
store.clear();
const before = Date.now();
const c = ctx('sdB_cno', [{ name: 'p1', teff: 20000, status: 'converged' }]);
savePsCache(c);
// 持久化 key 存在
assert.ok(store.has('dcts_ps_points_sdB_cno'));
// 恢复后 points 一致;快照时间由 savePsCache 记录为保存时刻(Date.now 附近)
const c2 = ctx('sdB_cno');
assert.equal(restorePsCache(c2), true);
assert.deepEqual(c2.psState.points, c.psState.points);
assert.ok(Number.isFinite(c2.psState.ts) && c2.psState.ts >= before);
assert.ok(Math.abs(c2.psState.ts - Date.now()) < 5000);
});
test('多工作流缓存并存:切换工作流不删除彼此的缓存', () => {
store.clear();
savePsCache(ctx('wf_a', [{ name: 'a' }]));
savePsCache(ctx('wf_b', [{ name: 'b' }]));
savePsCache(ctx('wf_a', [{ name: 'a2' }])); // 再回 wf_a 更新,writes 覆盖自身 key
// wf_a 与 wf_b 的缓存都保留
assert.ok(store.has('dcts_ps_points_wf_a'));
assert.ok(store.has('dcts_ps_points_wf_b'));
// 各自恢复出各自的数据(不串)
const ca = ctx('wf_a');
const cb = ctx('wf_b');
assert.equal(restorePsCache(ca), true);
assert.equal(restorePsCache(cb), true);
assert.deepEqual(ca.psState.points, [{ name: 'a2' }]); // 更新后是最新值
assert.deepEqual(cb.psState.points, [{ name: 'b' }]);
});
test('无缓存 / 空 key 时 restorePsCache 返回 false', () => {
store.clear();
const c = ctx('no_such_wf');
assert.equal(restorePsCache(c), false);
assert.deepEqual(c.psState.points, []);
});
test('损坏的 JSON / 非数组数据返回 false(降级为重新拉取)', () => {
store.clear();
store.set('dcts_ps_points_bad_json', 'not-json{{{');
store.set('dcts_ps_points_bad_shape', JSON.stringify({ foo: 1 }));
assert.equal(restorePsCache(ctx('bad_json')), false);
assert.equal(restorePsCache(ctx('bad_shape')), false);
});
test('QuotaExceeded 时 savePsCache 静默失败,不抛错', () => {
store.clear();
quotaExceeded = true;
assert.doesNotThrow(() => savePsCache(ctx('big_wf', new Array(10000).fill({ x: 1 }))));
quotaExceeded = false;
});
+259
View File
@@ -0,0 +1,259 @@
/* DCTS wfEnginePanel 阶段配置 YAML 解析/序列化单测(node:test,零依赖)
*
* 覆盖 utils/yamlStage.js 的注释处理、块边界、往返保真——这些是审查 #4/#5
* (顶层注释吞块、行内注释污染解析)修复的回归守护。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
defaultStage,
extractTopBlock,
parseStageFromYaml,
resolveTlustyFromYaml,
serializeStageBlock,
applyStageBlocks,
validateStageConfigs,
} from '../src/utils/yamlStage.js';
test('defaultStagetlusty 默认 [cold_run, seed_step]synspec 默认 [standard]', () => {
assert.deepEqual(defaultStage('tlusty'), {
enabled: true, policy: 'skip_converged', strategies: ['cold_run', 'seed_step'],
});
assert.deepEqual(defaultStage('synspec'), {
enabled: true, policy: 'skip_converged', strategies: ['standard'],
});
});
test('extractTopBlock:顶层注释(列 0 #)终止块,不吞入下一块(审查 #4)', () => {
const yaml = [
'tlusty:',
' enabled: true',
' policy: skip_converged',
'# 这是顶层注释,应终止 tlusty 块',
'synspec_stage:',
' enabled: false',
' policy: force_recompute',
].join('\n');
const t = extractTopBlock(yaml, 'tlusty');
assert.equal(t.lines.length, 3, 'tlusty 块应只含自身 3 行,不含顶层注释与后续块');
assert.ok(!t.lines.some(l => l.includes('synspec_stage')), '不吞入 synspec_stage');
const s = extractTopBlock(yaml, 'synspec_stage');
assert.ok(s, 'synspec_stage 块应能被独立定位(未被 tlusty 吞掉)');
assert.equal(s.lines.length, 3);
});
test('extractTopBlock:空行属块体,缩进注释属块体', () => {
const yaml = [
'tlusty:',
' enabled: true',
'',
' # 缩进注释,属块体',
' policy: skip_converged',
].join('\n');
const t = extractTopBlock(yaml, 'tlusty');
assert.equal(t.lines.length, 5);
});
test('parseStageFromYaml:行内注释不污染 policy 解析(审查 #5', () => {
const yaml = [
'tlusty:',
' enabled: true',
' policy: skip_converged # 旧值 force_recompute 已废弃',
' strategies: [cold_run, seed_step]',
].join('\n');
const t = parseStageFromYaml(yaml, 'tlusty');
assert.equal(t.policy, 'skip_converged', '应取真值而非注释里的 force_recompute');
assert.equal(t.enabled, true);
assert.deepEqual(t.strategies, ['cold_run', 'seed_step']);
});
test('parseStageFromYaml:整行注释里的 policy 不被当真值(审查 #5', () => {
const yaml = [
'tlusty:',
' # policy: force_recompute <- 注释掉的旧值',
' policy: skip_failed',
' strategies: [cold_run]',
].join('\n');
const t = parseStageFromYaml(yaml, 'tlusty');
assert.equal(t.policy, 'skip_failed', '应跳过整行注释,取真值 skip_failed');
});
test('parseStageFromYamlenabled 行内注释', () => {
const yaml = [
'synspec_stage:',
' enabled: false # 仅计算大气',
' policy: skip_converged',
' strategies: [standard]',
].join('\n');
const s = parseStageFromYaml(yaml, 'synspec_stage');
assert.equal(s.enabled, false, 'enabled=false 应正确解析(注释不干扰)');
});
test('parseStageFromYaml:块式 strategies 列表', () => {
const yaml = [
'tlusty:',
' enabled: true',
' policy: skip_converged',
' strategies:',
' - cold_run',
' - seed_step',
].join('\n');
const t = parseStageFromYaml(yaml, 'tlusty');
assert.deepEqual(t.strategies, ['cold_run', 'seed_step']);
});
test('parseStageFromYaml:块不存在返回 null', () => {
const yaml = 'grid:\n teff: [20000]\n';
assert.equal(parseStageFromYaml(yaml, 'tlusty'), null);
});
test('resolveTlustyFromYaml:无 tlusty 块时按 seed_step_fallback 推断(审查 #2 修复)', () => {
// seed_step_fallback: false → [cold_run](不回退),与服务端 resolve_tlusty_config 同口径。
const yamlFalse = [
'grid:',
' teff: [20000]',
'seed_step_fallback: false',
'synspec:',
' wstart: 3000.0',
].join('\n');
assert.deepEqual(resolveTlustyFromYaml(yamlFalse), {
enabled: true, policy: 'skip_converged', strategies: ['cold_run'],
}, 'seed_step_fallback:false 应推断 [cold_run](面板保存不得静默启用回退)');
// seed_step_fallback: true → 默认链 [cold_run, seed_step]。
const yamlTrue = 'seed_step_fallback: true\n';
assert.deepEqual(resolveTlustyFromYaml(yamlTrue), defaultStage('tlusty'));
// 缺省(无该字段)→ 默认链。
const yamlAbsent = 'grid:\n teff: [20000]\n';
assert.deepEqual(resolveTlustyFromYaml(yamlAbsent), defaultStage('tlusty'));
});
test('resolveTlustyFromYaml:有 tlusty 块时优先块(不受旧字段干扰)', () => {
const yaml = [
'tlusty:',
' enabled: true',
' policy: force_recompute',
' strategies: [cold_run]',
'seed_step_fallback: true',
].join('\n');
assert.deepEqual(resolveTlustyFromYaml(yaml), {
enabled: true, policy: 'force_recompute', strategies: ['cold_run'],
}, '显式 tlusty 块优先');
});
test('resolveTlustyFromYaml:注释行不误判 seed_step_fallback', () => {
const yaml = '# seed_step_fallback: false(注释,非真实配置)\ngrid:\n teff: [20000]\n';
assert.deepEqual(resolveTlustyFromYaml(yaml), defaultStage('tlusty'),
'注释行被忽略,按缺省推断默认链');
});
test('applyStageBlocks:替换已存在块,保留其他配置(往返保真)', () => {
const yaml = [
'grid:',
' teff: [20000]',
'timeout_sec: 7200',
'tlusty:',
' enabled: true',
' policy: skip_converged',
' strategies: [cold_run]',
'synspec_stage:',
' enabled: true',
' policy: skip_converged',
' strategies: [standard]',
].join('\n');
const out = applyStageBlocks(yaml,
{ enabled: false, policy: 'force_recompute', strategies: ['seed_step'] },
{ enabled: true, policy: 'skip_converged', strategies: ['standard'] },
);
// grid 与 timeout_sec 必须保留。
assert.match(out, /teff: \[20000\]/);
assert.match(out, /timeout_sec: 7200/);
// tlusty 块被替换为新值。
assert.match(out, /tlusty:\n enabled: false\n policy: force_recompute\n strategies: \[seed_step\]/);
// synspec_stage 块保留/替换正确。
assert.match(out, /synspec_stage:\n enabled: true\n policy: skip_converged\n strategies: \[standard\]/);
});
test('applyStageBlocks:块不存在时追加到末尾', () => {
const yaml = 'grid:\n teff: [20000]\n';
const out = applyStageBlocks(yaml, defaultStage('tlusty'), defaultStage('synspec'));
assert.match(out, /tlusty:/);
assert.match(out, /synspec_stage:/);
assert.match(out, /teff: \[20000\]/, '原配置保留');
});
test('applyStageBlocks:两块间有顶层注释时不互相破坏(审查 #4 回归)', () => {
const yaml = [
'tlusty:',
' enabled: true',
' policy: skip_converged',
' strategies: [cold_run, seed_step]',
'# 分隔注释',
'synspec_stage:',
' enabled: true',
' policy: skip_converged',
' strategies: [standard]',
].join('\n');
// 修改 tlusty 不应破坏 synspec_stage(旧实现会把注释+synspec 吞进 tlusty 块后覆盖丢失)。
const out = applyStageBlocks(yaml,
{ enabled: false, policy: 'skip_converged', strategies: ['cold_run'] },
defaultStage('synspec'),
);
assert.match(out, /synspec_stage:\n enabled: true/, 'synspec_stage 块必须存活');
assert.match(out, /tlusty:\n enabled: false/, 'tlusty 块已更新');
});
test('serializeStageBlockflow 序列输出格式', () => {
const s = serializeStageBlock('tlusty', {
enabled: true, policy: 'skip_converged', strategies: ['cold_run', 'seed_step'],
});
assert.equal(s, 'tlusty:\n enabled: true\n policy: skip_converged\n strategies: [cold_run, seed_step]');
});
test('端到端往返:解析 → 序列化 → 再解析 保持语义', () => {
const orig = [
'tlusty:',
' enabled: true',
' policy: force_recompute',
' strategies: [cold_run, seed_step]',
].join('\n');
const parsed = parseStageFromYaml(orig, 'tlusty');
const serialized = serializeStageBlock('tlusty', parsed);
const reparsed = parseStageFromYaml(serialized, 'tlusty');
assert.deepEqual(parsed, reparsed, '往返保真');
});
test('validateStageConfigs:双阶段全关拦截(修复审查 #5)', () => {
const err = validateStageConfigs(
{ enabled: false, policy: 'skip_converged', strategies: ['cold_run'] },
{ enabled: false, policy: 'skip_converged', strategies: ['standard'] },
);
assert.ok(err && err.includes('至少应启用一个计算阶段'), `应拦截双关,实际: ${err}`);
assert.equal(validateStageConfigs(
{ enabled: true, policy: 'skip_converged', strategies: ['cold_run'] },
{ enabled: false, policy: 'skip_converged', strategies: ['standard'] },
), null, '单阶段启用合法');
});
test('validateStageConfigs:启用阶段空策略链拦截(修复审查 #4)', () => {
const tlustyErr = validateStageConfigs(
{ enabled: true, policy: 'skip_converged', strategies: [] },
{ enabled: true, policy: 'skip_converged', strategies: ['standard'] },
);
assert.ok(tlustyErr && tlustyErr.includes('TLUSTY'), `应拦截 TLUSTY 空链,实际: ${tlustyErr}`);
const synspecErr = validateStageConfigs(
{ enabled: true, policy: 'skip_converged', strategies: ['cold_run'] },
{ enabled: true, policy: 'skip_converged', strategies: [] },
);
assert.ok(synspecErr && synspecErr.includes('SYNSPEC'), `应拦截 SYNSPEC 空链,实际: ${synspecErr}`);
// 禁用阶段的空链不拦截(该阶段不执行)。
assert.equal(validateStageConfigs(
{ enabled: false, policy: 'skip_converged', strategies: [] },
{ enabled: true, policy: 'skip_converged', strategies: ['standard'] },
), null, '禁用阶段空链合法');
});
+11
View File
@@ -13,5 +13,16 @@ export default defineConfig({
build: {
outDir: 'dist',
emptyOutDir: true,
// 项目零第三方依赖、无 polyfill 需求,目标锁定现代浏览器:
// es2022 允许更紧凑的语法(optional chaining 等不再转译)。
target: 'es2022',
// CSS 中使用了 light-dark()Chrome 123+/FF 120+/Safari 17.5+),
// 告知 esbuild 压缩器该语法已被目标支持,避免降级/告警。
cssTarget: 'chrome123',
// 首屏 CSS 独立产物;JS 分包由 router 的动态 import(首页/详情页)天然完成,
// 无第三方 vendor 可做 manualChunks。
cssCodeSplit: true,
// 详情页含 Parallel Sets 手写 SVG 渲染逻辑,bundle 较大;放宽告警阈值避免噪音。
chunkSizeWarningLimit: 600,
},
});