/* DCTS Dashboard API & Authentication Module * * 集中所有后端调用与鉴权逻辑。类型契约以 JSDoc 标注,供 IDE 提示与重构参考 * (项目保持原生 ESM,不引入 TypeScript)。 * * 安全:token 存储在 sessionStorage 而非 localStorage。 * sessionStorage 生命周期仅限当前标签页会话(关闭即失效),缩小了 XSS 窃取后的利用窗口; * localStorage 会跨标签页/重启长期持久化,一旦泄露(配合 CSP 的 'unsafe-inline')即等于 * 长期账号接管。服务端 session 仍有 24h 过期 + 容量淘汰,前端不再持有永久凭据。 */ import { showToast } from './components/toast.js'; // escapeHtml 的权威定义已迁至 utils/format.js;此处 re-export 保持向后兼容 // (nodesTable/workflows 等历史 import 自 api.js,改动成本高于收益)。 export { escapeHtml } from './utils/format.js'; const TOKEN_KEY = 'dcts_admin_token'; /** * @typedef {Object} ApiResponse 通用响应包装 * @property {boolean} success * @property {string} [message] 失败时的错误描述 * @property {*} [data] 业务数据 */ /** * @typedef {Object} ClusterStatus GET /api/status 返回的集群总体状态 * @property {number} [nodes_online] * @property {number} [active_nodes_count] * @property {number} [total_active_slots] * @property {number} [occupied_slots] * @property {number} [total_max_slots] * @property {Array} [nodes] * @property {object} [grid_stats] - { pending, queued, running, completed, failed } */ /** * @typedef {Object} NodeInfo GET /api/admin/nodes 列表项 * @property {string} node_id * @property {string} [id] - node_id 的别名(后端历史字段) * @property {string} status - online/active/disabled/pending_approval/offline * @property {string} token_status - active/inactive * @property {number} active_slots * @property {number} max_slots * @property {number} cpu_usage * @property {number} memory_usage * @property {string} last_heartbeat * @property {number|null} admin_max_slots - 管理员强制并发槽位上限(null=无限制) */ /** * @typedef {Object} Workflow GET /api/workflows 列表项 * @property {string} name * @property {string} [description] * @property {string} status - idle/initializing/running/paused/completed * @property {string} [config_yaml] * @property {WorkflowStats} [stats] 列表接口内联的统计概要 */ /** * @typedef {Object} WorkflowStats GET /api/workflows/:name/stats * @property {string} status * @property {number} total * @property {number} pending * @property {number} queued * @property {number} running * @property {number} completed * @property {number} failed * @property {number} cold_run_converged - TLUSTY 视图:冷启动收敛 * @property {number} seed_step_converged - TLUSTY 视图:种子步进收敛 * @property {number} [tlusty_converged] - TLUSTY 视图:大气收敛总数(权威口径,不按策略拆;2026-08-25 补) * @property {number} [seed_step_stab_converged] - TLUSTY 视图:稳定化种子收敛(2026-08-25 补) * @property {number} tlusty_failed - TLUSTY 视图:大气发散 * @property {number} synspec_converged - SYNSPEC 视图:光谱有效 * @property {number} synspec_failed - SYNSPEC 视图:光谱失败 * @property {number} synspec_pending - SYNSPEC 视图:未运行 * @property {number} [eta_sec] * @property {Array<{label:string,count:number}>} [waves] */ /** * @typedef {Object} WorkflowPoint GET /api/workflows/:name/points 列表项 * @property {string} name * @property {string} status * @property {string} [method] * @property {string} [tlusty_status] - 'converged'/'failed'/'pending'/null * @property {string} [synspec_status] - 'converged'/'failed'/'pending'/null * @property {number} [relc] * @property {number} [runtime_sec] * @property {number} [attempts] * @property {string} [last_completed_at] */ export function getAdminToken() { try { return sessionStorage.getItem(TOKEN_KEY); } catch (_) { return null; } } export function setAdminToken(token) { try { sessionStorage.setItem(TOKEN_KEY, token); } catch (_) { /* sessionStorage 不可用时静默,鉴权将走 401 流程 */ } } export function clearAdminToken() { try { sessionStorage.removeItem(TOKEN_KEY); } catch (_) { /* 同上 */ } } // 401 处理去重:并发请求(如详情页 Promise.allSettled 三路拉取)可能各自命中 401 并 // 各触发一次 location.reload,竞态下不规范。用一个模块级 guard 保证只处理一次。 let handling401 = false; /** * 统一 fetch 包装:自动注入 Authorization 头,支持超时处理与 401 处理。 * 401 时清 token、提示会话过期并跳回登录,而非静默 reload(避免详情页突然闪回无说明)。 */ export async function apiFetch(url, opts = {}, timeoutMs = 10000) { const token = getAdminToken(); const headers = { ...(opts.headers || {}) }; if (token) { headers['Authorization'] = `Bearer ${token}`; } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); // 调用方传入的 signal(如视图卸载 abortCtl)转发给内部 controller, // 否则 fetch 绑定的是调用方 signal,内部 timeoutMs 兜底会失效(请求挂起无法超时)。 const callerSignal = opts.signal; const onCallerAbort = () => controller.abort(); if (callerSignal) { if (callerSignal.aborted) { controller.abort(); } else { callerSignal.addEventListener('abort', onCallerAbort, { once: true }); } } // fetch 统一用内部 controller.signal:既能响应超时,也能响应调用方 abort。 const { signal: _ignored, ...optsWithoutSignal } = opts; try { const res = await fetch(url, { ...optsWithoutSignal, headers, signal: controller.signal }); clearTimeout(timeoutId); if (res.status === 401 && !handling401) { handling401 = true; clearAdminToken(); // 延迟提示让 toast 可见,再回到登录视图 try { showToast('会话已过期,请重新登录', 'error'); } catch (_) { /* toast 不可用时跳过 */ } setTimeout(() => { location.hash = ''; location.reload(); }, 800); } return res; } catch (err) { clearTimeout(timeoutId); throw err; } finally { // 清理调用方 signal 监听,避免泄漏(尤其长期存活的 abortCtl)。 if (callerSignal) callerSignal.removeEventListener('abort', onCallerAbort); } } // API 请求接口定义 // loginAdmin 走统一超时包装,避免裸 fetch 在网络挂起时永不返回、按钮永久 disabled。 export async function loginAdmin(password, timeoutMs = 10000) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), signal: controller.signal, }); return res; } finally { clearTimeout(timeoutId); } } /** * 登出:调用后端 /api/auth/logout 撤销当前 session(服务端立即失效), * 再清除前端 token。即便 token 已被窃取,登出也能阻断重放(不再依赖 24h 自然过期)。 */ export async function logoutAdmin() { try { await apiFetch('/api/auth/logout', { method: 'POST' }, 5000); } catch (_) { /* 网络异常时仍清前端 token,不阻断登出交互 */ } clearAdminToken(); } /** 获取集群总体状态(在线节点、槽位占用、网格点统计)。 */ export async function fetchClusterStatus() { const res = await apiFetch('/api/status'); if (!res.ok) throw new Error(`HTTP 错误代码: ${res.status}`); return res.json(); } /** 获取工作流列表(含内联 stats 概要)。 */ export async function fetchWorkflowsList() { const res = await apiFetch('/api/workflows'); if (!res.ok) throw new Error(`HTTP 错误代码: ${res.status}`); return res.json(); } /** 获取计算节点列表(管理员视角,含 token_status/cpu_usage 等)。 */ 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), }); } /** 更新既有工作流配置(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' }); } 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, opts = {}) { return apiFetch(`/api/workflows/${encodeURIComponent(name)}`, opts); } // ===== 工作流执行观测 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' }); } export async function rejectNodeApi(nodeId) { return apiFetch(`/api/admin/nodes/${encodeURIComponent(nodeId)}/reject`, { 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' }); } /** * 设置节点并发槽位配额(动态调整 CPU 核数)。 * @param {string} nodeId * @param {number|null} adminMaxSlots - null=清除限制(恢复物理 max_slots);number=配额上限(0=暂停接新任务) */ export async function setNodeQuotaApi(nodeId, adminMaxSlots) { return apiFetch(`/api/admin/nodes/${encodeURIComponent(nodeId)}/quota`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ admin_max_slots: adminMaxSlots }), }); }