核心变更:
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
195 lines
8.4 KiB
JavaScript
195 lines
8.4 KiB
JavaScript
/* 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 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 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');
|
|
|
|
if (countBadge) {
|
|
countBadge.textContent = `${nodes ? nodes.length : 0} 个节点`;
|
|
}
|
|
if (!tbody) return;
|
|
|
|
if (!nodes || nodes.length === 0) {
|
|
const emptyHtml = `
|
|
<tr>
|
|
<td colspan="7" class="empty-cell">
|
|
暂无计算节点记录。启动 DCTS Node Worker 提交注册申请后,在此审批授权接入集群。
|
|
</td>
|
|
</tr>
|
|
`;
|
|
if (tbody.innerHTML.trim() !== emptyHtml.trim()) {
|
|
tbody.innerHTML = emptyHtml;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 构建目前现有 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);
|
|
});
|
|
|
|
// 如果 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);
|
|
}
|
|
});
|
|
}
|