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:
@@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 统一 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' });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user