feat(all): 源精度命名体系、工作流可观测台、节点停用管理与白名单归档

核心变更:

  1. GridAxisValue 源精度命名
     - 新增 GridAxisValue 类型,携带 f64 数值 + YAML 源书写文本(Deref 透明兼容算术)
     - config.rs 绕过 serde_yaml 归一化,逐 token 捕获轴值原文(logg: 5.0 → g5.0)
     - runner/executor/scheduler 全链路改用 DB TEXT 列权威 point_name,
       修复 REAL 列回读丢精度导致的 model_name 错配

  2. 工作流执行可观测台
     - 新增 stats/progress/points 三组 API(进度时间序列、经验速率 ETA、
       停滞预警、逐点明细分页、收敛性热力图数据)
     - 新增 workflow_progress_snapshots 表 + tasks/grid_points 耗时列
     - runner 携带 last_iter/worst_depth/n_depths 进 conv.json
     - 前端新增 hash 路由、工作流详情页(概览/网格点/收敛分析三 Tab)、YAML 编辑器

  3. 节点停用/启用管理
     - 新增 disabled 状态 + disable/enable API;停用节点保持心跳但停止分发,
       worker 空闲待命而非退出;移除 revoke API,token 失效统一走重发覆盖;
       移除 host_name 字段

  4. 白名单结果归档
     - 新增 result_filter 模块,只归档有语义产物,丢弃 Tlusty 中间单元(~2MB/模型)
     - executor 原子写入归档 + 200 点 LRU 上限

  5. 历史数据导入
     - sync_seeds 重写为 import_results:经 /admin/import_seed 标记 converged +
       按新版命名迁移产物树

  6. 部署与目录重规划
     - data/results→seeds、data/archive→result + migrate_data_dirs.sh
     - deploy.sh 增强(SSH 复用、Profile、远程 env);Dockerfile 瘦身

  7. 文档同步更新 api/database/architecture/deployment
