收敛攻坚(81/400 失败点根因与实测,见 docs/failed81_cno_seed_popzer_dpsilg_2026_08_18.md): - runner: 新增 seed_step_stab 策略链——同物理族(同 Teff/logg/logHe)CNO 邻居种子 + DPSILG=3.0 λ 算子欠松弛 + POPZER=1E-10 微布居置零(联动 POPZR2/RADZER 同值 + NITZER=1),针对 20kK He 富大气 He I/II 电离前沿布居极限环 - runner: 三级链内延拓回退(主链与 nl_direct 全败后自动触发): · 固定 ladder 步进——plan_ladder_steps 按归一化间隔选轴,Δlogg≤0.25/ΔTeff≤2.5kK,≤4 步 · 自适应 Teff 延拓——步长 1250K 起、成功 ×1.5 恢复、失败二分至 25K 折叠墙,≤48 步 · C/N 丰度轴延拓——高温域(Teff>30kK)专用,严格同族种子沿 C(优先)/N 轴 0.2 dex 起步 延拓阶段 NITER 下限提至 300(慢收敛 waypoint 迭代饥饿误判修复) - runner: 稳定化多档回退(DPSILG/POPZER 三档互补,联合回收 41/65); 域门控 Teff≤30kK——高温高金属域实测旋钮致散(17 拍爆至 4e16),域外跳过 - 梯级种子持久化: 收敛中间模型登记 ladder_seeds 随上报落 server seeds 表 (任务失败也上传,合成名 _ladder 与真实网格点零冲突),簇内相邻失败点自动复用 调度与执行: - scheduler: 策略解析新增 seed_step_stab 臂——find_exact_family_seed_from_db 严格 同物理族判定(不做 global 退化,防 ladder 中间种子 ΔTeff≤5000K 误命中), 排除本点历史已用种子实现重试轮换;链在 stab 耗尽时轮换未试过同族邻居重派 - executor: seed_step_stab 补种子下载(漏列曾致 78 任务假失败,seed_nc 无 fort.8 崩溃); Teff>30kK 域外自动降级普通种子链 收敛判据: - conv_check: 热启动豁免——首拍 max_relc<1(种子已近解)时首末比 1e3 判据数学上 不可达,豁免后交五重物理硬门槛裁决(修复 nl_ladder 0.038→6e-4 物理全过被误杀); 冷启动仍受判据门控 workflow 生命周期: - workflows/tasks: 完成 flip 增加「未消费回退链」阻塞子句——failed 点策略链未耗尽 或链尾 seed_step_stab 尚有未试过同族种子时不得置 completed(修复最后活跃点 cold 失败上报抢先 flip、still_running 守卫拦截后续策略永不派发);按 failed_stage 归因 (synspec 失败行只看 synspec 链,防 stale 审计副本永久卡死)+ json_valid 脏行防护 统计与前端: - 统计新增权威口径 tlusty_converged(不按策略拆)与 seed_step_stab_converged 分项, 前端详情页色带/概览卡消费权威总数并新增稳定化青色段(修复 stab 收敛点漏计, 生产 9137/9216 差额);M14 迁移回填历史 completed 点的 tlusty_status 文档: - 新增 failed81 POPZER/DPSILG 制胜配方根因分析、Windows 节点经跳板 RDP 运维手册; failed400 增补 ladder 生产化实现与第二轮 121 残点实测矩阵
328 lines
12 KiB
JavaScript
328 lines
12 KiB
JavaScript
/* 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 }),
|
||
});
|
||
}
|