feat(all): 源精度命名体系、工作流可观测台、节点停用管理与白名单归档

核心变更:

  1. GridAxisValue 源精度命名
     - 新增 GridAxisValue 类型,携带 f64 数值 + YAML 源书写文本(Deref 透明兼容算术)
     - config.rs 绕过 serde_yaml 归一化,逐 token 捕获轴值原文(logg: 5.0 → g5.0)
     - runner/executor/scheduler 全链路改用 DB TEXT 列权威 point_name,
       修复 REAL 列回读丢精度导致的 model_name 错配

  2. 工作流执行可观测台
     - 新增 stats/progress/points 三组 API(进度时间序列、经验速率 ETA、
       停滞预警、逐点明细分页、收敛性热力图数据)
     - 新增 workflow_progress_snapshots 表 + tasks/grid_points 耗时列
     - runner 携带 last_iter/worst_depth/n_depths 进 conv.json
     - 前端新增 hash 路由、工作流详情页(概览/网格点/收敛分析三 Tab)、YAML 编辑器

  3. 节点停用/启用管理
     - 新增 disabled 状态 + disable/enable API;停用节点保持心跳但停止分发,
       worker 空闲待命而非退出;移除 revoke API,token 失效统一走重发覆盖;
       移除 host_name 字段

  4. 白名单结果归档
     - 新增 result_filter 模块,只归档有语义产物,丢弃 Tlusty 中间单元(~2MB/模型)
     - executor 原子写入归档 + 200 点 LRU 上限

  5. 历史数据导入
     - sync_seeds 重写为 import_results:经 /admin/import_seed 标记 converged +
       按新版命名迁移产物树

  6. 部署与目录重规划
     - data/results→seeds、data/archive→result + migrate_data_dirs.sh
     - deploy.sh 增强(SSH 复用、Profile、远程 env);Dockerfile 瘦身

  7. 文档同步更新 api/database/architecture/deployment
