feat(all): 科学计算正确性修复、调度竞态消除、节点优雅退出、安全加固与收敛分析重构
科学计算正确性: - 修复 Fortran 无-E 科学记数法(指数≥100 时 E 被挤掉,如 -1.35+118)导致 发散行被静默跳过、误判收敛的 bug;扩展大气无效检测覆盖 Inf 与 *** 溢出标记 - 种子匹配改为 CNO 有向距离(富金属方向重罚 4×、贫金属方向轻罚 1×), 基于 1191 个真实种子配对回测标定,回测净改善 314 个点 - GridAxisValue 反序列化拒绝非法文本(不再静默 NaN);chmax≤0 显式报错 - ions 行宽列宽对齐真实 fort.5 格式 调度与队列竞态: - 原子选点(IMMEDIATE 事务 SELECT+UPDATE)消除并发调度重复派发 (#5) - 调度互斥锁 + 冷启动优先策略(SeedStep 仅作失败后救援,不再正常路径热启动) - 毒消息 dead_letter 标记防出队死循环;clear_queue 保留 claimed 行 (#6) - 孤儿 running 点回收兜底;stale_sec 默认 7800→21600s(3 倍超时缓冲) 节点生命周期: - SIGTERM+SIGINT 双信号监听(修复 Docker stop 发 SIGTERM 不触发优雅退出) - SlotGuard RAII 防活动 slot 泄漏;子进程超时增加二级 30s wait 防 Fortran hang - SeedStep 种子下载 fail-fast + 沙盒私有副本解耦 LRU 清理竞争 - reqwest Client 增加连接/请求超时;启动清理残留 task_* 沙盒 安全加固: - 节点注册 registration_secret 二次凭据 (H8),恒定时间比对防时序旁路 - token 缓存 generation 机制消除 reissue 后旧 token TOCTOU 复活窗口 - 新增 /api/auth/logout 服务端 session 即时撤销;fail-closed 鉴权启动策略 - 前端 token 迁移 sessionStorage;YAML 高亮改 DOM API 消除 XSS 注入面 - 备份文件权限收紧 0600;点表动态值全面 escapeHtml 前端 Dashboard: - 收敛性分析从热力图重构为 Parallel Sets 平行集合图(6 维+状态轴,手写 SVG 零依赖) - 进度曲线横轴改为真实时间(服务端 now 锚定,停滞期诚实留白);轮询指数退避 - 移除 imported 收敛途径分类,导入点按实际途径 cold_run/seed_step 归类 - 初始化时 /api/auth/check 校验 token;401 toast 提示替代静默 reload 服务端恢复与工具链: - 启动恢复 initializing 态工作流;默认工作流 INSERT-only 不覆盖 API 编辑 - body limit 分层(10MB 不再截断 256MB report);multipart 显式错误处理 - 嵌入二进制原子写(tmp+rename)防半写损坏 - import_results 判定收敛途径透传 success_method;conv.json 格式对齐本项目 - push_import_results.sh 退出码修复 + .bat UTF-8 BOM + scp 上传 - Docker USE_MIRRORS 默认关闭;移除无用 assets 挂载;删除 hosts.ini 入库
This commit is contained in:
+86
-15
@@ -1,17 +1,35 @@
|
||||
/* DCTS Dashboard API & Authentication Module */
|
||||
|
||||
import { showToast } from './components/toast.js';
|
||||
|
||||
// 安全:token 存储在 sessionStorage 而非 localStorage。
|
||||
// sessionStorage 生命周期仅限当前标签页会话(关闭即失效),缩小了 XSS 窃取后的利用窗口;
|
||||
// localStorage 会跨标签页/重启长期持久化,一旦泄露(配合 CSP 的 'unsafe-inline')即等于
|
||||
// 长期账号接管。服务端 session 仍有 24h 过期 + 容量淘汰,前端不再持有永久凭据。
|
||||
const TOKEN_KEY = 'dcts_admin_token';
|
||||
|
||||
export function getAdminToken() {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
try {
|
||||
return sessionStorage.getItem(TOKEN_KEY);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setAdminToken(token) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
try {
|
||||
sessionStorage.setItem(TOKEN_KEY, token);
|
||||
} catch (_) {
|
||||
/* sessionStorage 不可用时静默,鉴权将走 401 流程 */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAdminToken() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
try {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
} catch (_) {
|
||||
/* 同上 */
|
||||
}
|
||||
}
|
||||
|
||||
// 安全 HTML 转义,防止存储型 XSS
|
||||
@@ -25,7 +43,14 @@ export function escapeHtml(s) {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 统一 fetch 包装:自动注入 Authorization 头,支持 10s 超时处理与 401 处理
|
||||
// 401 处理去重:并发请求(如详情页 Promise.allSettled 三路拉取)可能各自命中 401 并
|
||||
// 各触发一次 location.reload,竞态下不规范。用一个模块级 guard 保证只处理一次。
|
||||
let handling401 = false;
|
||||
|
||||
/**
|
||||
* 统一 fetch 包装:自动注入 Authorization 头,支持超时处理与 401 处理。
|
||||
* 401 时清 token、提示会话过期并跳回登录,而非静默 reload(避免详情页突然闪回无说明)。
|
||||
*/
|
||||
export async function apiFetch(url, opts = {}, timeoutMs = 10000) {
|
||||
const token = getAdminToken();
|
||||
const headers = { ...(opts.headers || {}) };
|
||||
@@ -35,30 +60,76 @@ export async function apiFetch(url, opts = {}, timeoutMs = 10000) {
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const signal = opts.signal || controller.signal;
|
||||
// 调用方传入的 signal(如视图卸载 abortCtl)转发给内部 controller,
|
||||
// 否则 fetch 绑定的是调用方 signal,内部 timeoutMs 兜底会失效(请求挂起无法超时)。
|
||||
const callerSignal = opts.signal;
|
||||
const onCallerAbort = () => controller.abort();
|
||||
if (callerSignal) {
|
||||
if (callerSignal.aborted) {
|
||||
controller.abort();
|
||||
} else {
|
||||
callerSignal.addEventListener('abort', onCallerAbort, { once: true });
|
||||
}
|
||||
}
|
||||
// fetch 统一用内部 controller.signal:既能响应超时,也能响应调用方 abort。
|
||||
const { signal: _ignored, ...optsWithoutSignal } = opts;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { ...opts, headers, signal });
|
||||
const res = await fetch(url, { ...optsWithoutSignal, headers, signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
if (res.status === 401) {
|
||||
if (res.status === 401 && !handling401) {
|
||||
handling401 = true;
|
||||
clearAdminToken();
|
||||
location.reload();
|
||||
// 延迟提示让 toast 可见,再回到登录视图
|
||||
try {
|
||||
showToast('会话已过期,请重新登录', 'error');
|
||||
} catch (_) {
|
||||
/* toast 不可用时跳过 */
|
||||
}
|
||||
setTimeout(() => {
|
||||
location.hash = '';
|
||||
location.reload();
|
||||
}, 800);
|
||||
}
|
||||
return res;
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
throw err;
|
||||
} finally {
|
||||
// 清理调用方 signal 监听,避免泄漏(尤其长期存活的 abortCtl)。
|
||||
if (callerSignal) callerSignal.removeEventListener('abort', onCallerAbort);
|
||||
}
|
||||
}
|
||||
|
||||
// API 请求接口定义
|
||||
export async function loginAdmin(password) {
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
return res;
|
||||
// loginAdmin 走统一超时包装,避免裸 fetch 在网络挂起时永不返回、按钮永久 disabled。
|
||||
export async function loginAdmin(password, timeoutMs = 10000) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
return res;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出:调用后端 /api/auth/logout 撤销当前 session(服务端立即失效),
|
||||
* 再清除前端 token。即便 token 已被窃取,登出也能阻断重放(不再依赖 24h 自然过期)。
|
||||
*/
|
||||
export async function logoutAdmin() {
|
||||
try {
|
||||
await apiFetch('/api/auth/logout', { method: 'POST' }, 5000);
|
||||
} catch (_) {
|
||||
/* 网络异常时仍清前端 token,不阻断登出交互 */
|
||||
}
|
||||
clearAdminToken();
|
||||
}
|
||||
|
||||
export async function fetchClusterStatus() {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* #modal-view-wf 排除出全局关闭回路),以便统一走脏状态守卫。
|
||||
*/
|
||||
|
||||
import { escapeHtml, getWorkflowDetailApi, updateWorkflowApi } from '../api.js';
|
||||
import { getWorkflowDetailApi, updateWorkflowApi } from '../api.js';
|
||||
import { showToast } from './toast.js';
|
||||
import { showConfirm, setupFocusTrap, releaseFocusTrap } from './modal.js';
|
||||
import { fetchAllData } from '../state.js';
|
||||
@@ -38,8 +38,27 @@ const editorState = {
|
||||
};
|
||||
|
||||
// ===== 语法高亮(轻量 YAML 分词:键/数字/布尔/字符串/注释/列表符) =====
|
||||
//
|
||||
// 安全:查看模式渲染来自服务端的 config_yaml(管理员可写,理论上是可信源,但
|
||||
// 历史导入/未来扩展可能引入不可信内容)。历史上 tokenizeLine 用正则分词后拼成
|
||||
// HTML 字符串再 innerHTML 注入,这是脆弱的注入面——分词器对多行字符串/锚点/块标量
|
||||
// 覆盖不全,一旦在转义边界产生未闭合 <span> 或 <> 序列即可执行脚本,配合 sessionStorage
|
||||
// 里的 admin token 构成 XSS → 接管链路。
|
||||
//
|
||||
// 现改为用 DOM API 逐 token 构造节点:所有文本片段一律走 textContent(浏览器自动转义),
|
||||
// span 标签用 createElement 创建,杜绝任何字符串拼接到 innerHTML 的路径。
|
||||
|
||||
function tokenizeLine(rawLine) {
|
||||
// 辅助:创建一个带 class 的 span,textContent 为给定文本(绝不拼接 HTML)。
|
||||
function spanEl(cls, text) {
|
||||
const el = document.createElement('span');
|
||||
if (cls) el.className = cls;
|
||||
el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
|
||||
// 对一行做分词,返回 DOM 节点数组(文本节点与 span 交替),全部经 textContent 注入。
|
||||
function tokenizeLineNodes(rawLine) {
|
||||
const nodes = [];
|
||||
let code = rawLine;
|
||||
let comment = '';
|
||||
// 注释:整行注释或行尾 " # ..."(配置场景足够,不处理引号内 # 的极端情形)
|
||||
@@ -48,25 +67,71 @@ function tokenizeLine(rawLine) {
|
||||
code = code.slice(0, cm.index + cm[1].length);
|
||||
comment = cm[2];
|
||||
}
|
||||
let s = escapeHtml(code);
|
||||
s = s.replace(/^(\s*)(-)(\s)/, '$1<span class="tk-dash">$2</span>$3');
|
||||
s = s.replace(/(^|[\s{[])([A-Za-z_][A-Za-z0-9_.\-]*)(\s*:)/g, '$1<span class="tk-key">$2</span>$3');
|
||||
s = s.replace(/(:\s*|[\[,]\s*)(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)(?=[\s,\]}]|$)/g, '$1<span class="tk-num">$2</span>');
|
||||
s = s.replace(/(:\s*|[\[,]\s*)(true|false|null|T|F)\b/g, '$1<span class="tk-bool">$2</span>');
|
||||
s = s.replace(/(".*?"|'.*?')/g, '<span class="tk-str">$1</span>');
|
||||
if (comment) {
|
||||
s += `<span class="tk-comment">${escapeHtml(comment)}</span>`;
|
||||
|
||||
// 对 code 部分做增量扫描:键 / 数字 / 布尔 / 字符串 / 列表符 / 普通文本。
|
||||
// 用正则定位各类 token 的起止,把"普通文本"段作为裸 textNode 插入,token 段包进 span。
|
||||
//
|
||||
// 数字/布尔分支用零宽 lookbehind(`(?<=...)`)而非消费式前缀,避免键分支 `[A-Za-z_]...\s*:`
|
||||
// 把 ":" 消费后数字分支的 `(:\s*)` 前缀失效导致数字/布尔不高亮(历史回归)。lookbehind 在
|
||||
// Chrome 62+/Firefox 116+/Safari 16.4+ 支持,Vite 默认 esbuild 目标覆盖。
|
||||
// 数字/布尔前缀允许 `:\s*`(键值后)或 `[,]`/`[` 后可选空白(YAML flow 序列,含无空白 `[1,2,3]`)。
|
||||
const tokenRegex = /(^\s*-\s)|([A-Za-z_][A-Za-z0-9_.\-]*\s*:)|(?<=:\s*|[,[]\s*)(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)(?=[\s,\]}]|$)|(?<=:\s*|[,[]\s*)(true|false|null|T|F)\b|("[^"]*"|'[^']*')/g;
|
||||
let last = 0;
|
||||
let m;
|
||||
while ((m = tokenRegex.exec(code)) !== null) {
|
||||
if (m.index > last) {
|
||||
nodes.push(document.createTextNode(code.slice(last, m.index)));
|
||||
}
|
||||
if (m[1]) {
|
||||
// 列表符 "- "
|
||||
nodes.push(spanEl('tk-dash', m[1]));
|
||||
} else if (m[2]) {
|
||||
// 键 "key:"
|
||||
nodes.push(spanEl('tk-key', m[2]));
|
||||
} else if (m[3]) {
|
||||
// 数字(含 flow 序列内的,如 [1, 2, 3])
|
||||
nodes.push(spanEl('tk-num', m[3]));
|
||||
} else if (m[4]) {
|
||||
// 布尔 true/false/null/T/F
|
||||
nodes.push(spanEl('tk-bool', m[4]));
|
||||
} else if (m[5]) {
|
||||
// 字符串字面量
|
||||
nodes.push(spanEl('tk-str', m[5]));
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
return s;
|
||||
if (last < code.length) {
|
||||
nodes.push(document.createTextNode(code.slice(last)));
|
||||
}
|
||||
if (comment) {
|
||||
nodes.push(spanEl('tk-comment', comment));
|
||||
}
|
||||
if (nodes.length === 0) {
|
||||
nodes.push(document.createTextNode(' '));
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function highlightYaml(text) {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line, i) =>
|
||||
`<div class="yaml-line"><span class="yaml-ln">${i + 1}</span><span class="yaml-code">${tokenizeLine(line) || ' '}</span></div>`
|
||||
)
|
||||
.join('');
|
||||
// 渲染整段 YAML 到容器(清空后逐行 append 节点,不使用 innerHTML)。
|
||||
function renderYamlView(container, text) {
|
||||
// 用 textContent 清空最稳妥(移除所有子节点与可能残留的事件)。
|
||||
container.textContent = '';
|
||||
const lines = text.split('\n');
|
||||
const frag = document.createDocumentFragment();
|
||||
lines.forEach((line, i) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'yaml-line';
|
||||
const ln = document.createElement('span');
|
||||
ln.className = 'yaml-ln';
|
||||
ln.textContent = String(i + 1);
|
||||
const code = document.createElement('span');
|
||||
code.className = 'yaml-code';
|
||||
tokenizeLineNodes(line).forEach((n) => code.appendChild(n));
|
||||
row.appendChild(ln);
|
||||
row.appendChild(code);
|
||||
frag.appendChild(row);
|
||||
});
|
||||
container.appendChild(frag);
|
||||
}
|
||||
|
||||
// ===== 面板填充与模式切换 =====
|
||||
@@ -96,7 +161,9 @@ function switchToView() {
|
||||
const editPane = document.getElementById('yaml-edit-pane');
|
||||
viewPane?.classList.remove('hidden');
|
||||
editPane?.classList.add('hidden');
|
||||
document.getElementById('view-wf-yaml').innerHTML = highlightYaml(editorState.original);
|
||||
// 安全:用 DOM 节点构造渲染,不使用 innerHTML(防 YAML 内容注入 XSS)。
|
||||
const viewContainer = document.getElementById('view-wf-yaml');
|
||||
if (viewContainer) renderYamlView(viewContainer, editorState.original);
|
||||
hideEditError();
|
||||
|
||||
const locked = LOCKED_STATUSES.includes(editorState.status);
|
||||
|
||||
+27
-7
@@ -10,6 +10,8 @@ import {
|
||||
setAdminToken,
|
||||
clearAdminToken,
|
||||
loginAdmin,
|
||||
logoutAdmin,
|
||||
apiFetch,
|
||||
createWorkflow
|
||||
} from './api.js';
|
||||
|
||||
@@ -96,7 +98,7 @@ async function handleLoginSubmit(password) {
|
||||
}
|
||||
|
||||
// ===== DOM 初始化与全局事件接线 =====
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const btnCreateWf = document.getElementById('btn-create-wf');
|
||||
const modalCreateWf = document.getElementById('modal-create-wf');
|
||||
const modalReissueToken = document.getElementById('modal-reissue-token');
|
||||
@@ -214,7 +216,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('btn-logout')?.addEventListener('click', async () => {
|
||||
const ok = await showConfirm('退出登录', '确定要登出并清除本页面的 Admin Token 吗?');
|
||||
if (ok) {
|
||||
clearAdminToken();
|
||||
// 先撤销服务端 session(使 token 立即失效,防重放),再清前端并刷新。
|
||||
await logoutAdmin();
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
@@ -256,11 +259,28 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
handleLoginSubmit(pass);
|
||||
});
|
||||
|
||||
// 初始化鉴权判断:已登录直接按当前 hash 渲染对应视图
|
||||
if (getAdminToken()) {
|
||||
document.getElementById('login-overlay')?.classList.add('hidden');
|
||||
document.getElementById('btn-logout')?.classList.remove('hidden');
|
||||
startRouter();
|
||||
// 初始化鉴权判断:校验 sessionStorage 里的 token 是否仍被服务端认可。
|
||||
// 旧实现仅凭 token 存在就直接进入,过期/被淘汰的 token 会"假登录"一瞬后被轮询 401 闪回。
|
||||
// 现改为先调 /api/auth/check 验证,失败则清 token 显示登录遮罩,成功才 startRouter。
|
||||
const existingToken = getAdminToken();
|
||||
if (existingToken) {
|
||||
try {
|
||||
const checkRes = await apiFetch('/api/auth/check', {}, 8000);
|
||||
if (checkRes.ok) {
|
||||
document.getElementById('login-overlay')?.classList.add('hidden');
|
||||
document.getElementById('btn-logout')?.classList.remove('hidden');
|
||||
startRouter();
|
||||
} else {
|
||||
// 401 等:token 已失效,清掉显示登录
|
||||
clearAdminToken();
|
||||
document.getElementById('btn-logout')?.classList.add('hidden');
|
||||
}
|
||||
} catch (_) {
|
||||
// 网络异常:乐观进入(保持可用性),后续轮询会自然 401 处理
|
||||
document.getElementById('login-overlay')?.classList.add('hidden');
|
||||
document.getElementById('btn-logout')?.classList.remove('hidden');
|
||||
startRouter();
|
||||
}
|
||||
} else {
|
||||
document.getElementById('btn-logout')?.classList.add('hidden');
|
||||
}
|
||||
|
||||
+12
-1
@@ -15,7 +15,18 @@ let current = null; // { view: 'home' | 'workflow', name?: string }
|
||||
export function parseHash() {
|
||||
const h = location.hash.replace(/^#/, '');
|
||||
const m = h.match(/^\/workflows\/([^/?#]+)\/?$/);
|
||||
if (m) return { view: 'workflow', name: decodeURIComponent(m[1]) };
|
||||
if (m) {
|
||||
// decodeURIComponent 对畸形百分号编码(如截断的 %E0%A4%A)会抛 URIError;
|
||||
// 若不捕获,整个 hashchange 监听里的 render 会失败、视图不再渲染。
|
||||
// 解码失败时回退到原始片段(仍可路由),避免崩溃。
|
||||
let name = m[1];
|
||||
try {
|
||||
name = decodeURIComponent(m[1]);
|
||||
} catch (e) {
|
||||
console.warn('工作流名解码失败,使用原始片段:', e);
|
||||
}
|
||||
return { view: 'workflow', name };
|
||||
}
|
||||
return { view: 'home' };
|
||||
}
|
||||
|
||||
|
||||
+20
-3
@@ -6,6 +6,11 @@ import { renderWorkflows } from './components/workflows.js';
|
||||
|
||||
let isPolling = false;
|
||||
let pollTimer = null;
|
||||
// 连续失败退避计数:服务端持续不可达时拉长轮询间隔(指数退避,上限 60s),
|
||||
// 避免空轮询刷请求;恢复正常即重置为基准 5s。
|
||||
let pollFailCount = 0;
|
||||
// 最近一次 cluster 状态拉取是否成功(由 updateServerStatus 维护,供 fetchAllData 判定退避)。
|
||||
let serverOnline = false;
|
||||
|
||||
// 监听浏览器 Tab 标签页切换,切入后台时暂停轮询,切回前台时恢复并立即刷新
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
@@ -44,18 +49,29 @@ export async function scheduleFetchData() {
|
||||
}
|
||||
if (document.hidden) return;
|
||||
|
||||
await fetchAllData();
|
||||
const ok = await fetchAllData();
|
||||
if (ok) {
|
||||
pollFailCount = 0;
|
||||
} else {
|
||||
pollFailCount = Math.min(pollFailCount + 1, 4);
|
||||
}
|
||||
// 指数退避:5s * 2^failCount,clamp 到 [5s, 60s]
|
||||
const delay = Math.min(5000 * Math.pow(2, pollFailCount), 60000);
|
||||
if (isPolling && !document.hidden) {
|
||||
pollTimer = setTimeout(scheduleFetchData, 5000);
|
||||
pollTimer = setTimeout(scheduleFetchData, delay);
|
||||
}
|
||||
}
|
||||
|
||||
// 返回主状态拉取是否成功(用于轮询退避计数)。三项并行拉取,
|
||||
// 以 cluster 状态能否成功获取作为整体健康判定。
|
||||
export async function fetchAllData() {
|
||||
await Promise.all([
|
||||
const results = await Promise.allSettled([
|
||||
fetchStatus(),
|
||||
fetchWorkflows(),
|
||||
fetchNodes(),
|
||||
]);
|
||||
// fetchStatus 内部已 try/catch,rejected 仅在极异常情况;以第一项(状态)成败为准。
|
||||
return results[0].status === 'fulfilled' && serverOnline;
|
||||
}
|
||||
|
||||
export async function fetchStatus() {
|
||||
@@ -92,6 +108,7 @@ export async function fetchNodes() {
|
||||
}
|
||||
|
||||
export function updateServerStatus(online) {
|
||||
serverOnline = !!online;
|
||||
const box = document.getElementById('status-indicator-box');
|
||||
const text = document.getElementById('server-status-text');
|
||||
if (!box || !text) return;
|
||||
|
||||
+137
-97
@@ -1131,12 +1131,6 @@ body::before {
|
||||
border-color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.method-badge.imported {
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-sunken);
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
|
||||
.method-badge.fail {
|
||||
color: var(--color-danger);
|
||||
background: var(--color-danger-bg);
|
||||
@@ -1276,115 +1270,160 @@ body::before {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 收敛性热力图 */
|
||||
.heat-controls {
|
||||
/* 收敛性联合分析(Parallel Sets 平行集合图) */
|
||||
.ps-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
margin-bottom: var(--space-3);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.ps-summary {
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.ps-hint { margin-bottom: var(--space-3); }
|
||||
.ps-container { overflow-x: auto; }
|
||||
.ps {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.heat-controls select,
|
||||
.heat-slice-selects select {
|
||||
background: var(--bg-surface);
|
||||
/* 色带:按状态着色,半透明叠加显示流量密度;悬停高亮某状态时其余压暗 */
|
||||
.ps-ribbon {
|
||||
fill-opacity: 0.45;
|
||||
stroke: none;
|
||||
transition: fill-opacity var(--transition-fast);
|
||||
cursor: crosshair;
|
||||
}
|
||||
.ps-ribbon.ps-hit { fill-opacity: 0.85; }
|
||||
.ps-ribbon.ps-dim { fill-opacity: 0.08; }
|
||||
|
||||
/* 轴色块分段(按状态) */
|
||||
.ps-seg {
|
||||
transition: fill-opacity var(--transition-fast);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ps-seg.ps-hit { fill-opacity: 1; }
|
||||
.ps-seg.ps-dim { fill-opacity: 0.12; }
|
||||
/* 状态配色(与 method-badge/status-badge 配色族一致) */
|
||||
.ps-cold { fill: var(--color-success); }
|
||||
.ps-seed { fill: var(--accent-purple); }
|
||||
.ps-failed { fill: var(--color-danger); }
|
||||
.ps-running { fill: var(--color-warning); }
|
||||
.ps-queued { fill: var(--color-info); }
|
||||
.ps-pending { fill: var(--text-muted); }
|
||||
/* 色带默认 fill-opacity 已定,色块需不透明 → 用 stroke 不可,故色块状态类同时设 fill */
|
||||
.ps-seg.ps-cold { fill: var(--color-success); }
|
||||
.ps-seg.ps-seed { fill: var(--accent-purple); }
|
||||
.ps-seg.ps-failed { fill: var(--color-danger); }
|
||||
.ps-seg.ps-running { fill: var(--color-warning); }
|
||||
.ps-seg.ps-queued { fill: var(--color-info); }
|
||||
.ps-seg.ps-pending { fill: var(--text-muted); }
|
||||
|
||||
.ps-axis-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
fill: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ps-vlabel {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
fill: var(--text-muted);
|
||||
}
|
||||
/* 状态轴取值标签染色 = 自带图例(与色块/色带配色一致) */
|
||||
.ps-vlabel-cold { fill: var(--color-success); font-weight: 600; }
|
||||
.ps-vlabel-seed { fill: var(--accent-purple); font-weight: 600; }
|
||||
.ps-vlabel-failed { fill: var(--color-danger); font-weight: 600; }
|
||||
.ps-vlabel-running { fill: var(--color-warning); font-weight: 600; }
|
||||
.ps-vlabel-queued { fill: var(--color-info); font-weight: 600; }
|
||||
.ps-vlabel-pending { fill: var(--text-muted); font-weight: 600; }
|
||||
|
||||
/* 自动洞察结论 */
|
||||
.ps-conclusion {
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.ps-concl-line:first-child { font-weight: 600; }
|
||||
.ps-concl-line:nth-child(n+2) { color: var(--color-danger); }
|
||||
|
||||
/* 悬停 tooltip */
|
||||
.ps-tooltip {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
max-width: 320px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
|
||||
.heat-slice-selects {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.heatmap-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.heatmap {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.heatmap-corner {
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.heatmap-collabel,
|
||||
.heatmap-rowlabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
padding: 2px 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.heatmap-cell {
|
||||
height: 26px;
|
||||
min-width: 26px;
|
||||
border-radius: var(--radius-xs);
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.heatmap-cell:hover {
|
||||
transform: scale(1.18);
|
||||
box-shadow: var(--shadow-md);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 格子颜色 = 状态 + 手段(与图例 .hc 共享) */
|
||||
.hc-cold { background: var(--color-success); }
|
||||
.hc-seed { background: var(--accent-purple); }
|
||||
.hc-imported { background: var(--text-muted); }
|
||||
.hc-failed { background: var(--color-danger); }
|
||||
.hc-running { background: var(--color-warning); }
|
||||
.hc-pending { background: var(--bg-sunken); border: 1px solid var(--border-subtle); }
|
||||
.hc-empty { background: transparent; border: 1px dashed var(--border-subtle); cursor: default; }
|
||||
.hc-empty:hover { transform: none; box-shadow: none; }
|
||||
|
||||
.heat-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.heat-legend-item {
|
||||
display: inline-flex;
|
||||
/* 点击色块的点名列表浮层 */
|
||||
.ps-pointlist {
|
||||
position: fixed;
|
||||
z-index: 1001;
|
||||
width: min(340px, 90vw);
|
||||
max-height: 60vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-elevated);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.ps-pointlist-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.heat-legend-item .hc {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.heat-conclusion {
|
||||
margin-top: var(--space-3);
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.heat-axis-note {
|
||||
margin-top: var(--space-2);
|
||||
.ps-pointlist-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
display: flex;
|
||||
}
|
||||
.ps-pointlist-close:hover { color: var(--text-primary); }
|
||||
.ps-pointlist-body {
|
||||
padding: var(--space-2);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.ps-pointlink {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--accent-blueprint);
|
||||
cursor: pointer;
|
||||
padding: 3px var(--space-2);
|
||||
border-radius: var(--radius-xs);
|
||||
word-break: break-all;
|
||||
}
|
||||
.ps-pointlink:hover {
|
||||
background: var(--color-info-bg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* YAML 配置编辑器(查看/编辑双模式) */
|
||||
@@ -2156,7 +2195,8 @@ body::before {
|
||||
.activity-item,
|
||||
.wf-tab,
|
||||
.point-panel,
|
||||
.heatmap-cell,
|
||||
.ps-ribbon,
|
||||
.ps-seg,
|
||||
.dirty-dot,
|
||||
.yaml-edit-error,
|
||||
.yaml-reset-warn {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
|
||||
let pollTimer = null;
|
||||
let onVisibility = null;
|
||||
let onResize = null;
|
||||
let abortCtl = null;
|
||||
let currentName = null;
|
||||
let activeTab = 'overview';
|
||||
@@ -28,13 +29,16 @@ let latestStats = null;
|
||||
let pointsFilter = { status: '', method: '', wave: '', q: '', sort: 'wave', order: 'asc', limit: 100, offset: 0 };
|
||||
let pointsBuilt = false;
|
||||
|
||||
// Tab3 收敛性分析状态
|
||||
const HEAT_DIMS = ['teff', 'logg', 'loghe', 'logc', 'logn', 'logo', 'cno_sum', 'wave'];
|
||||
let heatState = { points: [], x: 'teff', y: 'cno_sum', slice: {} };
|
||||
let heatBuilt = false;
|
||||
// Tab3 收敛性分析状态(Parallel Sets 平行集合图:6 维类别轴 + 状态轴,色带宽度=点数)
|
||||
const PS_DIMS = ['teff', 'logg', 'loghe', 'logc', 'logn', 'logo'];
|
||||
let psState = { points: [], hover: null }; // hover: 高亮的状态 key(null=无)
|
||||
let psBuilt = false;
|
||||
|
||||
// 进度曲线节流:快照 ~30s 一条,前端 30s 拉一次即可
|
||||
let lastProgressAt = 0;
|
||||
// 最近一次进度数据缓存:轮询每 5s 用 innerHTML 重建概览面板会清空曲线 DOM,
|
||||
// 故每次重建后用它立即重绘——拉取仍按 30s 节流,渲染不再等节流(否则两次拉取之间空白)。
|
||||
let lastProgress = null;
|
||||
|
||||
const STATUS_MAP = {
|
||||
running: ['运行中', 'online'],
|
||||
@@ -66,6 +70,13 @@ function fmtTime(iso) {
|
||||
return d.toLocaleTimeString('zh-CN');
|
||||
}
|
||||
|
||||
/** 同 fmtTime 的规范化口径,返回 epoch 毫秒(解析失败为 NaN)。供进度曲线按真实时间铺横轴。 */
|
||||
function parseTsMs(iso) {
|
||||
if (!iso) return NaN;
|
||||
const normalized = iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z';
|
||||
return new Date(normalized).getTime();
|
||||
}
|
||||
|
||||
// ===== 生命周期 =====
|
||||
|
||||
export function mountWorkflowDetail(name) {
|
||||
@@ -73,6 +84,13 @@ export function mountWorkflowDetail(name) {
|
||||
activeTab = 'overview';
|
||||
latestStats = null;
|
||||
lastProgressAt = 0;
|
||||
lastProgress = null;
|
||||
// 切换工作流时重置各 tab 缓存,避免显示上一个工作流的残留数据
|
||||
pointsBuilt = false;
|
||||
pointsFilter = { status: '', method: '', wave: '', q: '', sort: 'wave', order: 'asc', limit: 100, offset: 0 };
|
||||
psBuilt = false;
|
||||
psState.points = [];
|
||||
psState.hover = null;
|
||||
const root = document.getElementById('view-root');
|
||||
if (!root) return;
|
||||
root.innerHTML = pageSkeleton(name);
|
||||
@@ -83,6 +101,7 @@ export function mountWorkflowDetail(name) {
|
||||
export function unmountWorkflowDetail() {
|
||||
stopScopedPolling();
|
||||
closePointPanel();
|
||||
if (psTooltipEl) { psTooltipEl.remove(); psTooltipEl = null; }
|
||||
currentName = null;
|
||||
latestStats = null;
|
||||
const root = document.getElementById('view-root');
|
||||
@@ -94,6 +113,9 @@ export function unmountWorkflowDetail() {
|
||||
function startScopedPolling(name) {
|
||||
stopScopedPolling();
|
||||
abortCtl = new AbortController();
|
||||
// 连续失败退避计数:失败越多间隔越长(指数退避,上限 60s),成功即重置为基准 5s。
|
||||
// 避免服务端持续故障时空轮询刷请求与 console.warn。
|
||||
let failCount = 0;
|
||||
onVisibility = () => {
|
||||
if (document.hidden) {
|
||||
if (pollTimer) {
|
||||
@@ -105,6 +127,18 @@ function startScopedPolling(name) {
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
// Parallel Sets 用容器实测宽度布局,窗口缩放后重算(防抖;仅 analysis tab 且宽度显著变化时)
|
||||
let resizeTimer = null;
|
||||
onResize = () => {
|
||||
if (activeTab !== 'analysis' || !psBuilt) return;
|
||||
if (resizeTimer) clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
const c = document.getElementById('ps-container');
|
||||
const prev = psState.layout?.W || 0;
|
||||
if (c && Math.abs((c.clientWidth || 0) - prev) > 20) renderParSets();
|
||||
}, 200);
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
schedule();
|
||||
|
||||
async function schedule() {
|
||||
@@ -113,9 +147,21 @@ function startScopedPolling(name) {
|
||||
pollTimer = null;
|
||||
}
|
||||
if (document.hidden) return;
|
||||
await refreshStats(name);
|
||||
if (activeTab === 'points') await refreshPoints();
|
||||
pollTimer = setTimeout(schedule, 5000);
|
||||
let ok = true;
|
||||
try {
|
||||
await refreshStats(name);
|
||||
if (activeTab === 'points') await refreshPoints();
|
||||
} catch (e) {
|
||||
ok = false;
|
||||
}
|
||||
if (ok) {
|
||||
failCount = 0;
|
||||
} else {
|
||||
failCount = Math.min(failCount + 1, 4); // 上限对应 ~5*2^4=80s,再 clamp 到 60s
|
||||
}
|
||||
// 指数退避:5s * 2^failCount,clamp 到 [5s, 60s]
|
||||
const delay = Math.min(5000 * Math.pow(2, failCount), 60000);
|
||||
pollTimer = setTimeout(schedule, delay);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +174,10 @@ function stopScopedPolling() {
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
onVisibility = null;
|
||||
}
|
||||
if (onResize) {
|
||||
window.removeEventListener('resize', onResize);
|
||||
onResize = null;
|
||||
}
|
||||
if (abortCtl) {
|
||||
abortCtl.abort();
|
||||
abortCtl = null;
|
||||
@@ -148,7 +198,11 @@ async function refreshStats(name) {
|
||||
if (json.success && json.data) {
|
||||
latestStats = json.data;
|
||||
renderStrip(json.data);
|
||||
if (activeTab === 'overview') renderOverview(json.data);
|
||||
if (activeTab === 'overview') {
|
||||
renderOverview(json.data);
|
||||
// renderOverview 重建 DOM 会清空曲线,用缓存立即重绘,避免两次 30s 拉取之间空白
|
||||
if (lastProgress) renderSparkline(lastProgress);
|
||||
}
|
||||
maybeRefreshProgress();
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -346,7 +400,6 @@ function renderOverview(s) {
|
||||
<div class="panel-body method-attribution">
|
||||
<span class="method-badge cold">冷启动收敛 ${s.cold_run_converged}</span>
|
||||
<span class="method-badge seed">种子步进收敛 ${s.seed_step_converged}</span>
|
||||
<span class="method-badge imported">历史导入 ${s.imported_converged}</span>
|
||||
<span class="method-badge fail">失败 ${s.failed}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -380,7 +433,10 @@ async function refreshProgress() {
|
||||
const res = await fetchWorkflowProgressApi(currentName, { hours: 24 }, { signal: abortCtl?.signal });
|
||||
if (!res.ok) return;
|
||||
const json = await res.json();
|
||||
if (json.success && json.data) renderSparkline(json.data);
|
||||
if (json.success && json.data) {
|
||||
lastProgress = json.data;
|
||||
renderSparkline(json.data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
console.warn('获取进度曲线失败:', err);
|
||||
@@ -402,10 +458,18 @@ function renderSparkline(prog) {
|
||||
const PAD = 2;
|
||||
const n = series.length;
|
||||
const pctOf = (p, key) => (p.total > 0 ? (p[key] / p.total) * 100 : 0);
|
||||
const x = (i) => (i / (n - 1)) * W;
|
||||
// 横轴 = 真实时间(而非快照序号):快照只在计数变化时写入,按序号均分会把忙碌段拉长、
|
||||
// 把停滞段抹平,曲线斜率 ≠ 实际速率。锚定窗口 [now-hours, now](now 由服务端返回,
|
||||
// 避免前后端时钟偏差),右缘恒为“现在”,停滞期诚实显示为空白。
|
||||
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 = `0,${H} ${line('converged')} ${W},${H}`;
|
||||
const areaPts = `${x(0).toFixed(2)},${H} ${line('converged')} ${x(n - 1).toFixed(2)},${H}`;
|
||||
|
||||
// 两条细线(2px,non-scaling):收敛率实线 + 失败率虚线(CVD/打印下的第二编码)
|
||||
wrap.innerHTML = `
|
||||
@@ -430,12 +494,21 @@ function renderSparkline(prog) {
|
||||
svg.addEventListener('mousemove', (e) => {
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const rel = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||
const idx = Math.round(rel * (n - 1));
|
||||
const p = series[idx];
|
||||
crosshair.setAttribute('x1', x(idx));
|
||||
crosshair.setAttribute('x2', x(idx));
|
||||
const t = tStart + rel * span;
|
||||
// 横轴非均分,鼠标位置 → 时间 → 二分查找最近快照(tsMs 升序)
|
||||
let lo = 0;
|
||||
let hi = n - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (tsMs[mid] < t) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
if (lo > 0 && Math.abs(tsMs[lo - 1] - t) <= Math.abs(tsMs[lo] - t)) lo -= 1;
|
||||
const p = series[lo];
|
||||
crosshair.setAttribute('x1', x(lo));
|
||||
crosshair.setAttribute('x2', x(lo));
|
||||
crosshair.setAttribute('visibility', 'visible');
|
||||
readout.textContent = `${p.ts.replace('T', ' ')} · 收敛 ${p.converged}/${p.total} (${pctOf(p, 'converged').toFixed(1)}%) · 失败 ${p.failed} · 运行 ${p.running} · 排队 ${p.queued}`;
|
||||
readout.textContent = `${p.ts} · 收敛 ${p.converged}/${p.total} (${pctOf(p, 'converged').toFixed(1)}%) · 失败 ${p.failed} · 运行 ${p.running} · 排队 ${p.queued}`;
|
||||
});
|
||||
svg.addEventListener('mouseleave', () => {
|
||||
crosshair.setAttribute('visibility', 'hidden');
|
||||
@@ -491,7 +564,6 @@ function pointStatusBadge(status) {
|
||||
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>';
|
||||
if (method === 'imported') return '<span class="method-badge imported">导入</span>';
|
||||
return '<span class="text-hint">—</span>';
|
||||
}
|
||||
|
||||
@@ -523,7 +595,7 @@ function renderPointsTab() {
|
||||
}
|
||||
pointsBuilt = true;
|
||||
const waveOpts = (latestStats?.waves || [])
|
||||
.map(w => `<option value="${w.wave}">wave ${w.wave}</option>`)
|
||||
.map(w => `<option value="${escapeHtml(w.wave)}">wave ${escapeHtml(w.wave)}</option>`)
|
||||
.join('');
|
||||
el.innerHTML = `
|
||||
<div class="panel card">
|
||||
@@ -542,7 +614,6 @@ function renderPointsTab() {
|
||||
<option value="">全部手段</option>
|
||||
<option value="cold_run">冷启动</option>
|
||||
<option value="seed_step">种子步进</option>
|
||||
<option value="imported">历史导入</option>
|
||||
</select>
|
||||
<select data-wf-filter="wave" aria-label="按波次过滤">
|
||||
<option value="">全部波次</option>${waveOpts}
|
||||
@@ -626,16 +697,16 @@ function renderPointsRows(tbody, points) {
|
||||
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">${p.teff}</td>
|
||||
<td class="tabular-num">${p.logg}</td>
|
||||
<td class="tabular-num">${p.loghe}</td>
|
||||
<td class="tabular-num">${p.cno_sum}</td>
|
||||
<td class="tabular-num">${p.wave}</td>
|
||||
<td class="tabular-num">${escapeHtml(p.teff)}</td>
|
||||
<td class="tabular-num">${escapeHtml(p.logg)}</td>
|
||||
<td class="tabular-num">${escapeHtml(p.loghe)}</td>
|
||||
<td class="tabular-num">${escapeHtml(p.cno_sum)}</td>
|
||||
<td class="tabular-num">${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"${rescued ? ' title="冷启动失败后被种子步进救回"' : ''}>${p.attempt_count}${rescued ? ' ↻' : ''}</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>`;
|
||||
@@ -667,142 +738,396 @@ function renderPointsPagination(total) {
|
||||
if (next) next.disabled = cur >= pages;
|
||||
}
|
||||
|
||||
// ===== Tab3 收敛性分析(冷启动成功热力图) =====
|
||||
|
||||
function dimValues(points, dim) {
|
||||
const set = new Set(points.map(p => p[dim]));
|
||||
return [...set].sort((a, b) => a - b);
|
||||
// ===== Tab3 收敛性分析(Parallel Sets 平行集合图:6 维类别 + 状态,色带=点数) =====
|
||||
//
|
||||
// 为什么用 Parallel Sets 而非 PCP:网格是离散采样点(每维有限取值)+ 分类状态,
|
||||
// PCP 用单条折线表示个体,离散维度上大量线重叠无法区分;Parallel Sets 用色带宽度
|
||||
// 表示聚合数量,专为此场景设计——一眼看出「某维度某值的状态构成」与「跨维度流向」。
|
||||
// 手写 SVG(零依赖,不碰 CSP),几何 = 按状态分段的堆叠条 + 条间贝塞尔色带。
|
||||
|
||||
/** 状态轴档位(收敛拆冷启动/种子步进,物理意义不同:稳定区 vs 救回区)。 */
|
||||
const PS_STATUS = ['failed', 'cold', 'seed', 'running', 'queued', 'pending'];
|
||||
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 heatCellClass(p) {
|
||||
if (p.status === 'converged') {
|
||||
if (p.success_method === 'seed_step') return 'hc-seed';
|
||||
if (p.success_method === 'imported') return 'hc-imported';
|
||||
return 'hc-cold';
|
||||
}
|
||||
if (p.status === 'failed') return 'hc-failed';
|
||||
if (p.status === 'running' || p.status === 'queued') return 'hc-running';
|
||||
return 'hc-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 都是整数采样
|
||||
}
|
||||
|
||||
function renderAnalysisTab() {
|
||||
const el = document.getElementById('wf-tab-analysis');
|
||||
if (!el) return;
|
||||
if (!heatBuilt) {
|
||||
heatBuilt = true;
|
||||
if (!psBuilt) {
|
||||
psBuilt = true;
|
||||
el.innerHTML = `
|
||||
<div class="panel card">
|
||||
<div class="panel-header"><h2>冷启动成功图谱</h2></div>
|
||||
<div class="panel-header"><h2>收敛性联合分析(平行集合图)</h2></div>
|
||||
<div class="panel-body">
|
||||
<div class="heat-controls">
|
||||
<label>X 轴 <select data-heat-axis="x">${HEAT_DIMS.map(d => `<option value="${d}"${d === heatState.x ? ' selected' : ''}>${d}</option>`).join('')}</select></label>
|
||||
<label>Y 轴 <select data-heat-axis="y">${HEAT_DIMS.map(d => `<option value="${d}"${d === heatState.y ? ' selected' : ''}>${d}</option>`).join('')}</select></label>
|
||||
<span id="heat-slice-selects" class="heat-slice-selects"></span>
|
||||
<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>
|
||||
<div id="heatmap-container" class="heatmap-container"><p class="text-hint">加载中…</p></div>
|
||||
<div class="heat-legend">
|
||||
<span class="heat-legend-item"><i class="hc hc-cold"></i>冷启动收敛</span>
|
||||
<span class="heat-legend-item"><i class="hc hc-seed"></i>种子步进收敛</span>
|
||||
<span class="heat-legend-item"><i class="hc hc-imported"></i>历史导入</span>
|
||||
<span class="heat-legend-item"><i class="hc hc-failed"></i>失败</span>
|
||||
<span class="heat-legend-item"><i class="hc hc-running"></i>运行/排队</span>
|
||||
<span class="heat-legend-item"><i class="hc hc-pending"></i>未开始</span>
|
||||
</div>
|
||||
<p id="heat-conclusion" class="heat-conclusion"></p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
refreshHeatData();
|
||||
// 缓存策略:仅当本地无数据时拉取;切 tab / 轮询都不自动拉。唯一重拉入口是「刷新数据」。
|
||||
if (psState.points.length === 0) refreshPsData();
|
||||
else renderParSets();
|
||||
}
|
||||
|
||||
async function refreshHeatData() {
|
||||
/** 拉全量网格点(后端 limit 缺省即全量)。force=true 用于刷新按钮。 */
|
||||
async function refreshPsData(force = false) {
|
||||
if (!currentName) return;
|
||||
if (!force && 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(currentName, { limit: 500 }, { signal: abortCtl?.signal });
|
||||
if (!res.ok) return;
|
||||
const res = await fetchWorkflowPointsApi(currentName, {}, { signal: abortCtl?.signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
heatState.points = json.data?.points || [];
|
||||
initHeatSlice();
|
||||
renderHeatmap();
|
||||
psState.points = json.data?.points || [];
|
||||
renderParSets();
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
console.warn('获取收敛性分析数据失败:', err);
|
||||
if (container) container.innerHTML = `<p class="text-hint">加载失败:${escapeHtml(err.message)}(点「刷新数据」重试)</p>`;
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = '刷新数据'; }
|
||||
}
|
||||
}
|
||||
|
||||
/** 非轴维度存在多值时生成切片选择器(固定其余维度取一个值,保证 2D 投影无叠格)。 */
|
||||
function initHeatSlice() {
|
||||
const container = document.getElementById('heat-slice-selects');
|
||||
if (!container) return;
|
||||
const dims = HEAT_DIMS.filter(d => d !== heatState.x && d !== heatState.y);
|
||||
Object.keys(heatState.slice).forEach(k => {
|
||||
if (!dims.includes(k)) delete heatState.slice[k];
|
||||
});
|
||||
const selects = [];
|
||||
dims.forEach(d => {
|
||||
const vals = dimValues(heatState.points, d);
|
||||
if (vals.length <= 1) {
|
||||
delete heatState.slice[d];
|
||||
return;
|
||||
}
|
||||
if (heatState.slice[d] == null || !vals.includes(heatState.slice[d])) {
|
||||
heatState.slice[d] = vals[0];
|
||||
}
|
||||
selects.push(`<label>${d} = <select data-heat-slice="${d}">${vals.map(v => `<option value="${v}"${v === heatState.slice[d] ? ' selected' : ''}>${v}</option>`).join('')}</select></label>`);
|
||||
});
|
||||
container.innerHTML = selects.join('');
|
||||
/** 维度的有序离散取值列表。 */
|
||||
function psDimValues(points, dim) {
|
||||
const set = new Set(points.map(p => Number(p[dim])));
|
||||
return [...set].filter(Number.isFinite).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function renderHeatmap() {
|
||||
const container = document.getElementById('heatmap-container');
|
||||
/**
|
||||
* 核心渲染:Parallel Sets。
|
||||
* 布局:7 条垂直类别轴(6 物理 + 状态),等间距分布。每轴内按取值纵向堆叠,
|
||||
* 每个取值的色块再按状态分段(高度=点数占比)。相邻轴同状态间用贝塞尔色带连接。
|
||||
* 关键数据结构:flows[i] = { from:{dim,val,seg}, to:{dim,val,seg}, count, slot }
|
||||
* ——「seg」是该取值×状态在轴上的堆叠段,色带从 from.seg 的状态段连到 to.seg 的同状态段。
|
||||
*/
|
||||
function renderParSets() {
|
||||
const container = document.getElementById('ps-container');
|
||||
if (!container) return;
|
||||
const { x, y, slice, points } = heatState;
|
||||
const filtered = points.filter(p => Object.entries(slice).every(([d, v]) => p[d] === v));
|
||||
renderConclusion(filtered);
|
||||
if (filtered.length === 0) {
|
||||
container.innerHTML = '<p class="text-hint">当前切片无数据(调整切片过滤或先启动工作流)</p>';
|
||||
const points = psState.points;
|
||||
if (points.length === 0) {
|
||||
container.innerHTML = '<p class="text-hint">暂无网格点数据(启动工作流后生成)</p>';
|
||||
return;
|
||||
}
|
||||
const xs = dimValues(filtered, x);
|
||||
const ys = dimValues(filtered, y).reverse(); // 大值在上
|
||||
const cellMap = new Map();
|
||||
filtered.forEach(p => cellMap.set(`${p[x]}|${p[y]}`, p));
|
||||
|
||||
const header = '<div class="heatmap-corner"></div>' +
|
||||
xs.map(xv => `<div class="heatmap-collabel tabular-num">${xv}</div>`).join('');
|
||||
const rowsHtml = ys.map(yv => {
|
||||
const cells = xs.map(xv => {
|
||||
const p = cellMap.get(`${xv}|${yv}`);
|
||||
if (!p) return '<div class="heatmap-cell hc-empty"></div>';
|
||||
const title = `${p.name}\nrelc ${fmtRelc(p.last_max_relc)} · 尝试 ${p.attempt_count}`;
|
||||
return `<div class="heatmap-cell ${heatCellClass(p)}" data-point-name="${escapeHtml(p.name)}" title="${escapeHtml(title)}"></div>`;
|
||||
const dims = [...PS_DIMS, 'status'];
|
||||
// 每维取值列表(status 用 PS_STATUS 顺序)
|
||||
const dimValues = {};
|
||||
PS_DIMS.forEach(d => { dimValues[d] = psDimValues(points, d); });
|
||||
dimValues.status = PS_STATUS;
|
||||
|
||||
// 统计:每维每值的总数 + 每维每值每状态计数
|
||||
const dimValueTotal = {}; // dim -> val -> count
|
||||
const dimValSlotCount = {}; // dim -> val -> slot -> count
|
||||
dims.forEach(d => { dimValueTotal[d] = {}; dimValSlotCount[d] = {}; });
|
||||
points.forEach(p => {
|
||||
const slot = psSlot(p);
|
||||
dims.forEach(d => {
|
||||
const v = d === 'status' ? slot : Number(p[d]);
|
||||
if (d !== 'status' && !Number.isFinite(v)) return;
|
||||
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;
|
||||
});
|
||||
});
|
||||
|
||||
// 布局尺寸(像素 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; // 色块间距
|
||||
psState.layout = { W, H, top, bot, dims, dimValues, dimValueTotal, dimValSlotCount, colW, axisW, gap };
|
||||
psState.dimX = (i) => i * colW + colW / 2; // 轴中心 x
|
||||
|
||||
// 计算每个轴上每个取值的 y 区间(按总值占比纵向铺满 [top,bot])
|
||||
// segY: dim -> val -> slot -> {y0, y1}(该状态段在轴上的像素区间)
|
||||
const segY = {};
|
||||
dims.forEach((d, di) => {
|
||||
segY[d] = {};
|
||||
const vals = dimValues[d];
|
||||
const totalAll = vals.reduce((s, v) => s + (dimValueTotal[d][v] || 0), 0) || 1;
|
||||
let yCursor = top;
|
||||
vals.forEach(v => {
|
||||
const cnt = dimValueTotal[d][v] || 0;
|
||||
const h = (cnt / totalAll) * (bot - top);
|
||||
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;
|
||||
});
|
||||
});
|
||||
psState.segY = segY;
|
||||
|
||||
// 渲染轴色块(每值一个 g,内含按状态分段的 rect)+ 取值标签
|
||||
const axisHtml = dims.map((d, di) => {
|
||||
const x = psState.dimX(di);
|
||||
const rx = x - axisW / 2;
|
||||
const vals = dimValues[d];
|
||||
const blocks = vals.map(v => {
|
||||
const segs = PS_STATUS.map(sl => {
|
||||
const seg = segY[d][v][sl];
|
||||
if (!seg) return '';
|
||||
const h = Math.max(0.5, seg.y1 - seg.y0);
|
||||
const dimmed = psState.hover && psState.hover !== sl ? ' ps-dim' : '';
|
||||
const hi = 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)}"></rect>`;
|
||||
}).join('');
|
||||
// 取值标签(标在色块右侧,垂直居中于该值整块)。状态轴用中文+对应状态色,
|
||||
// 状态轴本身即图例(刻度=状态名、颜色=该状态色),不再单列底部图例。
|
||||
const segList = PS_STATUS.map(s => segY[d][v][s]).filter(Boolean);
|
||||
const yMid = segList.length ? (segList[0].y0 + segList[segList.length - 1].y1) / 2 : top;
|
||||
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('');
|
||||
return `<div class="heatmap-rowlabel tabular-num">${yv}</div>${cells}`;
|
||||
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 = psState.dimX(di) + axisW / 2;
|
||||
const x1 = psState.dimX(di + 1) - axisW / 2;
|
||||
const xc1 = x0 + (x1 - x0) * 0.5;
|
||||
// 统计 fromVal×toVal×slot 计数
|
||||
const triple = new Map(); // key "fv|tv|slot" -> count
|
||||
points.forEach(p => {
|
||||
const slot = psSlot(p);
|
||||
const fv = fromD === 'status' ? slot : Number(p[fromD]);
|
||||
const tv = toD === 'status' ? slot : Number(p[toD]);
|
||||
if (fromD !== 'status' && !Number.isFinite(fv)) return;
|
||||
if (toD !== 'status' && !Number.isFinite(tv)) return;
|
||||
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 => {
|
||||
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 = psState.hover && psState.hover !== slot ? ' ps-dim' : '';
|
||||
const hi = 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 = `
|
||||
<div class="heatmap" style="grid-template-columns: max-content repeat(${xs.length}, minmax(26px, 1fr));">
|
||||
${header}${rowsHtml}
|
||||
</div>
|
||||
<p class="text-hint heat-axis-note">X: ${x} · Y: ${y}${points.length >= 500 ? ' · 网格较大,仅取前 500 点' : ''}</p>
|
||||
<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();
|
||||
renderPsConclusion(points);
|
||||
const sum = document.getElementById('ps-summary');
|
||||
if (sum) sum.textContent = `共 ${points.length} 点`;
|
||||
}
|
||||
|
||||
function renderConclusion(pts) {
|
||||
const el = document.getElementById('heat-conclusion');
|
||||
|
||||
|
||||
/** 自动洞察:扫各维度取值,挑失败率显著高于均值的位置。 */
|
||||
function renderPsConclusion(pts) {
|
||||
const el = document.getElementById('ps-conclusion');
|
||||
if (!el) return;
|
||||
const total = pts.length;
|
||||
if (total === 0) {
|
||||
el.textContent = '';
|
||||
return;
|
||||
}
|
||||
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 imp = pts.filter(p => p.status === 'converged' && p.success_method === 'imported').length;
|
||||
const failed = pts.filter(p => p.status === 'failed').length;
|
||||
const rate = Math.round((cold / total) * 100);
|
||||
el.textContent = `冷启动成功 ${cold}/${total} (${rate}%) · 种子步进救回 ${seed} · 历史导入 ${imp} · 失败 ${failed}`;
|
||||
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('');
|
||||
}
|
||||
|
||||
/** 悬停交互:移到色带/色块 → 高亮该状态的全链路(色带 + 同状态色块),其余压暗。 */
|
||||
function bindPsHover() {
|
||||
const svg = document.querySelector('.ps');
|
||||
if (!svg) return;
|
||||
|
||||
const apply = (slot) => {
|
||||
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 = () => { psState.hover = null; apply(null); hidePsTooltip(); };
|
||||
|
||||
svg.addEventListener('mousemove', (e) => {
|
||||
const target = e.target.closest('[data-slot]');
|
||||
if (!target) { if (psState.hover) clear(); return; }
|
||||
const slot = target.getAttribute('data-slot');
|
||||
if (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]}:${(psState.dimValSlotCount[dim]?.[target.getAttribute('data-val')]?.[slot]) || 0} 点`;
|
||||
showPsTooltip(e.clientX, e.clientY, label);
|
||||
});
|
||||
svg.addEventListener('mouseleave', clear);
|
||||
|
||||
// 点击色块 → 弹出该维度值下的网格点名列表(点点名开详情面板)
|
||||
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 = 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(names[0]); return; }
|
||||
showPsPointList(e.clientX, e.clientY, dim, val, slot, names);
|
||||
});
|
||||
}
|
||||
|
||||
// tooltip 单例
|
||||
let psTooltipEl = null;
|
||||
function showPsTooltip(clientX, clientY, html) {
|
||||
if (!psTooltipEl) {
|
||||
psTooltipEl = document.createElement('div');
|
||||
psTooltipEl.className = 'ps-tooltip';
|
||||
document.body.appendChild(psTooltipEl);
|
||||
}
|
||||
psTooltipEl.textContent = html;
|
||||
const tw = psTooltipEl.offsetWidth || 160, th = 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;
|
||||
psTooltipEl.style.left = x + 'px';
|
||||
psTooltipEl.style.top = y + 'px';
|
||||
psTooltipEl.style.display = 'block';
|
||||
}
|
||||
function hidePsTooltip() {
|
||||
if (psTooltipEl) psTooltipEl.style.display = 'none';
|
||||
}
|
||||
|
||||
/** 点击色块时,若该组有多个点,弹出点名列表浮层(点点名 → openPointPanel)。 */
|
||||
let psPointListEl = null;
|
||||
function showPsPointList(clientX, clientY, dim, val, slot, names) {
|
||||
closePsPointList();
|
||||
psPointListEl = document.createElement('div');
|
||||
psPointListEl.className = 'ps-pointlist card';
|
||||
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="关闭">
|
||||
<svg width="14" height="14" 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="ps-pointlist-body">${names.map(n => `<a class="ps-pointlink" data-name="${escapeHtml(n)}" title="查看详情">${escapeHtml(n)}</a>`).join('')}</div>
|
||||
`;
|
||||
document.body.appendChild(psPointListEl);
|
||||
// 定位(锚定点击处,限制在视口内)
|
||||
const r = 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);
|
||||
psPointListEl.style.left = x + 'px';
|
||||
psPointListEl.style.top = y + 'px';
|
||||
psPointListEl.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.ps-pointlist-close')) { closePsPointList(); return; }
|
||||
const link = e.target.closest('[data-name]');
|
||||
if (link) { openPointPanel(link.getAttribute('data-name')); closePsPointList(); }
|
||||
});
|
||||
}
|
||||
function closePsPointList() {
|
||||
if (psPointListEl) { psPointListEl.remove(); psPointListEl = null; }
|
||||
}
|
||||
|
||||
// ===== 点详情滑入面板(尝试历史 + 阶段链诊断) =====
|
||||
@@ -999,8 +1324,10 @@ function switchTab(tab) {
|
||||
['overview', 'points', 'analysis'].forEach(t => {
|
||||
document.getElementById(`wf-tab-${t}`)?.classList.toggle('hidden', t !== tab);
|
||||
});
|
||||
if (tab === 'overview' && latestStats) renderOverview(latestStats);
|
||||
else if (tab === 'points') renderPointsTab();
|
||||
if (tab === 'overview' && latestStats) {
|
||||
renderOverview(latestStats);
|
||||
if (lastProgress) renderSparkline(lastProgress);
|
||||
} else if (tab === 'points') renderPointsTab();
|
||||
else if (tab === 'analysis') renderAnalysisTab();
|
||||
}
|
||||
|
||||
@@ -1042,14 +1369,26 @@ function bindEvents(root) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 点详情入口(表格"查看"按钮 / 热力图格子)
|
||||
// 点详情入口(表格"查看"按钮)
|
||||
const pointEl = e.target.closest('[data-point-name]');
|
||||
if (pointEl) {
|
||||
openPointPanel(pointEl.getAttribute('data-point-name'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Parallel Sets:手动刷新数据(强制重拉全量;唯一触发重新请求的入口)
|
||||
if (e.target.closest('[data-ps-refresh]')) {
|
||||
refreshPsData(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parallel Sets 点名列表浮层关闭(点空白处)
|
||||
if (e.target.closest('.ps-pointlist-close') || !e.target.closest('.ps-pointlist')) {
|
||||
closePsPointList();
|
||||
}
|
||||
});
|
||||
|
||||
// 过滤器 / 热力轴 / 切片 变更
|
||||
// 过滤器变更
|
||||
root.addEventListener('change', (e) => {
|
||||
const f = e.target.closest('[data-wf-filter]');
|
||||
if (f) {
|
||||
@@ -1059,18 +1398,6 @@ function bindEvents(root) {
|
||||
refreshPoints();
|
||||
return;
|
||||
}
|
||||
const ax = e.target.closest('[data-heat-axis]');
|
||||
if (ax) {
|
||||
heatState[ax.getAttribute('data-heat-axis')] = e.target.value;
|
||||
initHeatSlice();
|
||||
renderHeatmap();
|
||||
return;
|
||||
}
|
||||
const sl = e.target.closest('[data-heat-slice]');
|
||||
if (sl) {
|
||||
heatState.slice[sl.getAttribute('data-heat-slice')] = Number(sl.value);
|
||||
renderHeatmap();
|
||||
}
|
||||
});
|
||||
|
||||
// 点名搜索框回车立即生效
|
||||
|
||||
Reference in New Issue
Block a user