Files
fmq f2700031ce feat(all): seed_step_stab 稳定化种子链与同族种子轮换、ladder/自适应 Teff/丰度轴延拓三级回退与梯级种子入库复用、热启动首末比豁免、workflow 完成翻转回退链阻塞修复、大气收敛权威统计与 M14 回填
收敛攻坚(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 残点实测矩阵
2026-09-02 19:37:25 +08:00

328 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 }),
});
}