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:
@@ -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
@@ -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
@@ -25,19 +25,30 @@ export function escapeHtml(s) {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 统一 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' });
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(/(".*?"|'.*?')/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
@@ -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-wf(YAML 编辑器)的关闭由 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');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -5,7 +5,7 @@ export default defineConfig({
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8090',
|
||||
target: 'http://127.0.0.1:8090',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user