feat(server,dashboard): 引入多工作流数据隔离、安全中间件与前端 ESM 模块化重构

- server: 实现按 workflow_name 的多工作流数据隔离与旧数据库平滑迁移机制
- server: 新增 API Key 认证(auth)、限流中间件(rate_limit)与运维备份接口(admin)
- server: 统一 AppError 错误处理体系,重构调度器 scheduler 支持工作流级重置与抢占
- node: 节点 ID 缺失时自动生成随机 UUID,原生支持 `docker compose --scale node=N` 动态扩容
- dashboard: 前端模块化重构(state/api/components),升级 CSS 变量设计系统与 Toast 通知
- docker/docs: 更新 /healthz 健康检查、部署脚本 IP 配置及数据库设计文档
This commit is contained in:
fmq
2026-07-28 21:54:02 +08:00
parent 4b4238d702
commit b91f1e4fa5
59 changed files with 7697 additions and 1490 deletions
+109
View File
@@ -0,0 +1,109 @@
/* DCTS Dashboard API & Authentication Module */
const TOKEN_KEY = 'dcts_admin_token';
export function getAdminToken() {
return localStorage.getItem(TOKEN_KEY);
}
export function setAdminToken(token) {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearAdminToken() {
localStorage.removeItem(TOKEN_KEY);
}
// 安全 HTML 转义,防止存储型 XSS
export function escapeHtml(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// 统一 fetch 包装:自动注入 Authorization 头,401 时清除 token 并刷新
export async function apiFetch(url, opts = {}) {
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();
}
return res;
}
// API 请求接口定义
export async function loginAdmin(password) {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
return res;
}
export async function fetchClusterStatus() {
const res = await apiFetch('/api/status');
if (!res.ok) throw new Error(`HTTP 错误代码: ${res.status}`);
return res.json();
}
export async function fetchWorkflowsList() {
const res = await apiFetch('/api/workflows');
if (!res.ok) throw new Error(`HTTP 错误代码: ${res.status}`);
return res.json();
}
export async function fetchNodesList() {
const res = await apiFetch('/api/admin/nodes');
if (!res.ok) throw new Error(`HTTP 错误代码: ${res.status}`);
return res.json();
}
export async function createWorkflow(workflowData) {
return apiFetch('/api/workflows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(workflowData),
});
}
export async function startWorkflowApi(name) {
return apiFetch(`/api/workflows/${encodeURIComponent(name)}/start`, { method: 'POST' });
}
export async function stopWorkflowApi(name) {
return apiFetch(`/api/workflows/${encodeURIComponent(name)}/stop`, { method: 'POST' });
}
export async function deleteWorkflowApi(name) {
return apiFetch(`/api/workflows/${encodeURIComponent(name)}`, { method: 'DELETE' });
}
export async function getWorkflowDetailApi(name) {
return apiFetch(`/api/workflows/${encodeURIComponent(name)}`);
}
export async function approveNodeApi(nodeId) {
return apiFetch(`/api/admin/nodes/${encodeURIComponent(nodeId)}/approve`, { method: 'POST' });
}
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' });
}
+83
View File
@@ -0,0 +1,83 @@
/* 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];
lastActiveElement = document.activeElement;
if (firstFocusable) {
firstFocusable.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();
}
}
}
}
modalEl.addEventListener('keydown', handleKeyDown);
return function releaseFocusTrap() {
modalEl.removeEventListener('keydown', handleKeyDown);
if (lastActiveElement && typeof lastActiveElement.focus === 'function') {
lastActiveElement.focus();
}
};
}
export function showConfirm(title, message) {
return new Promise((resolve) => {
const modal = document.getElementById('modal-confirm');
const titleEl = document.getElementById('confirm-modal-title');
const msgEl = document.getElementById('confirm-modal-msg');
const btnOk = document.getElementById('btn-confirm-ok');
const btnCancel = document.getElementById('btn-confirm-cancel');
if (!modal) {
resolve(window.confirm(`${title}\n${message}`));
return;
}
if (titleEl) titleEl.textContent = title;
if (msgEl) msgEl.textContent = message;
modal.classList.remove('hidden');
const releaseTrap = setupFocusTrap(modal);
const cleanup = () => {
modal.classList.add('hidden');
releaseTrap();
btnOk?.removeEventListener('click', onOk);
btnCancel?.removeEventListener('click', onCancel);
};
const onOk = () => {
cleanup();
resolve(true);
};
const onCancel = () => {
cleanup();
resolve(false);
};
btnOk?.addEventListener('click', onOk);
btnCancel?.addEventListener('click', onCancel);
});
}
+122
View File
@@ -0,0 +1,122 @@
/* DCTS Dashboard Nodes Table Component (增量 DOM 更新与 Skeleton) */
import { escapeHtml } from '../api.js';
export function renderNodesTableSkeleton() {
const tbody = document.getElementById('nodes-table-body');
if (!tbody) return;
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-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>
</tr>
`).join('');
tbody.innerHTML = skeletonRows;
}
export function renderNodesTable(nodes) {
const tbody = document.getElementById('nodes-table-body');
const countBadge = document.getElementById('node-count-badge');
if (countBadge) {
countBadge.textContent = `${nodes ? nodes.length : 0} 个节点`;
}
if (!tbody) return;
if (!nodes || nodes.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="8" class="empty-cell">
暂无计算节点记录。启动 DCTS Node Worker 提交注册申请后,在此审批授权接入集群。
</td>
</tr>
`;
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');
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;
}
}
+20
View File
@@ -0,0 +1,20 @@
/* DCTS Dashboard Toast Component */
export function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
if (!container) {
console.log(`[Toast ${type}]`, message);
return;
}
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'polite');
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('toast-fadeOut');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
+95
View File
@@ -0,0 +1,95 @@
/* DCTS Dashboard Workflows Component */
import { escapeHtml } from '../api.js';
export function renderWorkflowsSkeleton() {
const container = document.getElementById('workflows-list');
if (!container) return;
container.innerHTML = Array.from({ length: 2 }).map(() => `
<div class="workflow-card skeleton-card">
<div class="workflow-header">
<div>
<div class="skeleton-shimmer skeleton-title" style="width: 140px; height: 18px; margin-bottom: 8px;"></div>
<div class="skeleton-shimmer skeleton-text" style="width: 220px; height: 14px;"></div>
</div>
<div class="skeleton-shimmer skeleton-badge"></div>
</div>
<div class="workflow-actions" style="margin-top: 16px;">
<div class="skeleton-shimmer skeleton-btn"></div>
<div class="skeleton-shimmer skeleton-btn"></div>
</div>
</div>
`).join('');
}
export function renderWorkflows(workflows) {
const container = document.getElementById('workflows-list');
const wfMetric = document.getElementById('val-total-workflows');
const wfSub = document.getElementById('val-active-wf');
if (!container) return;
if (wfMetric) wfMetric.textContent = workflows ? workflows.length : 0;
if (!workflows || workflows.length === 0) {
if (wfSub) wfSub.textContent = '暂无已注册工作流';
container.innerHTML = `
<div class="empty-cell">
暂无预置工作流。可点击右上角「创建工作流」按钮快速注册。
</div>
`;
return;
}
const runningCount = workflows.filter(w => w.status === 'running').length;
if (wfSub) {
wfSub.textContent = runningCount > 0 ? `${runningCount} 个工作流运行中` : '集群待命';
}
const html = workflows.map(wf => {
let statusCn = '闲置 (Idle)';
let statusClass = 'secondary';
if (wf.status === 'running') {
statusCn = '运行中 (Running)';
statusClass = 'online';
} else if (wf.status === 'completed') {
statusCn = '已完成 (Completed)';
statusClass = 'online';
}
const name = escapeHtml(wf.name);
const desc = escapeHtml(wf.description || '无描述');
const isRunning = wf.status === 'running';
return `
<div class="workflow-card">
<div class="workflow-header">
<div>
<h3 class="workflow-name">${name}</h3>
<p class="workflow-desc">${desc}</p>
</div>
<span class="status-badge ${statusClass}">${statusCn}</span>
</div>
<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>
</div>
`;
}).join('');
if (container.innerHTML.trim() !== html.trim()) {
container.innerHTML = html;
}
}
+324 -241
View File
@@ -1,4 +1,26 @@
// DCTS Cluster Dashboard Client Logic (全中文,无占位符,对接所有后端 API)
/* DCTS Cluster Dashboard Client Logic */
import {
getAdminToken,
setAdminToken,
clearAdminToken,
loginAdmin,
createWorkflow,
startWorkflowApi,
stopWorkflowApi,
deleteWorkflowApi,
getWorkflowDetailApi,
approveNodeApi,
rejectNodeApi,
revokeNodeApi,
reissueNodeApi
} 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';
const defaultYamlConfig = `name: sdB_cno
description: sdB CNO 6D Stellar Atmosphere Grid
@@ -14,295 +36,356 @@ convergence:
max_iterations: 30
`;
// 获取状态数据
async function fetchStatus() {
// ===== 工作流交互处理 =====
async function handleStartWorkflow(name) {
const ok = await showConfirm('启动计算网格', `确定要启动工作流 '${name}' 展开网格计算任务吗?`);
if (!ok) return;
try {
const res = await fetch('/api/status');
if (!res.ok) throw new Error(`HTTP 错误代码: ${res.status}`);
const data = await res.json();
updateUI(data);
updateServerStatus(true);
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) {
console.warn('获取 DCTS 状态失败:', err);
updateServerStatus(false);
showToast(`启动失败: ${err.message}`, 'error');
}
}
// 获取工作流列表
async function fetchWorkflows() {
async function handleStopWorkflow(name) {
const ok = await showConfirm('暂停工作流', `确定要暂停工作流 '${name}' 的后续任务派发吗?`);
if (!ok) return;
try {
const res = await fetch('/api/workflows');
if (!res.ok) return;
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) {
renderWorkflows(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) {
console.warn('获取工作流列表失败:', err);
showToast(`请求异常: ${err.message}`, 'error');
}
}
function updateServerStatus(online) {
const statusText = document.getElementById('server-status-text');
const indicator = document.getElementById('status-indicator-box');
if (!statusText || !indicator) return;
// ===== 节点授权管理交互处理 =====
async function handleApprove(nodeId) {
const ok = await showConfirm('同意节点接入', `确定同意授权计算节点 '${nodeId}' 加入集群吗?\n系统将自动分配身份 Token 并在节点轮询时下发。`);
if (!ok) return;
if (online) {
statusText.textContent = '服务端正常运行';
indicator.className = 'status-indicator online';
} else {
statusText.textContent = '服务端已断开连接';
indicator.className = 'status-indicator offline';
}
}
function updateUI(data) {
const nodes = data.nodes || [];
const activeNodesCount = data.nodes_online !== undefined ? data.nodes_online : nodes.length;
const activeSlots = data.total_active_slots || 0;
const maxSlots = data.total_max_slots || 0;
const gridStats = data.grid_stats || {};
const totalPoints = gridStats.total || 0;
const pendingPoints = gridStats.pending || 0;
const runningPoints = gridStats.running || 0;
const convergedPoints = gridStats.converged || 0;
// 更新指标卡片
document.getElementById('val-active-nodes').textContent = activeNodesCount;
document.getElementById('val-total-slots').textContent = `${activeSlots} / ${maxSlots} CPU 槽位占用`;
document.getElementById('val-pending-tasks').textContent = pendingPoints;
document.getElementById('val-running-tasks').textContent = `${runningPoints} 个网格点计算中`;
document.getElementById('val-completed-tasks').textContent = convergedPoints;
const rate = totalPoints > 0 ? ((convergedPoints / totalPoints) * 100).toFixed(1) : '0.0';
document.getElementById('val-completion-rate').textContent = `全网网格总数: ${totalPoints} (完成率 ${rate}%)`;
document.getElementById('node-count-badge').textContent = `${activeNodesCount} 个节点在线`;
renderNodesTable(nodes);
}
function renderNodesTable(nodes) {
const tbody = document.getElementById('nodes-table-body');
if (!tbody) return;
if (!nodes || nodes.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="7" class="empty-cell">
暂无在线计算节点。请启动 DCTS Node Worker 节点程序。
</td>
</tr>
`;
return;
}
tbody.innerHTML = nodes.map(node => {
const isOnline = node.status === 'online' || node.status === 'active';
const statusText = isOnline ? '在线 (Online)' : '离线 (Offline)';
const badgeClass = isOnline ? 'online' : 'offline';
const lastHeartbeat = node.last_heartbeat ? new Date(node.last_heartbeat).toLocaleTimeString('zh-CN') : '未知';
return `
<tr>
<td class="node-id">${node.node_id || node.id || 'N/A'}</td>
<td>${node.host_name || node.host || '127.0.0.1'}</td>
<td>${node.active_slots || 0} / ${node.max_slots || 4}</td>
<td>${(node.cpu_usage || 0).toFixed(1)}%</td>
<td>${(node.memory_usage || 0).toFixed(1)}%</td>
<td>${lastHeartbeat}</td>
<td><span class="status-badge ${badgeClass}">${statusText}</span></td>
</tr>
`;
}).join('');
}
function renderWorkflows(workflows) {
const container = document.getElementById('workflows-list');
const wfMetric = document.getElementById('val-total-workflows');
const wfSub = document.getElementById('val-active-wf');
if (!container) return;
if (wfMetric) wfMetric.textContent = workflows.length;
if (!workflows || workflows.length === 0) {
if (wfSub) wfSub.textContent = '暂无已注册工作流';
container.innerHTML = `
<div class="empty-cell">
暂无预置工作流。可点击右上角「创建工作流」按钮快速注册。
</div>
`;
return;
}
const runningCount = workflows.filter(w => w.status === 'running').length;
if (wfSub) {
wfSub.textContent = runningCount > 0 ? `${runningCount} 个工作流运行中` : '集群待命';
}
container.innerHTML = workflows.map(wf => {
let statusCn = '闲置 (Idle)';
let statusClass = 'secondary';
if (wf.status === 'running') {
statusCn = '运行中 (Running)';
statusClass = 'online';
} else if (wf.status === 'paused') {
statusCn = '已暂停 (Paused)';
statusClass = 'offline';
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');
}
return `
<div class="workflow-card">
<div class="wf-header">
<span class="wf-name">${wf.name}</span>
<span class="status-badge ${statusClass}">${statusCn}</span>
</div>
<div class="wf-desc">${wf.description || '无详细描述'}</div>
<div class="wf-meta">
<span>创建时间: ${wf.created_at || '最近'}</span>
</div>
<div class="wf-actions">
${wf.status !== 'running' ? `
<button class="btn btn-primary btn-sm" onclick="startWorkflow('${wf.name}')">启动计算</button>
` : `
<button class="btn btn-secondary btn-sm" onclick="stopWorkflow('${wf.name}')">暂停计算</button>
`}
<button class="btn btn-secondary btn-sm" onclick="viewWorkflowYaml('${wf.name}')">查看 YAML</button>
<button class="btn btn-danger btn-sm" onclick="deleteWorkflow('${wf.name}')">删除</button>
</div>
</div>
`;
}).join('');
} catch (err) {
showToast(`请求异常: ${err.message}`, 'error');
}
}
// 模态弹窗交互
function initModals() {
const modalWf = document.getElementById('modal-workflow');
const modalYaml = document.getElementById('modal-view-yaml');
const btnCreateWf = document.getElementById('btn-create-wf');
const btnCloseModal = document.getElementById('modal-close-btn');
const btnCancelModal = document.getElementById('btn-cancel-modal');
const formWf = document.getElementById('form-workflow');
async function handleReject(nodeId) {
const ok = await showConfirm('拒绝节点接入', `确定拒绝节点 '${nodeId}' 的接入申请吗?`);
if (!ok) return;
const btnCloseYaml = document.getElementById('view-yaml-close-btn');
const btnCloseYamlFoot = document.getElementById('btn-close-yaml-modal');
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) {
errorEl.textContent = msg;
errorEl.classList.remove('hidden');
} else {
showToast(msg, 'error');
}
}
async function handleLoginSubmit(password) {
const errorEl = document.getElementById('login-error-msg');
const btnSubmit = document.getElementById('btn-submit-login');
if (errorEl) {
errorEl.textContent = '';
errorEl.classList.add('hidden');
}
if (!password) {
showLoginError('请输入管理员密码');
return;
}
if (btnSubmit) btnSubmit.disabled = true;
try {
const res = await loginAdmin(password);
const json = await res.json().catch(() => ({}));
if (res.ok && json.success && json.token) {
setAdminToken(json.token);
document.getElementById('login-overlay')?.classList.add('hidden');
document.getElementById('btn-logout')?.classList.remove('hidden');
showToast('管理员登录成功', 'success');
startApp();
} else {
const errMsg = json.message || `登录失败: HTTP ${res.status}`;
showLoginError(errMsg);
}
} catch (err) {
showLoginError(`登录请求异常:${err.message}`);
} finally {
if (btnSubmit) btnSubmit.disabled = false;
}
}
function startApp() {
renderNodesTableSkeleton();
renderWorkflowsSkeleton();
startPolling();
}
// ===== 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');
btnCreateWf?.addEventListener('click', () => {
document.getElementById('input-wf-name').value = '';
document.getElementById('input-wf-desc').value = '';
document.getElementById('input-wf-yaml').value = defaultYamlConfig;
modalWf?.classList.remove('hidden');
modalCreateWf?.classList.remove('hidden');
setupFocusTrap(modalCreateWf);
});
const hideWfModal = () => modalWf?.classList.add('hidden');
btnCloseModal?.addEventListener('click', hideWfModal);
btnCancelModal?.addEventListener('click', hideWfModal);
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'));
document.getElementById('reissue-close-btn')?.addEventListener('click', () => {
document.getElementById('reissue-token-content').textContent = '';
modalReissueToken?.classList.add('hidden');
});
document.getElementById('btn-close-reissue-modal')?.addEventListener('click', () => {
document.getElementById('reissue-token-content').textContent = '';
modalReissueToken?.classList.add('hidden');
});
const hideYamlModal = () => modalYaml?.classList.add('hidden');
btnCloseYaml?.addEventListener('click', hideYamlModal);
btnCloseYamlFoot?.addEventListener('click', hideYamlModal);
// 点击 Backdrop 背景关闭弹窗
document.querySelectorAll('.login-overlay, .modal-backdrop').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay && overlay.id !== 'login-overlay') {
overlay.classList.add('hidden');
}
});
});
// 提交创建工作流
formWf?.addEventListener('submit', async (e) => {
document.querySelectorAll('.modal-card, .login-card').forEach(card => {
card.addEventListener('click', (e) => e.stopPropagation());
});
// 按 Escape 键快速关闭 Modal
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');
}
});
}
});
// 创建工作流提交处理
document.getElementById('form-create-wf')?.addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('input-wf-name').value.trim();
const description = document.getElementById('input-wf-desc').value.trim();
const config_yaml = document.getElementById('input-wf-yaml').value;
if (!name || !config_yaml) {
alert('请填写工作流名称及 YAML 配置内容!');
showToast('请填写工作流名称及 YAML 配置内容!', 'error');
return;
}
try {
const res = await fetch('/api/workflows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, config_yaml }),
});
const res = await createWorkflow({ name, description, config_yaml });
const json = await res.json();
if (json.success) {
alert(`工作流 '${name}' 保存成功!`);
hideWfModal();
fetchWorkflows();
if (res.ok && json.success) {
showToast(`工作流 '${name}' 保存成功!`, 'success');
modalCreateWf?.classList.add('hidden');
fetchAllData();
} else {
alert(`保存失败: ${json.message}`);
showToast(`保存失败: ${json.message}`, 'error');
}
} catch (err) {
alert(`请求异常: ${err.message}`);
showToast(`请求异常: ${err.message}`, 'error');
}
});
}
// 暴露操作给全局 window 对象
window.startWorkflow = async function(name) {
if (!confirm(`确定要启动工作流 '${name}' 展开计算网格点吗?`)) return;
try {
const res = await fetch(`/api/workflows/${name}/start`, { method: 'POST' });
const json = await res.json();
alert(json.message);
fetchWorkflows();
fetchStatus();
} catch (err) {
alert(`启动失败: ${err.message}`);
}
};
// 工作流卡片事件委托
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);
});
window.stopWorkflow = async function(name) {
try {
const res = await fetch(`/api/workflows/${name}/stop`, { method: 'POST' });
const json = await res.json();
alert(json.message);
fetchWorkflows();
} catch (err) {
alert(`暂停失败: ${err.message}`);
}
};
// 节点大表事件委托
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);
});
window.deleteWorkflow = async function(name) {
if (!confirm(`确定要删除工作流 '${name}' 吗?`)) return;
try {
const res = await fetch(`/api/workflows/${name}`, { method: 'DELETE' });
const json = await res.json();
alert(json.message);
fetchWorkflows();
} catch (err) {
alert(`删除失败: ${err.message}`);
}
};
window.viewWorkflowYaml = async function(name) {
try {
const res = await fetch(`/api/workflows/${name}`);
const json = await res.json();
if (json.success && json.data) {
document.getElementById('view-yaml-title').textContent = `工作流 '${name}' 配置 YAML`;
document.getElementById('view-yaml-content').textContent = json.data.config_yaml;
document.getElementById('modal-view-yaml')?.classList.remove('hidden');
} else {
alert(`获取详情失败: ${json.message}`);
// 复制 Token 按钮
document.getElementById('btn-copy-token')?.addEventListener('click', async () => {
const text = document.getElementById('reissue-token-content')?.textContent || '';
if (!text) return;
try {
await navigator.clipboard.writeText(text);
showToast('Token 已成功复制到剪贴板', 'success');
} catch {
showToast('自动复制失败,请手动选择复制', 'info');
}
} catch (err) {
alert(`请求异常: ${err.message}`);
}
};
// 初始化与定时轮询
document.addEventListener('DOMContentLoaded', () => {
fetchStatus();
fetchWorkflows();
initModals();
});
// 刷新按钮 & 登出
document.getElementById('refresh-btn')?.addEventListener('click', () => {
fetchStatus();
fetchWorkflows();
showToast('正在刷新数据...', 'info');
fetchAllData();
});
// 每 5 秒轮询刷新
setInterval(() => {
fetchStatus();
fetchWorkflows();
}, 5000);
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) {
clearAdminToken();
location.reload();
}
});
// 登录表单
document.getElementById('form-login')?.addEventListener('submit', (e) => {
e.preventDefault();
const pass = document.getElementById('input-admin-token').value.trim();
handleLoginSubmit(pass);
});
// 初始化鉴权判断
if (getAdminToken()) {
document.getElementById('login-overlay')?.classList.add('hidden');
document.getElementById('btn-logout')?.classList.remove('hidden');
startApp();
} else {
document.getElementById('btn-logout')?.classList.add('hidden');
}
});
+134
View File
@@ -0,0 +1,134 @@
/* DCTS Dashboard Application State & Polling Scheduler */
import { fetchClusterStatus, fetchWorkflowsList, fetchNodesList } from './api.js';
import { renderNodesTable } from './components/nodesTable.js';
import { renderWorkflows } from './components/workflows.js';
let isPolling = false;
let pollTimer = null;
export function isAppPolling() {
return isPolling;
}
export function startPolling() {
if (isPolling) return;
isPolling = true;
scheduleFetchData();
}
export function stopPolling() {
isPolling = false;
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
}
export async function scheduleFetchData() {
if (pollTimer) clearTimeout(pollTimer);
await fetchAllData();
if (isPolling) {
pollTimer = setTimeout(scheduleFetchData, 5000);
}
}
export async function fetchAllData() {
await Promise.all([
fetchStatus(),
fetchWorkflows(),
fetchNodes(),
]);
}
export async function fetchStatus() {
try {
const data = await fetchClusterStatus();
updateUI(data);
updateServerStatus(true);
} catch (err) {
console.warn('获取 DCTS 状态失败:', err);
updateServerStatus(false);
}
}
export async function fetchWorkflows() {
try {
const json = await fetchWorkflowsList();
if (json.success && json.data) {
renderWorkflows(json.data);
}
} catch (err) {
console.warn('获取工作流列表失败:', err);
}
}
export async function fetchNodes() {
try {
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);
}
}
export function updateServerStatus(online) {
const box = document.getElementById('status-indicator-box');
const text = document.getElementById('server-status-text');
if (!box || !text) return;
if (online) {
box.className = 'status-indicator online';
text.textContent = '服务端正常运行';
} else {
box.className = 'status-indicator offline';
text.textContent = '服务端连接中断';
}
}
export function updateUI(data) {
if (!data) return;
const activeNodesCount = data.nodes_online ?? data.active_nodes_count ?? (data.nodes ? data.nodes.length : 0);
const activeSlots = data.total_active_slots ?? data.occupied_slots ?? 0;
const maxSlots = data.total_max_slots ?? 0;
const valActiveNodes = document.getElementById('val-active-nodes');
const valTotalSlots = document.getElementById('val-total-slots');
if (valActiveNodes) valActiveNodes.textContent = activeNodesCount;
if (valTotalSlots) {
valTotalSlots.textContent = `${activeSlots} / ${maxSlots} CPU 槽位占用`;
}
const stats = data.grid_stats || {};
const pending = stats.pending ?? data.pending_points ?? 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;
const completed = converged + failed;
const valPendingTasks = document.getElementById('val-pending-tasks');
const valRunningTasks = document.getElementById('val-running-tasks');
if (valPendingTasks) valPendingTasks.textContent = pending.toLocaleString();
if (valRunningTasks) {
valRunningTasks.textContent = `${running.toLocaleString()} 个任务计算中`;
}
const valCompletedTasks = document.getElementById('val-completed-tasks');
const valCompletionRate = document.getElementById('val-completion-rate');
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;
}
}
+645 -168
View File
File diff suppressed because it is too large Load Diff