This commit is contained in:
fmq
2026-07-31 01:34:05 +08:00
parent b91f1e4fa5
commit 1bfa240cb0
73 changed files with 12332 additions and 1608 deletions
+91
View File
@@ -0,0 +1,91 @@
import { JSDOM } from 'jsdom';
import fs from 'node:fs';
const html = fs.readFileSync('index.html', 'utf8');
const dom = new JSDOM(html, {
url: 'http://localhost:3000/#/workflows/sdB_cno',
runScripts: 'outside-only',
pretendToBeVisual: true,
});
const { window } = dom;
global.window = window;
global.document = window.document;
global.location = window.location;
global.localStorage = window.localStorage;
global.matchMedia =
window.matchMedia?.bind(window) ?? (() => ({ matches: false, addEventListener() {} }));
window.localStorage.setItem('dcts_admin_token', 'test-token');
const mkPoint = (teff, logc, status, method, attempts = 1, relc = 0.0005) => ({
name: `t${teff}_g5.0_he-2_c${logc}_n-4_o-4`,
teff, logg: 5.0, loghe: -2, logc, logn: -4, logo: -4,
cno_sum: logc - 8, wave: 0,
status, success_method: method, attempt_count: attempts,
last_max_relc: relc, last_task_type: method, seed_point_name: method === 'seed_step' ? 't20000_g5.0_he-2_c-4_n-4_o-4' : null,
node_id: 'node-a1', last_completed_at: status === 'converged' || status === 'failed' ? '2026-07-30 10:00:00' : null,
last_error: status === 'failed' ? 'nl stage diverged' : null,
});
const POINTS = [
mkPoint(20000, -4, 'converged', 'cold_run'),
mkPoint(40000, -4, 'converged', 'seed_step', 2),
mkPoint(60000, -4, 'failed', null, 2, 9.5e5),
mkPoint(20000, -3, 'running', null),
];
global.fetch = window.fetch = async (url) => {
const u = String(url);
if (u.includes('/stats')) {
return new Response(JSON.stringify({
success: true, message: 'ok',
data: { name: 'sdB_cno', status: 'running', total: 4, pending: 0, queued: 0, running: 1, converged: 2, failed: 1, cold_run_converged: 1, seed_step_converged: 1, imported_converged: 0, waves: [{ wave: 0, total: 4, converged: 2, failed: 1 }], avg_point_sec: 600, eta_sec: 600 },
}), { status: 200 });
}
if (u.includes('/points')) {
let pts = [...POINTS];
const q = new URL(u, 'http://localhost');
if (q.searchParams.get('status')) pts = pts.filter(p => p.status === q.searchParams.get('status'));
if (q.searchParams.get('method')) pts = pts.filter(p => p.success_method === q.searchParams.get('method'));
return new Response(JSON.stringify({ success: true, message: 'ok', data: { total: pts.length, points: pts } }), { status: 200 });
}
return new Response('{}', { status: 404 });
};
const { mountWorkflowDetail } = await import('./src/views/workflowDetail.js');
const tick = () => new Promise((r) => setTimeout(r, 30));
mountWorkflowDetail('sdB_cno');
await tick(); await tick();
// Tab2:点表
document.querySelector('[data-wf-tab="points"]').click();
await tick(); await tick();
console.log('points rows:', document.querySelectorAll('#points-table-body tr[data-point-row]').length === 4 ? 'OK' : 'MISSING');
console.log('rescued marker:', document.querySelector('#points-table-body')?.textContent.includes('↻') ? 'OK' : 'MISSING');
// 状态过滤 → 仅 failed
document.querySelector('[data-wf-filter="status"]').value = 'failed';
document.querySelector('[data-wf-filter="status"]').dispatchEvent(new window.Event('change', { bubbles: true }));
await tick();
console.log('status filter:', document.querySelectorAll('#points-table-body tr[data-point-row]').length === 1 ? 'OK' : 'WRONG');
// Tab3:热力图
document.querySelector('[data-wf-tab="analysis"]').click();
await tick(); await tick();
// logc 非轴且多值 → 切片选择器应出现并默认固定 logc=-4(3 点成格,c-3 点被切掉)
const sliceSel = document.querySelector('[data-heat-slice="logc"]');
console.log('slice selector:', sliceSel ? 'OK' : 'MISSING');
const cells = document.querySelectorAll('.heatmap-cell');
console.log('heatmap cells (sliced):', cells.length === 3 ? 'OK' : `GOT ${cells.length}`);
console.log('cold cell:', document.querySelector('.heatmap-cell.hc-cold') ? 'OK' : 'MISSING');
console.log('seed cell:', document.querySelector('.heatmap-cell.hc-seed') ? 'OK' : 'MISSING');
console.log('failed cell:', document.querySelector('.heatmap-cell.hc-failed') ? 'OK' : 'MISSING');
console.log('conclusion:', document.getElementById('heat-conclusion')?.textContent.includes('冷启动成功') ? 'OK' : 'MISSING');
// 切片切到 logc=-3 → 只剩 running 点一格
sliceSel.value = '-3';
sliceSel.dispatchEvent(new window.Event('change', { bubbles: true }));
await tick();
console.log('slice switch:', document.querySelectorAll('.heatmap-cell').length === 1 && document.querySelector('.heatmap-cell.hc-running') ? 'OK' : 'WRONG');
process.exit(0);
+96 -27
View File
@@ -6,14 +6,24 @@
<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&display=swap" rel="stylesheet">
<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">
<script>
/* 首帧前落主题,避免 prefers-color-scheme 先绘制导致深/浅闪变 (FOUC) */
(function () {
try {
var saved = localStorage.getItem('dcts_theme');
var theme = saved || (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
} catch (e) { /* localStorage 不可用时回退媒体查询 */ }
})();
</script>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<div id="app">
<!-- ===== 登录遮罩(鉴权) ===== -->
<div id="login-overlay" class="login-overlay">
<div class="login-card glass">
<div class="login-card card">
<div class="login-header">
<h2>DCTS 控制台登录</h2>
<p>请输入管理员密码以访问管理控制台</p>
@@ -21,7 +31,7 @@
<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="off" />
<input type="password" id="input-admin-token" class="form-input" placeholder="输入管理员密码..." required autocomplete="current-password" />
</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>
@@ -51,7 +61,7 @@
</div>
<div class="header-status">
<div class="status-indicator online" id="status-indicator-box">
<div class="status-indicator online" id="status-indicator-box" role="status" aria-live="polite">
<span class="status-dot"></span>
<span id="server-status-text">服务端正常运行</span>
</div>
@@ -69,6 +79,22 @@
</svg>
刷新
</button>
<button id="btn-theme-toggle" class="btn btn-secondary" title="切换主题模式" aria-label="切换主题模式">
<svg class="icon-theme-sun hidden" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
<svg class="icon-theme-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
<button id="btn-logout" class="btn btn-secondary hidden" title="登出并清除 Token">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
@@ -80,11 +106,16 @@
</div>
</header>
<!-- 主内容区 -->
<!-- 主内容区(视图由 src/router.js 挂载到 #view-root:首页 / 工作流详情页) -->
<main class="main-container">
<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 glass">
<div class="metric-card card">
<div class="metric-header">
<span class="metric-title">在线计算节点</span>
<div class="metric-icon nodes-icon">
@@ -99,10 +130,13 @@
<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 glass">
<div class="metric-card card">
<div class="metric-header">
<span class="metric-title">待计算网格点</span>
<div class="metric-icon pending-icon">
@@ -118,7 +152,7 @@
</div>
</div>
<div class="metric-card glass">
<div class="metric-card card">
<div class="metric-header">
<span class="metric-title">已计算网格模型</span>
<div class="metric-icon success-icon">
@@ -131,10 +165,13 @@
<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 glass">
<div class="metric-card card">
<div class="metric-header">
<span class="metric-title">计算工作流</span>
<div class="metric-icon workflow-icon">
@@ -155,7 +192,7 @@
<!-- 主要内容双栏 -->
<div class="content-grid">
<!-- 左侧:计算节点集群与凭据管理 (统一全功能控制大表) -->
<div class="panel glass">
<div class="panel card">
<div class="panel-header">
<h2>计算节点集群与凭据管理</h2>
<div class="row-wrap" style="align-items: center; gap: 0.5rem;">
@@ -168,7 +205,6 @@
<thead>
<tr>
<th class="col-node-id">节点 ID</th>
<th class="col-hostname">主机名</th>
<th class="col-slots">CPU 槽位</th>
<th class="col-usage">CPU/内存</th>
<th class="col-status">节点状态</th>
@@ -179,7 +215,7 @@
</thead>
<tbody id="nodes-table-body">
<tr>
<td colspan="8" class="empty-cell">正在连接服务端获取计算节点...</td>
<td colspan="7" class="empty-cell">正在连接服务端获取计算节点...</td>
</tr>
</tbody>
</table>
@@ -189,7 +225,7 @@
<!-- 右侧:工作流管理 -->
<div class="stack-col stack-col-lg">
<!-- 工作流面板 -->
<div class="panel glass">
<div class="panel card">
<div class="panel-header">
<h2>恒星大气网格工作流</h2>
</div>
@@ -201,10 +237,10 @@
</div>
</div>
</div>
</main>
</template>
<!-- 创建 / 编辑工作流 模态弹窗 -->
<div id="modal-create-wf" class="modal-backdrop hidden">
<div id="modal-create-wf" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div class="modal-card">
<div class="modal-header">
<h3 id="modal-title">注册新的计算工作流</h3>
@@ -222,7 +258,10 @@
<input type="text" id="input-wf-desc" class="form-input" placeholder="例如: sdB 6维恒星大气模型计算网格" />
</div>
<div class="form-group">
<label for="input-wf-yaml">YAML 配置内容 (GridConfig)</label>
<div class="row-wrap" style="justify-content: space-between; align-items: center;">
<label for="input-wf-yaml">YAML 配置内容 (GridConfig)</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>
</div>
<div class="modal-footer">
@@ -233,27 +272,57 @@
</div>
</div>
<!-- 查看 YAML 模态弹窗 -->
<div id="modal-view-wf" class="modal-backdrop hidden">
<div class="modal-card">
<!-- 工作流 YAML 配置编辑器(查看/编辑双模式,逻辑见 src/components/yamlEditor.js -->
<div id="modal-view-wf" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="view-wf-title">
<div class="modal-card modal-wide yaml-editor">
<div class="modal-header">
<h3 id="view-wf-title">工作流 YAML 配置详情</h3>
<div class="yaml-editor-head">
<h3 id="view-wf-title">工作流配置</h3>
<span id="yaml-editor-status"></span>
<span id="yaml-editor-dirty" class="dirty-dot hidden" title="有未保存的修改" aria-label="有未保存的修改"></span>
</div>
<button id="close-view-wf" 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>
<pre id="view-wf-yaml" class="code-pre"></pre>
<div class="modal-footer">
<button type="button" id="btn-close-view-wf" class="btn btn-secondary">关闭</button>
<!-- 查看模式:语法高亮 + 行号 -->
<div id="yaml-view-pane" class="yaml-view-pane">
<div class="yaml-scroll"><pre id="view-wf-yaml" class="yaml-highlight"></pre></div>
</div>
<!-- 编辑模式:等宽编辑区 + 校验错误/状态重置提示 -->
<div id="yaml-edit-pane" class="yaml-edit-pane hidden">
<div id="yaml-edit-error" class="yaml-edit-error hidden" role="alert"></div>
<div id="yaml-reset-warn" class="yaml-reset-warn hidden">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><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"/></svg>
<span>保存后工作流状态将重置为「闲置」,需重新启动才能使新配置生效</span>
</div>
<textarea id="yaml-edit-area" class="yaml-edit-area" spellcheck="false" aria-label="YAML 配置编辑区"></textarea>
</div>
<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-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>
编辑
</button>
<button type="button" id="btn-cancel-edit-yaml" class="btn btn-secondary btn-sm hidden">取消</button>
<button type="button" id="btn-save-yaml" class="btn btn-primary btn-sm hidden">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/></svg>
保存配置
</button>
<button type="button" id="btn-close-view-wf" class="btn btn-secondary btn-sm">关闭</button>
</div>
</div>
</div>
<!-- 重发 Token 结果展示模态弹窗 -->
<div id="modal-reissue-token" class="modal-backdrop hidden">
<div id="modal-reissue-token" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="reissue-modal-title">
<div class="modal-card">
<div class="modal-header">
<h3>节点专属 Token 已重新颁发</h3>
<h3 id="reissue-modal-title">节点专属 Token 已重新颁发</h3>
<button id="reissue-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>
@@ -280,8 +349,8 @@
<div id="toast-container" class="toast-container"></div>
<!-- ===== 通用自定义 Confirm 模态弹窗 ===== -->
<div id="modal-confirm" class="login-overlay hidden">
<div class="login-card glass modal-card">
<div id="modal-confirm" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title" aria-describedby="confirm-modal-msg">
<div class="login-card card modal-card">
<div class="login-header">
<h3 id="confirm-modal-title" class="confirm-modal-title">确认操作</h3>
<p id="confirm-modal-msg" class="confirm-modal-msg">您确定要执行此操作吗?</p>
+68 -11
View File
@@ -25,19 +25,30 @@ export function escapeHtml(s) {
.replace(/'/g, '&#39;');
}
// 统一 fetch 包装:自动注入 Authorization 头,401 时清除 token 并刷新
export async function apiFetch(url, opts = {}) {
// 统一 fetch 包装:自动注入 Authorization 头,支持 10s 超时处理与 401 处理
export async function apiFetch(url, opts = {}, timeoutMs = 10000) {
const token = getAdminToken();
const headers = { ...(opts.headers || {}) };
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const res = await fetch(url, { ...opts, headers });
if (res.status === 401) {
clearAdminToken();
location.reload();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const signal = opts.signal || controller.signal;
try {
const res = await fetch(url, { ...opts, headers, signal });
clearTimeout(timeoutId);
if (res.status === 401) {
clearAdminToken();
location.reload();
}
return res;
} catch (err) {
clearTimeout(timeoutId);
throw err;
}
return res;
}
// API 请求接口定义
@@ -76,6 +87,15 @@ export async function createWorkflow(workflowData) {
});
}
/** 更新既有工作流配置(PUT)。服务端拒绝 running/initializing 状态覆写(400)。 */
export async function updateWorkflowApi(name, workflowData) {
return apiFetch(`/api/workflows/${encodeURIComponent(name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(workflowData),
});
}
export async function startWorkflowApi(name) {
return apiFetch(`/api/workflows/${encodeURIComponent(name)}/start`, { method: 'POST' });
}
@@ -92,6 +112,39 @@ export async function getWorkflowDetailApi(name) {
return apiFetch(`/api/workflows/${encodeURIComponent(name)}`);
}
// ===== 工作流执行观测 API(详情页数据源) =====
// 均返回 raw Response,调用方检查 res.ok && json.success
// opts 可传 { signal } 用于视图卸载时中断在途请求。
export async function fetchWorkflowStatsApi(name, opts = {}) {
return apiFetch(`/api/workflows/${encodeURIComponent(name)}/stats`, opts);
}
export async function fetchWorkflowPointsApi(name, params = {}, opts = {}) {
const qs = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v != null && v !== '') qs.set(k, v);
});
const s = qs.toString();
return apiFetch(`/api/workflows/${encodeURIComponent(name)}/points${s ? `?${s}` : ''}`, opts);
}
export async function fetchPointDetailApi(name, point, opts = {}) {
return apiFetch(
`/api/workflows/${encodeURIComponent(name)}/points/${encodeURIComponent(point)}`,
opts
);
}
export async function fetchWorkflowProgressApi(name, params = {}, opts = {}) {
const qs = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v != null && v !== '') qs.set(k, v);
});
const s = qs.toString();
return apiFetch(`/api/workflows/${encodeURIComponent(name)}/progress${s ? `?${s}` : ''}`, opts);
}
export async function approveNodeApi(nodeId) {
return apiFetch(`/api/admin/nodes/${encodeURIComponent(nodeId)}/approve`, { method: 'POST' });
}
@@ -100,10 +153,14 @@ export async function rejectNodeApi(nodeId) {
return apiFetch(`/api/admin/nodes/${encodeURIComponent(nodeId)}/reject`, { method: 'POST' });
}
export async function revokeNodeApi(nodeId) {
return apiFetch(`/api/admin/nodes/${encodeURIComponent(nodeId)}/revoke`, { method: 'POST' });
}
export async function reissueNodeApi(nodeId) {
return apiFetch(`/api/admin/nodes/${encodeURIComponent(nodeId)}/reissue`, { method: 'POST' });
}
export async function disableNodeApi(nodeId) {
return apiFetch(`/api/admin/nodes/${encodeURIComponent(nodeId)}/disable`, { method: 'POST' });
}
export async function enableNodeApi(nodeId) {
return apiFetch(`/api/admin/nodes/${encodeURIComponent(nodeId)}/enable`, { method: 'POST' });
}
+66 -22
View File
@@ -1,44 +1,88 @@
/* DCTS Dashboard Modal & Focus Trap Component */
let lastActiveElement = null;
export function setupFocusTrap(modalEl) {
if (!modalEl) return () => {};
const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusableElements = modalEl.querySelectorAll(focusableSelector);
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
// 清理现有在该 Modal 上的 FocusTrap 监听,防止累积
if (modalEl._releaseFocusTrap) {
modalEl._releaseFocusTrap();
}
lastActiveElement = document.activeElement;
if (firstFocusable) {
firstFocusable.focus();
const focusableSelector = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
const lastActiveElement = document.activeElement;
const initialElements = modalEl.querySelectorAll(focusableSelector);
if (initialElements.length > 0) {
initialElements[0].focus();
}
function handleKeyDown(e) {
if (e.key === 'Tab') {
if (e.shiftKey) {
if (document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable?.focus();
}
} else {
if (document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable?.focus();
}
if (e.key === 'Escape') {
const closeBtn = modalEl.querySelector('.modal-close, #btn-close-view-wf, #btn-close-reissue-modal, #btn-confirm-cancel, #cancel-create-wf');
if (closeBtn && typeof closeBtn.click === 'function') {
e.preventDefault();
closeBtn.click();
return;
}
}
if (e.key !== 'Tab') return;
const focusableElements = Array.from(modalEl.querySelectorAll(focusableSelector));
if (focusableElements.length === 0) return;
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstFocusable || !modalEl.contains(document.activeElement)) {
e.preventDefault();
lastFocusable.focus();
}
} else {
if (document.activeElement === lastFocusable || !modalEl.contains(document.activeElement)) {
e.preventDefault();
firstFocusable.focus();
}
}
}
modalEl.addEventListener('keydown', handleKeyDown);
return function releaseFocusTrap() {
const releaseFocusTrap = () => {
modalEl.removeEventListener('keydown', handleKeyDown);
if (lastActiveElement && typeof lastActiveElement.focus === 'function') {
if (lastActiveElement && typeof lastActiveElement.focus === 'function' && document.body.contains(lastActiveElement)) {
lastActiveElement.focus();
}
delete modalEl._releaseFocusTrap;
};
modalEl._releaseFocusTrap = releaseFocusTrap;
return releaseFocusTrap;
}
export function releaseFocusTrap(modalEl) {
if (modalEl && modalEl._releaseFocusTrap) {
modalEl._releaseFocusTrap();
}
}
export function setupModalDismiss(modalEl, onClose) {
if (!modalEl) return;
function handleBackdropClick(e) {
if (e.target === modalEl) {
if (typeof onClose === 'function') {
onClose();
} else {
const closeBtn = modalEl.querySelector('.modal-close, #btn-close-view-wf, #btn-close-reissue-modal, #btn-confirm-cancel, #cancel-create-wf');
if (closeBtn && typeof closeBtn.click === 'function') {
closeBtn.click();
}
}
}
}
modalEl.addEventListener('click', handleBackdropClick);
}
export function showConfirm(title, message) {
+155 -83
View File
@@ -8,20 +8,122 @@ export function renderNodesTableSkeleton() {
const skeletonRows = Array.from({ length: 4 }).map(() => `
<tr class="skeleton-row">
<td><div class="skeleton-shimmer skeleton-text" style="width: 110px;"></div></td>
<td><div class="skeleton-shimmer skeleton-text" style="width: 90px;"></div></td>
<td><div class="skeleton-shimmer skeleton-text" style="width: 60px;"></div></td>
<td><div class="skeleton-shimmer skeleton-text" style="width: 80px;"></div></td>
<td><div class="skeleton-shimmer skeleton-text skeleton-w-110"></div></td>
<td><div class="skeleton-shimmer skeleton-text skeleton-w-90"></div></td>
<td><div class="skeleton-shimmer skeleton-text skeleton-w-80"></div></td>
<td><div class="skeleton-shimmer skeleton-badge"></div></td>
<td><div class="skeleton-shimmer skeleton-badge"></div></td>
<td><div class="skeleton-shimmer skeleton-text" style="width: 70px;"></div></td>
<td><div class="skeleton-shimmer skeleton-text" style="width: 60px;"></div></td>
<td><div class="skeleton-shimmer skeleton-text skeleton-w-70"></div></td>
<td><div class="skeleton-shimmer skeleton-text skeleton-w-60"></div></td>
</tr>
`).join('');
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 重建会销毁用户正悬停/聚焦的按钮。
function rowSignature(node) {
return `${node.status || ''}|${node.token_status || ''}`;
}
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),
};
}
function updateVolatileCells(tr, node) {
const v = volatileValues(node);
const slotsCell = tr.querySelector('.col-slots');
if (slotsCell && slotsCell.textContent !== v.slots) slotsCell.textContent = v.slots;
const usageCell = tr.querySelector('.col-usage');
if (usageCell && usageCell.textContent !== v.usage) usageCell.textContent = v.usage;
const hbCell = tr.querySelector('.col-heartbeat');
if (hbCell && hbCell.textContent !== v.heartbeat) hbCell.textContent = v.heartbeat;
}
function buildRowHtml(node, nodeId) {
const isPending = node.status === 'pending_approval';
const isOnline = node.status === 'online' || node.status === 'active';
const isDisabled = node.status === 'disabled';
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>';
} else if (isDisabled) {
statusBadge = '<span class="status-badge secondary">已停用</span>';
} else if (isOnline) {
statusBadge = '<span class="status-badge online">在线</span>';
} else {
statusBadge = '<span class="status-badge offline">离线</span>';
}
let tokenBadge;
if (isPending) {
tokenBadge = '<span class="status-badge secondary">待授权</span>';
} else if (node.token_status === 'active') {
tokenBadge = '<span class="status-badge online">有效</span>';
} else {
tokenBadge = '<span class="status-badge secondary">无凭据</span>';
}
let actionBtns;
if (isPending) {
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>
</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>
</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>`;
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>
</div>
`;
}
const v = volatileValues(node);
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>
`;
}
export function renderNodesTable(nodes) {
const tbody = document.getElementById('nodes-table-body');
const countBadge = document.getElementById('node-count-badge');
@@ -32,91 +134,61 @@ export function renderNodesTable(nodes) {
if (!tbody) return;
if (!nodes || nodes.length === 0) {
tbody.innerHTML = `
const emptyHtml = `
<tr>
<td colspan="8" class="empty-cell">
<td colspan="7" class="empty-cell">
暂无计算节点记录。启动 DCTS Node Worker 提交注册申请后,在此审批授权接入集群。
</td>
</tr>
`;
if (tbody.innerHTML.trim() !== emptyHtml.trim()) {
tbody.innerHTML = emptyHtml;
}
return;
}
// 构建新 HTML
const html = nodes.map(node => {
const nodeId = escapeHtml(node.node_id || node.id || 'N/A');
const hostName = escapeHtml(node.host_name || node.host || '127.0.0.1');
// 构建目前现有 tr 的 Map (nodeId -> tr Element)
const existingRows = new Map();
tbody.querySelectorAll('tr[data-node-row-id]').forEach(tr => {
existingRows.set(tr.getAttribute('data-node-row-id'), tr);
});
const isPending = node.status === 'pending_approval';
const isOnline = node.status === 'online' || node.status === 'active';
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" style="vertical-align: -1px; margin-right: 3px;"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>待审批</span>';
} else if (isOnline) {
statusBadge = '<span class="status-badge online">在线</span>';
} else {
statusBadge = '<span class="status-badge offline">离线</span>';
}
let tokenBadge;
if (isPending) {
tokenBadge = '<span class="status-badge secondary">待授权</span>';
} else if (node.token_status === 'active') {
tokenBadge = '<span class="status-badge online">有效</span>';
} else if (node.token_status === 'revoked') {
tokenBadge = '<span class="status-badge offline">已吊销</span>';
} else {
tokenBadge = '<span class="status-badge secondary">无凭据</span>';
}
const lastHeartbeat = node.last_heartbeat ? escapeHtml(new Date(node.last_heartbeat).toLocaleTimeString('zh-CN')) : '—';
const activeSlots = Number(node.active_slots || 0);
const maxSlots = Number(node.max_slots || 4);
const cpuUsage = Number(node.cpu_usage || 0).toFixed(1);
const memUsage = Number(node.memory_usage || 0).toFixed(1);
let actionBtns;
if (isPending) {
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>
</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>
</button>
</div>
`;
} else {
actionBtns = `
<div class="action-cell-wrap">
<button class="btn-action-icon btn-action-reissue" data-node-action="reissue" data-node-id="${nodeId}" title="重新颁发专属 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>
<button class="btn-action-icon btn-action-danger" data-node-action="revoke" data-node-id="${nodeId}" title="吊销 Token 凭据" aria-label="吊销 Token 凭据">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
</button>
</div>
`;
}
return `
<tr data-node-row-id="${nodeId}">
<td class="col-node-id node-id" title="${nodeId}">${nodeId}</td>
<td class="col-hostname" title="${hostName}">${hostName}</td>
<td class="col-slots tabular-num">${activeSlots} / ${maxSlots}</td>
<td class="col-usage tabular-num">${cpuUsage}% / ${memUsage}%</td>
<td class="col-status">${statusBadge}</td>
<td class="col-token">${tokenBadge}</td>
<td class="col-heartbeat tabular-num">${lastHeartbeat}</td>
<td class="col-actions">${actionBtns}</td>
</tr>
`;
}).join('');
// 增量 Diff 判断:如果内容一致,避重刷;否则进行更新
if (tbody.innerHTML.trim() !== html.trim()) {
tbody.innerHTML = html;
// 如果 tbody 里包含 skeleton 或 empty-cell,清空
if (tbody.querySelector('.skeleton-row, .empty-cell')) {
tbody.innerHTML = '';
existingRows.clear();
}
const updatedRowElements = [];
nodes.forEach(node => {
const nodeId = escapeHtml(node.node_id || node.id || 'N/A');
const sig = rowSignature(node);
let tr = existingRows.get(nodeId);
if (tr && tr.getAttribute('data-sig') === sig) {
// 状态/凭据未变:仅原位刷新高频数值单元格,不触碰徽章与操作按钮
updateVolatileCells(tr, node);
} else {
const html = buildRowHtml(node, nodeId);
if (!tr) {
tr = document.createElement('tr');
tr.setAttribute('data-node-row-id', nodeId);
}
tr.innerHTML = html;
tr.setAttribute('data-sig', sig);
}
existingRows.delete(nodeId);
updatedRowElements.push(tr);
});
// 移除不再存在的旧 tr
existingRows.forEach(tr => tr.remove());
// 按顺序重排/挂载到 tbody
updatedRowElements.forEach((tr, idx) => {
if (tbody.children[idx] !== tr) {
tbody.insertBefore(tr, tbody.children[idx] || null);
}
});
}
+28 -7
View File
@@ -1,5 +1,11 @@
/* DCTS Dashboard Toast Component */
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>`
};
export function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
if (!container) {
@@ -8,13 +14,28 @@ export function showToast(message, type = 'info') {
}
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'polite');
toast.textContent = message;
// role="alert" 隐含 assertive,仅用于错误;普通提示用 polite,避免与 role 冲突
if (type === 'error') {
toast.setAttribute('role', 'alert');
} else {
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
}
// 消息一律走 textContent,杜绝 innerHTML 注入(message 可能携带用户输入,如工作流名)
const iconSvg = TOAST_ICONS[type] || TOAST_ICONS.info;
toast.innerHTML = iconSvg;
const textEl = document.createElement('span');
textEl.textContent = message;
toast.appendChild(textEl);
const dismiss = () => {
toast.classList.add('toast-fadeOut');
setTimeout(() => toast.remove(), 250);
};
toast.addEventListener('click', dismiss);
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('toast-fadeOut');
setTimeout(() => toast.remove(), 300);
}, 3000);
setTimeout(dismiss, 3500);
}
+78
View File
@@ -0,0 +1,78 @@
/* 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);
}
+97 -32
View File
@@ -10,12 +10,12 @@ export function renderWorkflowsSkeleton() {
<div class="workflow-card skeleton-card">
<div class="workflow-header">
<div>
<div class="skeleton-shimmer skeleton-title" style="width: 140px; height: 18px; margin-bottom: 8px;"></div>
<div class="skeleton-shimmer skeleton-text" style="width: 220px; height: 14px;"></div>
<div class="skeleton-shimmer skeleton-w-140 skeleton-h-18"></div>
<div class="skeleton-shimmer skeleton-w-220 skeleton-h-14"></div>
</div>
<div class="skeleton-shimmer skeleton-badge"></div>
</div>
<div class="workflow-actions" style="margin-top: 16px;">
<div class="workflow-actions wf-actions-mt">
<div class="skeleton-shimmer skeleton-btn"></div>
<div class="skeleton-shimmer skeleton-btn"></div>
</div>
@@ -33,11 +33,23 @@ export function renderWorkflows(workflows) {
if (!workflows || workflows.length === 0) {
if (wfSub) wfSub.textContent = '暂无已注册工作流';
container.innerHTML = `
<div class="empty-cell">
暂无预置工作流。可点击右上角「创建工作流」按钮快速注册。
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>
<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>
注册第一个工作流
</button>
</div>
`;
if (container.innerHTML.trim() !== emptyHtml.trim()) {
container.innerHTML = emptyHtml;
}
return;
}
@@ -46,23 +58,57 @@ export function renderWorkflows(workflows) {
wfSub.textContent = runningCount > 0 ? `${runningCount} 个工作流运行中` : '集群待命';
}
const html = workflows.map(wf => {
let statusCn = '闲置 (Idle)';
const existingCards = new Map();
container.querySelectorAll('.workflow-card[data-wf-card-name]').forEach(card => {
existingCards.set(card.getAttribute('data-wf-card-name'), card);
});
if (container.querySelector('.skeleton-card, .empty-cell')) {
container.innerHTML = '';
existingCards.clear();
}
const updatedCards = [];
workflows.forEach(wf => {
// 状态徽章全映射:后端可发 idle/initializing/running/paused/completed
let statusCn = '闲置';
let statusClass = 'secondary';
if (wf.status === 'running') {
statusCn = '运行中 (Running)';
statusCn = '运行中';
statusClass = 'online';
} else if (wf.status === 'completed') {
statusCn = '已完成 (Completed)';
statusClass = 'online';
statusCn = '已完成';
statusClass = 'completed';
} else if (wf.status === 'initializing') {
statusCn = '初始化中';
statusClass = 'warning';
} else if (wf.status === 'paused') {
statusCn = '已暂停';
statusClass = 'secondary';
}
const name = escapeHtml(wf.name);
const href = `#/workflows/${encodeURIComponent(wf.name)}`;
const desc = escapeHtml(wf.description || '无描述');
const isRunning = wf.status === 'running';
return `
<div class="workflow-card">
// 内联进度(来自列表接口内联 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>
@@ -70,26 +116,45 @@ export function renderWorkflows(workflows) {
</div>
<span class="status-badge ${statusClass}">${statusCn}</span>
</div>
<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"/></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>
${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>
`;
}).join('');
if (container.innerHTML.trim() !== html.trim()) {
container.innerHTML = html;
}
let card = existingCards.get(name);
if (card) {
if (card.innerHTML.trim() !== cardInnerHtml.trim()) {
card.innerHTML = cardInnerHtml;
}
existingCards.delete(name);
} else {
card = document.createElement('div');
card.className = 'workflow-card';
card.setAttribute('data-wf-card-name', name);
card.innerHTML = cardInnerHtml;
}
updatedCards.push(card);
});
existingCards.forEach(card => card.remove());
updatedCards.forEach((card, idx) => {
if (container.children[idx] !== card) {
container.insertBefore(card, container.children[idx] || null);
}
});
}
+303
View File
@@ -0,0 +1,303 @@
/* DCTS 工作流 YAML 配置编辑器(查看/编辑双模式统一面板)
*
* 操作逻辑:
* - 查看模式:语法高亮 + 行号;「编辑」进入编辑模式。
* - 运行中/初始化中:服务端拒绝覆写(400),UI 侧锁定编辑入口并给出提示。
* - paused/completed:可编辑,但保存会重置状态为「闲置」(服务端 upsert 语义),进入编辑时警示。
* - 编辑模式:Tab 缩进两空格、Ctrl/Cmd+S 保存、脏状态指示、取消/关闭前放弃确认。
* - 保存:PUT /api/workflows/:name;服务端 YAML 校验失败(400)的错误信息内联横幅展示。
*
* 模态的关闭(X / 关闭按钮 / Esc / 背景点击)由本模块独占管理(main.js 已将
* #modal-view-wf 排除出全局关闭回路),以便统一走脏状态守卫。
*/
import { escapeHtml, getWorkflowDetailApi, updateWorkflowApi } from '../api.js';
import { showToast } from './toast.js';
import { showConfirm, setupFocusTrap, releaseFocusTrap } from './modal.js';
import { fetchAllData } from '../state.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: '',
status: '',
original: '',
dirty: false,
mode: 'view',
wired: false,
};
// ===== 语法高亮(轻量 YAML 分词:键/数字/布尔/字符串/注释/列表符) =====
function tokenizeLine(rawLine) {
let code = rawLine;
let comment = '';
// 注释:整行注释或行尾 " # ..."(配置场景足够,不处理引号内 # 的极端情形)
const cm = code.match(/(^|\s)(#.*)$/);
if (cm) {
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(/(&quot;.*?&quot;|&#39;.*?&#39;)/g, '<span class="tk-str">$1</span>');
if (comment) {
s += `<span class="tk-comment">${escapeHtml(comment)}</span>`;
}
return s;
}
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('');
}
// ===== 面板填充与模式切换 =====
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);
}
function updateMeta() {
const area = document.getElementById('yaml-edit-area');
const meta = document.getElementById('yaml-editor-meta');
if (!meta) return;
const text = editorState.mode === 'edit' && area ? area.value : editorState.original;
const lines = text ? text.split('\n').length : 0;
meta.textContent = `${lines} 行 · ${text.length} 字符`;
}
function switchToView() {
editorState.mode = 'view';
const viewPane = document.getElementById('yaml-view-pane');
const editPane = document.getElementById('yaml-edit-pane');
viewPane?.classList.remove('hidden');
editPane?.classList.add('hidden');
document.getElementById('view-wf-yaml').innerHTML = highlightYaml(editorState.original);
hideEditError();
const locked = LOCKED_STATUSES.includes(editorState.status);
const btnEdit = document.getElementById('btn-edit-yaml');
if (btnEdit) {
btnEdit.classList.toggle('hidden', locked);
}
const lockHint = document.getElementById('yaml-editor-lock');
if (lockHint) {
lockHint.textContent = locked
? (editorState.status === 'running' ? '运行中不可编辑,请先暂停工作流' : '初始化中,请稍候再试')
: '';
lockHint.classList.toggle('hidden', !locked);
}
document.getElementById('btn-cancel-edit-yaml')?.classList.add('hidden');
document.getElementById('btn-save-yaml')?.classList.add('hidden');
document.getElementById('yaml-reset-warn')?.classList.add('hidden');
setDirty(false);
updateMeta();
}
function switchToEdit() {
editorState.mode = 'edit';
const area = document.getElementById('yaml-edit-area');
document.getElementById('yaml-view-pane')?.classList.add('hidden');
document.getElementById('yaml-edit-pane')?.classList.remove('hidden');
document.getElementById('btn-edit-yaml')?.classList.add('hidden');
document.getElementById('btn-cancel-edit-yaml')?.classList.remove('hidden');
document.getElementById('btn-save-yaml')?.classList.remove('hidden');
const warn = document.getElementById('yaml-reset-warn');
if (warn) {
warn.classList.toggle('hidden', !RESET_WARN_STATUSES.includes(editorState.status));
}
if (area) {
area.value = editorState.original;
area.focus();
const end = area.value.length;
area.setSelectionRange(end, end);
}
updateMeta();
}
function showEditError(message) {
const el = document.getElementById('yaml-edit-error');
if (!el) return;
el.textContent = message;
el.classList.remove('hidden');
}
function hideEditError() {
const el = document.getElementById('yaml-edit-error');
if (!el) return;
el.textContent = '';
el.classList.add('hidden');
}
function closeModal() {
const modal = document.getElementById('modal-view-wf');
if (!modal) return;
modal.classList.add('hidden');
releaseFocusTrap(modal);
}
/** 带脏状态守卫的关闭:编辑中且有未保存修改时先确认放弃。 */
async function requestClose() {
if (editorState.mode === 'edit' && editorState.dirty) {
const ok = await showConfirm('放弃修改', '有未保存的配置修改,确定放弃并关闭吗?');
if (!ok) return;
}
closeModal();
}
// ===== 保存 =====
async function saveConfig() {
const area = document.getElementById('yaml-edit-area');
if (!area || editorState.mode !== 'edit') return;
const yaml = area.value;
if (!yaml.trim()) {
showEditError('配置内容不能为空');
return;
}
hideEditError();
const btnSave = document.getElementById('btn-save-yaml');
if (btnSave) btnSave.disabled = true;
try {
const res = await updateWorkflowApi(editorState.name, {
name: editorState.name,
description: editorState.description,
config_yaml: yaml,
});
const json = await res.json().catch(() => ({}));
if (res.ok && json.success) {
editorState.original = yaml;
showToast(`工作流 '${editorState.name}' 配置已保存`, 'success');
if (RESET_WARN_STATUSES.includes(editorState.status)) {
editorState.status = 'idle'; // 服务端 upsert 已将状态重置
}
switchToView();
const statusEl = document.getElementById('yaml-editor-status');
if (statusEl) statusEl.innerHTML = statusBadge(editorState.status);
fetchAllData();
} else {
showEditError(json.message || `保存失败(HTTP ${res.status}`);
}
} catch (err) {
showEditError(`请求异常:${err.message}`);
} finally {
if (btnSave) btnSave.disabled = false;
}
}
// ===== 事件接线(仅一次) =====
function wireOnce() {
if (editorState.wired) return;
editorState.wired = true;
const modal = document.getElementById('modal-view-wf');
if (!modal) return;
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);
document.getElementById('btn-cancel-edit-yaml')?.addEventListener('click', async () => {
if (editorState.dirty) {
const ok = await showConfirm('放弃修改', '有未保存的配置修改,确定放弃吗?');
if (!ok) return;
}
switchToView();
});
document.getElementById('btn-save-yaml')?.addEventListener('click', saveConfig);
const area = document.getElementById('yaml-edit-area');
if (area) {
area.addEventListener('input', () => {
setDirty(area.value !== editorState.original);
hideEditError();
updateMeta();
});
area.addEventListener('keydown', (e) => {
// Tab 缩进两空格(保留撤销栈:优先 insertText 命令)
if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
if (document.execCommand && document.execCommand('insertText', false, ' ')) return;
const { selectionStart, selectionEnd } = area;
area.value = area.value.slice(0, selectionStart) + ' ' + area.value.slice(selectionEnd);
area.setSelectionRange(selectionStart + 2, selectionStart + 2);
area.dispatchEvent(new Event('input'));
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
saveConfig();
}
});
}
// Esc 与背景点击:走脏状态守卫(全局关闭回路已排除本模态)
modal.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.stopPropagation();
requestClose();
}
});
modal.addEventListener('click', (e) => {
if (e.target === modal) requestClose();
});
modal.querySelector('.modal-card')?.addEventListener('click', (e) => e.stopPropagation());
}
// ===== 入口 =====
export async function openYamlEditor(name) {
try {
const res = await getWorkflowDetailApi(name);
const json = await res.json();
if (!json.success || !json.data) {
showToast(`获取配置失败: ${json.message}`, 'error');
return;
}
const data = json.data;
editorState.name = data.name;
editorState.description = data.description || '';
editorState.status = data.status;
editorState.original = data.config_yaml || '';
wireOnce();
const title = document.getElementById('view-wf-title');
if (title) title.textContent = `工作流配置 · ${data.name}`;
const statusEl = document.getElementById('yaml-editor-status');
if (statusEl) statusEl.innerHTML = statusBadge(data.status);
switchToView();
const modal = document.getElementById('modal-view-wf');
modal?.classList.remove('hidden');
setupFocusTrap(modal);
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
+109 -233
View File
@@ -1,191 +1,55 @@
/* DCTS Cluster Dashboard Client Logic */
/* DCTS 集群控制台客户端入口
*
* 只负责全局接线:登录鉴权、主题切换、全局模态(创建工作流 / 查看 YAML /
* 重发 Token / 确认框)、header 刷新与登出、以及登录成功后的路由启动。
* 视图内容与视图内事件委托见 src/views/(由 src/router.js 挂载)。
*/
import {
getAdminToken,
setAdminToken,
clearAdminToken,
loginAdmin,
createWorkflow,
startWorkflowApi,
stopWorkflowApi,
deleteWorkflowApi,
getWorkflowDetailApi,
approveNodeApi,
rejectNodeApi,
revokeNodeApi,
reissueNodeApi
createWorkflow
} from './api.js';
import { showToast } from './components/toast.js';
import { showConfirm, setupFocusTrap } from './components/modal.js';
import { renderNodesTableSkeleton } from './components/nodesTable.js';
import { renderWorkflowsSkeleton } from './components/workflows.js';
import { startPolling, fetchAllData, fetchNodes } from './state.js';
import { showConfirm, setupFocusTrap, releaseFocusTrap } from './components/modal.js';
import { fetchAllData } from './state.js';
import { triggerRefreshAnimation } from './utils.js';
import { startRouter } from './router.js';
const defaultYamlConfig = `name: sdB_cno
description: sdB CNO 6D Stellar Atmosphere Grid
const defaultYamlConfig = `# DCTS 恒星大气网格配置 (GridConfig)
grid:
teff: [20000.0, 40000.0, 5000.0]
logg: [5.0, 6.2, 0.3]
loghe: [-1.0, -1.0, 0.0]
logc: [-5.0, -2.0, 0.5]
logn: [-5.0, -2.0, 0.5]
logo: [-5.0, -2.0, 0.5]
convergence:
max_relc: 0.01
max_iterations: 30
teff: [20000.0, 40000.0]
logg: [5.0, 6.0]
loghe: [-2.0]
logc: [-4.0]
logn: [-4.0]
logo: [-4.0]
chain:
- {label: lte, lte: T, ltgray: T, ilvlin: 0, require_converged: false, niter: 0}
- {label: nc, lte: F, ltgray: F, ilvlin: 0, require_converged: false, niter: 10}
- {label: nl, lte: F, ltgray: F, ilvlin: 100, require_converged: true, niter: 100}
seed_step_fallback: true
nworkers: 4
timeout_sec: 3600
resume: true
synspec:
wstart: 3000.0
wend: 7000.0
imode: 0
idrv: 50
ifreq: 1
rel_cutoff: 0.0001
abs_cutoff: 0.01
results: data/seeds
`;
// ===== 工作流交互处理 =====
async function handleStartWorkflow(name) {
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();
} else {
showToast(json.message || '启动工作流失败', 'error');
}
} catch (err) {
showToast(`启动失败: ${err.message}`, 'error');
}
}
async function handleStopWorkflow(name) {
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();
} else {
showToast(json.message || '暂停失败', 'error');
}
} catch (err) {
showToast(`暂停失败: ${err.message}`, 'error');
}
}
async function handleDeleteWorkflow(name) {
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();
} else {
showToast(json.message || '删除失败', 'error');
}
} catch (err) {
showToast(`删除失败: ${err.message}`, 'error');
}
}
async function handleViewWorkflow(name) {
try {
const res = await getWorkflowDetailApi(name);
const json = await res.json();
if (json.success && json.data) {
document.getElementById('view-wf-title').textContent = `工作流: ${json.data.name}`;
document.getElementById('view-wf-yaml').textContent = json.data.config_yaml;
const modal = document.getElementById('modal-view-wf');
modal?.classList.remove('hidden');
setupFocusTrap(modal);
} else {
showToast(`获取详情失败: ${json.message}`, 'error');
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
// ===== 节点授权管理交互处理 =====
async function handleApprove(nodeId) {
const ok = await showConfirm('同意节点接入', `确定同意授权计算节点 '${nodeId}' 加入集群吗?\n系统将自动分配身份 Token 并在节点轮询时下发。`);
if (!ok) return;
try {
const res = await approveNodeApi(nodeId);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '节点接入成功已授权', 'success');
fetchAllData();
} else {
showToast(json.message || '审批失败', 'error');
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
async function handleReject(nodeId) {
const ok = await showConfirm('拒绝节点接入', `确定拒绝节点 '${nodeId}' 的接入申请吗?`);
if (!ok) return;
try {
const res = await rejectNodeApi(nodeId);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '已拒绝节点接入申请', 'info');
fetchAllData();
} else {
showToast(json.message || '操作失败', 'error');
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
async function handleRevoke(nodeId) {
const ok = await showConfirm('吊销节点 Token', `确定吊销节点 '${nodeId}' 的专属 Token 吗?\n该节点的现有 Token 将立即失效。`);
if (!ok) return;
try {
const res = await revokeNodeApi(nodeId);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || 'Token 吊销成功', 'info');
fetchAllData();
} else {
showToast(json.message || '吊销失败', 'error');
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
async function handleReissue(nodeId) {
const ok = await showConfirm('重发节点 Token', `确定为节点 '${nodeId}' 重新颁发专属 Token 吗?旧 Token 将失效。`);
if (!ok) return;
try {
const res = await reissueNodeApi(nodeId);
const json = await res.json();
if (json.success && json.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');
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
function showLoginError(msg) {
const errorEl = document.getElementById('login-error-msg');
if (errorEl) {
@@ -219,7 +83,7 @@ async function handleLoginSubmit(password) {
document.getElementById('login-overlay')?.classList.add('hidden');
document.getElementById('btn-logout')?.classList.remove('hidden');
showToast('管理员登录成功', 'success');
startApp();
startRouter();
} else {
const errMsg = json.message || `登录失败: HTTP ${res.status}`;
showLoginError(errMsg);
@@ -231,19 +95,18 @@ async function handleLoginSubmit(password) {
}
}
function startApp() {
renderNodesTableSkeleton();
renderWorkflowsSkeleton();
startPolling();
}
// ===== DOM 初始化与事件委托 =====
// ===== DOM 初始化与全局事件接线 =====
document.addEventListener('DOMContentLoaded', () => {
const btnCreateWf = document.getElementById('btn-create-wf');
const modalCreateWf = document.getElementById('modal-create-wf');
const modalViewWf = document.getElementById('modal-view-wf');
const modalReissueToken = document.getElementById('modal-reissue-token');
function closeModal(modal) {
if (!modal) return;
modal.classList.add('hidden');
releaseFocusTrap(modal);
}
btnCreateWf?.addEventListener('click', () => {
document.getElementById('input-wf-name').value = '';
document.getElementById('input-wf-desc').value = '';
@@ -252,24 +115,37 @@ document.addEventListener('DOMContentLoaded', () => {
setupFocusTrap(modalCreateWf);
});
document.getElementById('close-create-wf')?.addEventListener('click', () => modalCreateWf?.classList.add('hidden'));
document.getElementById('cancel-create-wf')?.addEventListener('click', () => modalCreateWf?.classList.add('hidden'));
document.getElementById('close-view-wf')?.addEventListener('click', () => modalViewWf?.classList.add('hidden'));
document.getElementById('btn-close-view-wf')?.addEventListener('click', () => modalViewWf?.classList.add('hidden'));
const btnLoadPresetYaml = document.getElementById('btn-load-preset-yaml');
if (btnLoadPresetYaml) {
btnLoadPresetYaml.addEventListener('click', () => {
const nameInput = document.getElementById('input-wf-name');
const descInput = document.getElementById('input-wf-desc');
const yamlInput = document.getElementById('input-wf-yaml');
if (nameInput && !nameInput.value) nameInput.value = 'sdB_cno';
if (descInput && !descInput.value) descInput.value = 'sdB CNO 6D Stellar Atmosphere Grid';
if (yamlInput) yamlInput.value = defaultYamlConfig;
showToast('已载入预设示例 YAML 配置', 'info');
});
}
document.getElementById('close-create-wf')?.addEventListener('click', () => closeModal(modalCreateWf));
document.getElementById('cancel-create-wf')?.addEventListener('click', () => closeModal(modalCreateWf));
// #modal-view-wfYAML 编辑器)的关闭由 yamlEditor.js 独占管理(带脏状态守卫),
// 这里不再接线,并将其排除出全局背景点击 / Escape 关闭回路。
document.getElementById('reissue-close-btn')?.addEventListener('click', () => {
document.getElementById('reissue-token-content').textContent = '';
modalReissueToken?.classList.add('hidden');
closeModal(modalReissueToken);
});
document.getElementById('btn-close-reissue-modal')?.addEventListener('click', () => {
document.getElementById('reissue-token-content').textContent = '';
modalReissueToken?.classList.add('hidden');
closeModal(modalReissueToken);
});
// 点击 Backdrop 背景关闭弹窗
// 点击 Backdrop 背景关闭弹窗(YAML 编辑器自带脏守卫关闭,除外)
document.querySelectorAll('.login-overlay, .modal-backdrop').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay && overlay.id !== 'login-overlay') {
overlay.classList.add('hidden');
if (e.target === overlay && overlay.id !== 'login-overlay' && overlay.id !== 'modal-view-wf') {
closeModal(overlay);
}
});
});
@@ -278,12 +154,12 @@ document.addEventListener('DOMContentLoaded', () => {
card.addEventListener('click', (e) => e.stopPropagation());
});
// 按 Escape 键快速关闭 Modal
// 按 Escape 键快速关闭 Modal(YAML 编辑器自带脏守卫关闭,除外)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-backdrop:not(.hidden), .login-overlay:not(.hidden)').forEach(modal => {
if (modal.id !== 'login-overlay' && modal.id !== 'modal-confirm') {
modal.classList.add('hidden');
if (modal.id !== 'login-overlay' && modal.id !== 'modal-confirm' && modal.id !== 'modal-view-wf') {
closeModal(modal);
}
});
}
@@ -306,7 +182,7 @@ document.addEventListener('DOMContentLoaded', () => {
const json = await res.json();
if (res.ok && json.success) {
showToast(`工作流 '${name}' 保存成功!`, 'success');
modalCreateWf?.classList.add('hidden');
closeModal(modalCreateWf);
fetchAllData();
} else {
showToast(`保存失败: ${json.message}`, 'error');
@@ -316,32 +192,6 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// 工作流卡片事件委托
const workflowsList = document.getElementById('workflows-list');
workflowsList?.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);
});
// 节点大表事件委托
const nodesTbody = document.getElementById('nodes-table-body');
nodesTbody?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-node-action], [data-cred-action]');
if (!btn) return;
const action = btn.getAttribute('data-node-action') || btn.getAttribute('data-cred-action');
const nodeId = btn.getAttribute('data-node-id');
if (action === 'approve') handleApprove(nodeId);
else if (action === 'reject') handleReject(nodeId);
else if (action === 'revoke') handleRevoke(nodeId);
else if (action === 'reissue') handleReissue(nodeId);
});
// 复制 Token 按钮
document.getElementById('btn-copy-token')?.addEventListener('click', async () => {
const text = document.getElementById('reissue-token-content')?.textContent || '';
@@ -354,17 +204,13 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// 刷新按钮 & 登出
document.getElementById('refresh-btn')?.addEventListener('click', () => {
showToast('正在刷新数据...', 'info');
// header 全局刷新 & 登出
document.getElementById('refresh-btn')?.addEventListener('click', (e) => {
triggerRefreshAnimation(e.currentTarget);
showToast('正在刷新集群全量数据...', 'info');
fetchAllData();
});
document.getElementById('btn-refresh-nodes')?.addEventListener('click', () => {
showToast('正在刷新节点数据...', 'info');
fetchNodes();
});
document.getElementById('btn-logout')?.addEventListener('click', async () => {
const ok = await showConfirm('退出登录', '确定要登出并清除本页面的 Admin Token 吗?');
if (ok) {
@@ -373,6 +219,36 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// ===== 深色/浅色主题模式控制 =====
function initTheme() {
const savedTheme = localStorage.getItem('dcts_theme');
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
setTheme(initialTheme);
}
function setTheme(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
document.querySelector('.icon-theme-sun')?.classList.remove('hidden');
document.querySelector('.icon-theme-moon')?.classList.add('hidden');
} else {
document.documentElement.setAttribute('data-theme', 'light');
document.querySelector('.icon-theme-sun')?.classList.add('hidden');
document.querySelector('.icon-theme-moon')?.classList.remove('hidden');
}
localStorage.setItem('dcts_theme', theme);
}
document.getElementById('btn-theme-toggle')?.addEventListener('click', () => {
const currentTheme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
const nextTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(nextTheme);
showToast(`已切换至 ${nextTheme === 'dark' ? '深色' : '浅色'}模式`, 'info');
});
initTheme();
// 登录表单
document.getElementById('form-login')?.addEventListener('submit', (e) => {
e.preventDefault();
@@ -380,11 +256,11 @@ document.addEventListener('DOMContentLoaded', () => {
handleLoginSubmit(pass);
});
// 初始化鉴权判断
// 初始化鉴权判断:已登录直接按当前 hash 渲染对应视图
if (getAdminToken()) {
document.getElementById('login-overlay')?.classList.add('hidden');
document.getElementById('btn-logout')?.classList.remove('hidden');
startApp();
startRouter();
} else {
document.getElementById('btn-logout')?.classList.add('hidden');
}
+44
View File
@@ -0,0 +1,44 @@
/* DCTS 零依赖 hash 路由
*
* #/(或空/未知 hash)→ 控制台首页;#/workflows/:name → 工作流详情页。
* 视图挂载/卸载成对调用:各视图在 mount 里启动自己的轮询、在 unmount 里彻底清理,
* 保证切换视图不留定时器/监听器泄漏。登录门禁:无 token 时不渲染任何视图
* (登录遮罩由 main.js 管理,登录成功后调用 startRouter())。
*/
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 }
export function parseHash() {
const h = location.hash.replace(/^#/, '');
const m = h.match(/^\/workflows\/([^/?#]+)\/?$/);
if (m) return { view: 'workflow', name: decodeURIComponent(m[1]) };
return { view: 'home' };
}
function render() {
if (!getAdminToken()) return;
const next = parseHash();
if (current && current.view === next.view && current.name === next.name) return;
if (current) {
if (current.view === 'home') unmountHome();
else unmountWorkflowDetail();
}
if (next.view === 'workflow') mountWorkflowDetail(next.name);
else mountHome();
current = next;
}
/** 注册 hashchange 并按当前 hash 首次渲染(登录成功后调用)。 */
export function startRouter() {
window.addEventListener('hashchange', render);
render();
}
export function navigate(hash) {
location.hash = hash;
}
+34 -12
View File
@@ -7,6 +7,18 @@ import { renderWorkflows } from './components/workflows.js';
let isPolling = false;
let pollTimer = null;
// 监听浏览器 Tab 标签页切换,切入后台时暂停轮询,切回前台时恢复并立即刷新
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
} else if (isPolling) {
scheduleFetchData();
}
});
export function isAppPolling() {
return isPolling;
}
@@ -26,9 +38,14 @@ export function stopPolling() {
}
export async function scheduleFetchData() {
if (pollTimer) clearTimeout(pollTimer);
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
if (document.hidden) return;
await fetchAllData();
if (isPolling) {
if (isPolling && !document.hidden) {
pollTimer = setTimeout(scheduleFetchData, 5000);
}
}
@@ -68,11 +85,6 @@ export async function fetchNodes() {
const json = await fetchNodesList();
if (json.success && Array.isArray(json.data)) {
renderNodesTable(json.data);
return;
}
const data = await fetchClusterStatus();
if (data.nodes) {
renderNodesTable(data.nodes);
}
} catch (err) {
console.warn('获取计算节点列表失败:', err);
@@ -101,13 +113,20 @@ export function updateUI(data) {
const valActiveNodes = document.getElementById('val-active-nodes');
const valTotalSlots = document.getElementById('val-total-slots');
const slotsProgressFill = document.getElementById('slots-progress-fill');
if (valActiveNodes) valActiveNodes.textContent = activeNodesCount;
if (valTotalSlots) {
valTotalSlots.textContent = `${activeSlots} / ${maxSlots} CPU 槽位占用`;
}
if (slotsProgressFill) {
const pct = maxSlots > 0 ? Math.min(100, Math.max(0, Math.round((activeSlots / maxSlots) * 100))) : 0;
slotsProgressFill.style.width = `${pct}%`;
}
const stats = data.grid_stats || {};
const pending = stats.pending ?? data.pending_points ?? 0;
// 后端已将 queued 从 pending 拆出(详情页需区分"未入队/排队中");
// 首页"待计算网格点"保持传统合并口径 = pending + queued。
const pending = (stats.pending ?? data.pending_points ?? 0) + (stats.queued ?? 0);
const running = stats.running ?? data.running_points ?? 0;
const converged = stats.converged ?? data.converged_points ?? 0;
const failed = stats.failed ?? data.failed_points ?? 0;
@@ -122,13 +141,16 @@ export function updateUI(data) {
const valCompletedTasks = document.getElementById('val-completed-tasks');
const valCompletionRate = document.getElementById('val-completion-rate');
const convergedProgressFill = document.getElementById('converged-progress-fill');
if (valCompletedTasks) valCompletedTasks.textContent = completed.toLocaleString();
if (valCompletionRate) {
valCompletionRate.textContent = `${converged.toLocaleString()} 收敛 / ${failed.toLocaleString()} 未收敛`;
}
const valTotalWorkflows = document.getElementById('val-total-workflows');
if (valTotalWorkflows && data.total_workflows !== undefined) {
valTotalWorkflows.textContent = data.total_workflows;
if (convergedProgressFill) {
const totalModels = pending + running + completed;
const pct = totalModels > 0 ? Math.min(100, Math.max(0, Math.round((converged / totalModels) * 100))) : 0;
convergedProgressFill.style.width = `${pct}%`;
}
// 工作流总数由 renderWorkflows 从列表单独写入,避免与此处双源覆盖造成数字抖动
}
+1152 -28
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
/* DCTS 前端小工具 */
/**
* 刷新按钮旋转动画svg .spin 800ms 后恢复并解禁按钮
* header 全局刷新与首页节点面板刷新共用
*/
export function triggerRefreshAnimation(btn) {
if (!btn) return;
const svg = btn.querySelector('svg');
if (svg) svg.classList.add('spin');
btn.disabled = true;
setTimeout(() => {
if (svg) svg.classList.remove('spin');
btn.disabled = false;
}, 800);
}
+176
View File
@@ -0,0 +1,176 @@
/* DCTS
*
* 内容核心指标卡 + 计算节点集群与凭据管理大表 + 工作流卡片列表
* 标记来自 index.html #view-home-template克隆挂载ID 与原版逐字一致
* state.js 的轮询渲染逻辑无需任何改动
*
* 生命周期mountHome 克隆模板 绑定首页作用域事件委托 骨架屏 启动全局轮询
* unmountHome 停轮询 清空 #view-root委托监听随 DOM 移除无泄漏
*/
import {
approveNodeApi,
rejectNodeApi,
reissueNodeApi,
disableNodeApi,
enableNodeApi
} from '../api.js';
import { showToast } from '../components/toast.js';
import { showConfirm, setupFocusTrap } 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';
// ===== 节点授权管理交互处理(首页专属) =====
async function handleApprove(nodeId) {
const ok = await showConfirm('同意节点接入', `确定同意授权计算节点 '${nodeId}' 加入集群吗?\n系统将自动分配身份 Token 并在节点轮询时下发。`);
if (!ok) return;
try {
const res = await approveNodeApi(nodeId);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '节点接入成功已授权', 'success');
fetchAllData();
} else {
showToast(json.message || '审批失败', 'error');
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
async function handleReject(nodeId) {
const ok = await showConfirm('拒绝节点接入', `确定拒绝节点 '${nodeId}' 的接入申请吗?`);
if (!ok) return;
try {
const res = await rejectNodeApi(nodeId);
const json = await res.json();
if (res.ok && json.success) {
showToast(json.message || '已拒绝节点接入申请', 'info');
fetchAllData();
} else {
showToast(json.message || '操作失败', 'error');
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
async function handleDisable(nodeId) {
const ok = await showConfirm('停用节点', `确定停用节点 '${nodeId}' 吗?\n停用后该节点保持在线但不再被分发任务,worker 将空闲待命。可随时重新启用。`);
if (!ok) return;
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');
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
async function handleEnable(nodeId) {
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');
fetchAllData();
} else {
showToast(json.message || '启用失败', 'error');
}
} 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');
}
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
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));
// 工作流卡片事件委托(随 DOM 移除而销毁,无需手动解绑)
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();
});
// 节点大表事件委托
document.getElementById('nodes-table-body')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-node-action], [data-cred-action]');
if (!btn) return;
const action = btn.getAttribute('data-node-action') || btn.getAttribute('data-cred-action');
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);
});
// 节点面板刷新按钮(header 的全局刷新按钮仍由 main.js 管理)
document.getElementById('btn-refresh-nodes')?.addEventListener('click', (e) => {
triggerRefreshAnimation(e.currentTarget);
showToast('正在刷新节点列表...', 'info');
fetchNodes();
});
renderNodesTableSkeleton();
renderWorkflowsSkeleton();
startPolling();
}
export function unmountHome() {
stopPolling();
const root = document.getElementById('view-root');
if (root) root.innerHTML = '';
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -5,7 +5,7 @@ export default defineConfig({
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8090',
target: 'http://127.0.0.1:8090',
changeOrigin: true,
},
},