This commit is contained in:
fmq
2026-07-31 01:34:05 +08:00
parent b91f1e4fa5
commit 1bfa240cb0
73 changed files with 12332 additions and 1608 deletions
+155 -83
View File
@@ -8,20 +8,122 @@ export function renderNodesTableSkeleton() {
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-text skeleton-w-110"></div></td>
<td><div class="skeleton-shimmer skeleton-text skeleton-w-90"></div></td>
<td><div class="skeleton-shimmer skeleton-text skeleton-w-80"></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>
<td><div class="skeleton-shimmer skeleton-text skeleton-w-70"></div></td>
<td><div class="skeleton-shimmer skeleton-text skeleton-w-60"></div></td>
</tr>
`).join('');
tbody.innerHTML = skeletonRows;
}
// 心跳时间容错:字段缺失或非法时间戳一律显示占位符,避免渲染出 "Invalid Date"
function formatHeartbeat(ts) {
if (!ts) return '—';
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleTimeString('zh-CN');
}
// 行重建签名:仅状态/凭据决定徽章与操作按钮。槽位、负载、心跳是秒级高频字段,
// 走 textContent 原位刷新——整行 innerHTML 重建会销毁用户正悬停/聚焦的按钮。
function rowSignature(node) {
return `${node.status || ''}|${node.token_status || ''}`;
}
function volatileValues(node) {
return {
slots: `${Number(node.active_slots || 0)} / ${Number(node.max_slots || 4)}`,
usage: `${Number(node.cpu_usage || 0).toFixed(1)}% / ${Number(node.memory_usage || 0).toFixed(1)}%`,
heartbeat: formatHeartbeat(node.last_heartbeat),
};
}
function updateVolatileCells(tr, node) {
const v = volatileValues(node);
const slotsCell = tr.querySelector('.col-slots');
if (slotsCell && slotsCell.textContent !== v.slots) slotsCell.textContent = v.slots;
const usageCell = tr.querySelector('.col-usage');
if (usageCell && usageCell.textContent !== v.usage) usageCell.textContent = v.usage;
const hbCell = tr.querySelector('.col-heartbeat');
if (hbCell && hbCell.textContent !== v.heartbeat) hbCell.textContent = v.heartbeat;
}
function buildRowHtml(node, nodeId) {
const isPending = node.status === 'pending_approval';
const isOnline = node.status === 'online' || node.status === 'active';
const isDisabled = node.status === 'disabled';
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" class="badge-icon-space"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>待审批</span>';
} else if (isDisabled) {
statusBadge = '<span class="status-badge secondary">已停用</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 {
tokenBadge = '<span class="status-badge secondary">无凭据</span>';
}
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 {
// 启停切换:disabled 时显示「启用」(绿 ▶),否则显示「停用」(琥珀 ⏸)。
// 停用用琥珀+暂停符号,表达"可恢复的软暂停"——节点保持存活待命,与「重发 Token」
// (蓝 🔄,轮换凭据、旧 token 立即失效)是两类不同运维操作,颜色与图标刻意区分。
const toggleBtn = isDisabled
? `<button class="btn-action-icon btn-action-approve" data-node-action="enable" 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" stroke-linecap="round"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg>
</button>`
: `<button class="btn-action-icon btn-action-warn" data-node-action="disable" 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" stroke-linecap="round"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></svg>
</button>`;
actionBtns = `
<div class="action-cell-wrap">
${toggleBtn}
<button class="btn-action-icon btn-action-reissue" data-node-action="reissue" data-node-id="${nodeId}" title="重新颁发专属 Token(旧 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>
</div>
`;
}
const v = volatileValues(node);
return `
<td class="col-node-id node-id" title="${nodeId}">${nodeId}</td>
<td class="col-slots tabular-num">${v.slots}</td>
<td class="col-usage tabular-num">${v.usage}</td>
<td class="col-status">${statusBadge}</td>
<td class="col-token">${tokenBadge}</td>
<td class="col-heartbeat tabular-num">${v.heartbeat}</td>
<td class="col-actions">${actionBtns}</td>
`;
}
export function renderNodesTable(nodes) {
const tbody = document.getElementById('nodes-table-body');
const countBadge = document.getElementById('node-count-badge');
@@ -32,91 +134,61 @@ export function renderNodesTable(nodes) {
if (!tbody) return;
if (!nodes || nodes.length === 0) {
tbody.innerHTML = `
const emptyHtml = `
<tr>
<td colspan="8" class="empty-cell">
<td colspan="7" class="empty-cell">
暂无计算节点记录。启动 DCTS Node Worker 提交注册申请后,在此审批授权接入集群。
</td>
</tr>
`;
if (tbody.innerHTML.trim() !== emptyHtml.trim()) {
tbody.innerHTML = emptyHtml;
}
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');
// 构建目前现有 tr 的 Map (nodeId -> tr Element)
const existingRows = new Map();
tbody.querySelectorAll('tr[data-node-row-id]').forEach(tr => {
existingRows.set(tr.getAttribute('data-node-row-id'), tr);
});
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;
// 如果 tbody 里包含 skeleton 或 empty-cell,清空
if (tbody.querySelector('.skeleton-row, .empty-cell')) {
tbody.innerHTML = '';
existingRows.clear();
}
const updatedRowElements = [];
nodes.forEach(node => {
const nodeId = escapeHtml(node.node_id || node.id || 'N/A');
const sig = rowSignature(node);
let tr = existingRows.get(nodeId);
if (tr && tr.getAttribute('data-sig') === sig) {
// 状态/凭据未变:仅原位刷新高频数值单元格,不触碰徽章与操作按钮
updateVolatileCells(tr, node);
} else {
const html = buildRowHtml(node, nodeId);
if (!tr) {
tr = document.createElement('tr');
tr.setAttribute('data-node-row-id', nodeId);
}
tr.innerHTML = html;
tr.setAttribute('data-sig', sig);
}
existingRows.delete(nodeId);
updatedRowElements.push(tr);
});
// 移除不再存在的旧 tr
existingRows.forEach(tr => tr.remove());
// 按顺序重排/挂载到 tbody
updatedRowElements.forEach((tr, idx) => {
if (tbody.children[idx] !== tr) {
tbody.insertBefore(tr, tbody.children[idx] || null);
}
});
}