1462 lines
61 KiB
JavaScript
1462 lines
61 KiB
JavaScript
// 标签切换功能
|
||
function openTab(evt, tabName) {
|
||
var i, tabContent, tabLinks;
|
||
tabContent = document.getElementsByClassName("tab-content");
|
||
for (i = 0; i < tabContent.length; i++) {
|
||
tabContent[i].classList.add("hidden");
|
||
}
|
||
tabLinks = document.getElementsByClassName("tab");
|
||
for (i = 0; i < tabLinks.length; i++) {
|
||
tabLinks[i].classList.remove("active");
|
||
}
|
||
document.getElementById(tabName).classList.remove("hidden");
|
||
evt.currentTarget.classList.add("active");
|
||
|
||
// 当切换到其他标签页时,清除所有日志自动更新定时器
|
||
if (tabName !== 'processes') {
|
||
for (let processId in logTimers) {
|
||
clearInterval(logTimers[processId]);
|
||
delete logTimers[processId];
|
||
}
|
||
}
|
||
|
||
// 如果切换到进程页面,刷新进程列表
|
||
if (tabName === 'processes') {
|
||
loadProcesses();
|
||
} else if (tabName === 'logfiles') {
|
||
loadLogFiles();
|
||
}
|
||
}
|
||
|
||
// 根据训练类型显示/隐藏特定参数
|
||
document.getElementById('train_type').addEventListener('change', function() {
|
||
const trainType = this.value;
|
||
const pretrainSftFields = document.querySelectorAll('.pretrain-sft');
|
||
const fromWeightFields = document.querySelectorAll('.from-weight');
|
||
const loraFields = document.querySelectorAll('.lora');
|
||
const dpoFields = document.querySelectorAll('.dpo');
|
||
const dpoParamCard = document.querySelector('.parameter-card.dpo');
|
||
const ppoFields = document.querySelectorAll('.ppo');
|
||
const ppoParamCard = document.querySelector('.parameter-card.ppo');
|
||
|
||
pretrainSftFields.forEach(field => {
|
||
field.style.display = (trainType === 'pretrain' || trainType === 'sft' || trainType === 'dpo' || trainType === 'ppo') ? 'block' : 'none';
|
||
});
|
||
|
||
fromWeightFields.forEach(field => {
|
||
field.style.display = (trainType !== 'ppo') ? 'block' : 'none';
|
||
});
|
||
|
||
loraFields.forEach(field => {
|
||
field.style.display = trainType === 'lora' ? 'block' : 'none';
|
||
});
|
||
|
||
dpoFields.forEach(field => {
|
||
field.style.display = trainType === 'dpo' ? 'block' : 'none';
|
||
});
|
||
|
||
ppoFields.forEach(field => {
|
||
field.style.display = trainType === 'ppo' ? 'block' : 'none';
|
||
});
|
||
|
||
if (dpoParamCard) {
|
||
dpoParamCard.style.display = trainType === 'dpo' ? 'block' : 'none';
|
||
}
|
||
|
||
if (ppoParamCard) {
|
||
ppoParamCard.style.display = trainType === 'ppo' ? 'block' : 'none';
|
||
}
|
||
|
||
// 设置默认值
|
||
if (trainType === 'pretrain') {
|
||
document.getElementById('save_dir').value = '../out';
|
||
document.getElementById('save_weight').value = 'pretrain';
|
||
document.getElementById('epochs').value = '1';
|
||
document.getElementById('batch_size').value = '32';
|
||
document.getElementById('learning_rate').value = '5e-4';
|
||
document.getElementById('data_path').value = '../dataset/pretrain_hq.jsonl';
|
||
document.getElementById('from_weight').value = 'none';
|
||
document.getElementById('log_interval').value = '100';
|
||
document.getElementById('save_interval').value = '100';
|
||
// 模型结构参数默认值
|
||
document.getElementById('hidden_size').value = '512';
|
||
document.getElementById('num_hidden_layers').value = '8';
|
||
document.getElementById('max_seq_len').value = '512';
|
||
document.getElementById('use_moe').value = '0';
|
||
} else if (trainType === 'sft') {
|
||
document.getElementById('save_dir').value = '../out';
|
||
document.getElementById('save_weight').value = 'full_sft';
|
||
document.getElementById('epochs').value = '2';
|
||
document.getElementById('batch_size').value = '16';
|
||
document.getElementById('learning_rate').value = '5e-7';
|
||
document.getElementById('data_path').value = '../dataset/sft_mini_512.jsonl';
|
||
document.getElementById('from_weight').value = 'pretrain';
|
||
document.getElementById('log_interval').value = '100';
|
||
document.getElementById('save_interval').value = '100';
|
||
// 模型结构参数默认值
|
||
document.getElementById('hidden_size').value = '512';
|
||
document.getElementById('num_hidden_layers').value = '8';
|
||
document.getElementById('max_seq_len').value = '512';
|
||
document.getElementById('use_moe').value = '0';
|
||
} else if (trainType === 'lora') {
|
||
document.getElementById('save_dir').value = '../out/lora';
|
||
document.getElementById('lora_name').value = 'lora_identity';
|
||
document.getElementById('epochs').value = '50';
|
||
document.getElementById('batch_size').value = '32';
|
||
document.getElementById('learning_rate').value = '1e-4';
|
||
document.getElementById('data_path').value = '../dataset/lora_identity.jsonl';
|
||
document.getElementById('from_weight').value = 'full_sft';
|
||
document.getElementById('log_interval').value = '10';
|
||
document.getElementById('save_interval').value = '1';
|
||
// 模型结构参数默认值
|
||
document.getElementById('hidden_size').value = '512';
|
||
document.getElementById('num_hidden_layers').value = '8';
|
||
document.getElementById('max_seq_len').value = '512';
|
||
document.getElementById('use_moe').value = '0';
|
||
} else if (trainType === 'dpo') {
|
||
document.getElementById('save_dir').value = '../out';
|
||
document.getElementById('save_weight').value = 'dpo';
|
||
document.getElementById('epochs').value = '1';
|
||
document.getElementById('batch_size').value = '4';
|
||
document.getElementById('learning_rate').value = '4e-8';
|
||
document.getElementById('data_path').value = '../dataset/dpo.jsonl';
|
||
document.getElementById('from_weight').value = 'full_sft';
|
||
document.getElementById('log_interval').value = '100';
|
||
document.getElementById('save_interval').value = '100';
|
||
document.getElementById('beta').value = '0.1';
|
||
// 模型结构参数默认值
|
||
document.getElementById('hidden_size').value = '512';
|
||
document.getElementById('num_hidden_layers').value = '8';
|
||
document.getElementById('max_seq_len').value = '1024';
|
||
document.getElementById('use_moe').value = '0';
|
||
} else if (trainType === 'ppo') {
|
||
document.getElementById('save_dir').value = '../out';
|
||
document.getElementById('save_weight').value = 'ppo_actor';
|
||
document.getElementById('epochs').value = '1';
|
||
document.getElementById('batch_size').value = '2';
|
||
document.getElementById('learning_rate').value = '8e-8';
|
||
document.getElementById('data_path').value = '../dataset/rlaif-mini.jsonl';
|
||
document.getElementById('log_interval').value = '1';
|
||
document.getElementById('save_interval').value = '10';
|
||
// PPO特有参数默认值
|
||
document.getElementById('clip_epsilon').value = '0.1';
|
||
document.getElementById('vf_coef').value = '0.5';
|
||
document.getElementById('kl_coef').value = '0.02';
|
||
document.getElementById('reasoning').value = '1';
|
||
document.getElementById('update_old_actor_freq').value = '4';
|
||
document.getElementById('reward_model_path').value = '../../internlm2-1_8b-reward';
|
||
// 模型结构参数默认值
|
||
document.getElementById('hidden_size').value = '512';
|
||
document.getElementById('num_hidden_layers').value = '8';
|
||
document.getElementById('max_seq_len').value = '66';
|
||
document.getElementById('use_moe').value = '0';
|
||
}
|
||
});
|
||
|
||
// 初始触发一次change事件以设置默认值
|
||
document.getElementById('train_type').dispatchEvent(new Event('change'));
|
||
|
||
// 页面加载时启动进程状态轮询
|
||
window.addEventListener('load', () => {
|
||
startProcessPolling();
|
||
|
||
// 加载初始进程列表
|
||
loadProcesses();
|
||
});
|
||
|
||
// 在进程标签页切换时也触发一次检查
|
||
const tabLinks = document.querySelectorAll('.tab');
|
||
tabLinks.forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
// 给标签页切换一些时间完成
|
||
setTimeout(() => {
|
||
const processesTab = document.querySelector('.tab.active');
|
||
if (processesTab && processesTab.textContent.includes('进程')) {
|
||
checkProcessStatusChanges();
|
||
}
|
||
}, 100);
|
||
});
|
||
});
|
||
|
||
// 添加定时轮询机制,每5秒检查一次进程状态变化
|
||
let processPollingTimer;
|
||
|
||
// 开始进程状态轮询
|
||
function startProcessPolling() {
|
||
// 清除可能存在的旧定时器
|
||
if (processPollingTimer) {
|
||
clearInterval(processPollingTimer);
|
||
}
|
||
// 每5秒检查一次进程状态
|
||
processPollingTimer = setInterval(() => {
|
||
// 只有在进程标签页打开时才检查
|
||
const processesTab = document.querySelector('.tab.active');
|
||
if (processesTab && processesTab.textContent.includes('进程')) {
|
||
checkProcessStatusChanges();
|
||
}
|
||
}, 5000);
|
||
}
|
||
|
||
// 带超时和重试的fetch请求函数
|
||
function fetchWithTimeoutAndRetry(url, options = {}, timeout = 10000, retries = 3) {
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||
|
||
// 添加缓存控制头
|
||
const fetchOptions = {
|
||
...options,
|
||
headers: {
|
||
...options.headers,
|
||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||
'Pragma': 'no-cache',
|
||
'Expires': '0'
|
||
},
|
||
signal: controller.signal
|
||
};
|
||
|
||
return fetch(url, fetchOptions)
|
||
.then(response => {
|
||
clearTimeout(timeoutId);
|
||
|
||
// 检查响应是否成功
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP错误! 状态码: ${response.status}`);
|
||
}
|
||
return response;
|
||
})
|
||
.catch(error => {
|
||
clearTimeout(timeoutId);
|
||
|
||
// 如果是AbortError,说明请求超时
|
||
if (error.name === 'AbortError') {
|
||
throw new Error('请求超时');
|
||
}
|
||
|
||
// 如果还有重试次数,重新发起请求
|
||
if (retries > 0) {
|
||
console.warn(`请求失败,${retries}次重试剩余,${timeout/2}ms后重试...`);
|
||
return new Promise(resolve => {
|
||
setTimeout(() => {
|
||
resolve(fetchWithTimeoutAndRetry(url, options, timeout, retries - 1));
|
||
}, timeout / 2);
|
||
});
|
||
}
|
||
|
||
// 重试次数用完,抛出错误
|
||
throw error;
|
||
});
|
||
}
|
||
|
||
// 检查进程状态变化(特别是错误状态)
|
||
function checkProcessStatusChanges() {
|
||
fetchWithTimeoutAndRetry('/processes')
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
// 遍历每个进程,检查状态变化
|
||
data.forEach(process => {
|
||
const processItem = document.querySelector(`[data-process-id="${process.id}"]`);
|
||
if (processItem) {
|
||
const currentStatus = processItem.dataset.processStatus;
|
||
const newStatus = process.status;
|
||
|
||
// 如果状态发生变化,更新进程项
|
||
if (currentStatus !== newStatus) {
|
||
updateProcessItem(processItem, process);
|
||
|
||
// 如果状态变为出错,显示通知
|
||
if (newStatus === '出错') {
|
||
showNotification(`进程 ${process.train_type} 已出错`, 'error');
|
||
}
|
||
}
|
||
}
|
||
});
|
||
})
|
||
.catch(error => {
|
||
console.error('检查进程状态时出错:', error);
|
||
// 显示连接错误通知
|
||
showNotification('连接服务器失败,请刷新页面重试', 'error');
|
||
});
|
||
}
|
||
|
||
// 检查并打开SwanLab链接
|
||
function checkAndOpenSwanlab(processId) {
|
||
// 首先检查训练监控设置
|
||
const processItem = document.querySelector(`[data-process-id="${processId}"]`);
|
||
const trainMonitor = processItem ? processItem.dataset.trainMonitor : 'none';
|
||
|
||
// 如果训练监控设置为'none',显示提示信息
|
||
if (trainMonitor === 'none') {
|
||
showNotification('此训练未启用监控功能', 'info');
|
||
return;
|
||
}
|
||
|
||
// 首先从DOM中获取最新的URL
|
||
let currentUrl = processItem ? processItem.dataset.swanlabUrl : '';
|
||
|
||
// 如果没有URL或URL不完整,尝试从后端获取最新的进程信息
|
||
if (!currentUrl || currentUrl.trim() === '') {
|
||
fetchWithTimeoutAndRetry('/processes')
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
const process = data.find(p => p.id === processId);
|
||
if (process && process.swanlab_url) {
|
||
currentUrl = process.swanlab_url;
|
||
// 更新DOM中的URL数据属性
|
||
if (processItem) {
|
||
processItem.dataset.swanlabUrl = currentUrl;
|
||
}
|
||
openSwanlab(processId, currentUrl);
|
||
} else {
|
||
// 没有找到有效的链接
|
||
showNotification('SwanLab链接尚未生成,请稍后再试', 'info');
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('获取进程信息失败:', error);
|
||
showNotification('获取SwanLab链接失败,请稍后再试', 'error');
|
||
});
|
||
} else {
|
||
// 有URL,直接打开
|
||
openSwanlab(processId, currentUrl);
|
||
}
|
||
}
|
||
|
||
// 打开SwanLab链接
|
||
function openSwanlab(processId, url) {
|
||
// 检查URL是否有效
|
||
if (!url || typeof url !== 'string' || url.trim() === '' || !isValidUrl(url)) {
|
||
showNotification('SwanLab链接无效或尚未生成', 'info');
|
||
return;
|
||
}
|
||
|
||
// 在新窗口打开链接
|
||
const newWindow = window.open(url, '_blank');
|
||
|
||
// 检查窗口是否成功打开
|
||
if (newWindow) {
|
||
// 显示成功通知
|
||
showNotification('正在打开SwanLab页面', 'info');
|
||
} else {
|
||
// 弹出窗口被阻止
|
||
showNotification('无法打开新窗口,请检查浏览器设置', 'error');
|
||
}
|
||
}
|
||
|
||
// 检查URL是否有效
|
||
function isValidUrl(url) {
|
||
try {
|
||
// 尝试创建URL对象,如果失败说明URL无效
|
||
new URL(url);
|
||
return true;
|
||
} catch (error) {
|
||
// 简单检查是否以http或https开头
|
||
return url.toLowerCase().startsWith('http://') || url.toLowerCase().startsWith('https://');
|
||
}
|
||
}
|
||
|
||
// 更新单个进程项
|
||
function updateProcessItem(processItem, process) {
|
||
// 更新数据属性
|
||
processItem.dataset.processStatus = process.status;
|
||
processItem.dataset.trainMonitor = process.train_monitor || 'none';
|
||
|
||
// 更新SwanLab URL数据属性
|
||
if (process.swanlab_url) {
|
||
processItem.dataset.swanlabUrl = process.swanlab_url;
|
||
}
|
||
|
||
// 更新状态类和文本
|
||
const statusElement = processItem.querySelector('.process-status');
|
||
if (statusElement) {
|
||
// 移除所有状态类
|
||
statusElement.classList.remove('status-running', 'status-manual-stop', 'status-error', 'status-completed');
|
||
|
||
// 添加新的状态类
|
||
let statusClass = '';
|
||
if (process.status === '运行中') {
|
||
statusClass = 'status-running';
|
||
} else if (process.status === '手动停止') {
|
||
statusClass = 'status-manual-stop';
|
||
} else if (process.status === '出错') {
|
||
statusClass = 'status-error';
|
||
} else {
|
||
statusClass = 'status-completed';
|
||
}
|
||
|
||
statusElement.classList.add(statusClass);
|
||
statusElement.textContent = process.status;
|
||
}
|
||
|
||
// 更新SwanLab按钮
|
||
const existingSwanlabButton = processItem.querySelector('.btn-swanlab');
|
||
const buttonContainer = processItem.querySelector('div:nth-child(2)'); // 按钮容器是第二个div
|
||
|
||
// 只有当train_monitor不是'none'时才显示SwanLab按钮
|
||
const shouldShowSwanlab = process.train_monitor !== 'none';
|
||
|
||
// 如果应该显示按钮但不存在,则创建并添加
|
||
if (shouldShowSwanlab && !existingSwanlabButton && buttonContainer) {
|
||
const swanlabButton = document.createElement('button');
|
||
swanlabButton.className = 'btn-swanlab';
|
||
swanlabButton.textContent = 'SwanLab';
|
||
swanlabButton.onclick = function() {
|
||
checkAndOpenSwanlab(process.id);
|
||
};
|
||
|
||
// 插入到停止按钮之前
|
||
const stopButton = buttonContainer.querySelector('.btn-stop');
|
||
if (stopButton) {
|
||
buttonContainer.insertBefore(swanlabButton, stopButton);
|
||
} else {
|
||
// 如果没有停止按钮,插入到刷新按钮之后
|
||
const refreshButton = buttonContainer.querySelector('.btn-logs:nth-child(2)');
|
||
if (refreshButton) {
|
||
buttonContainer.insertBefore(swanlabButton, refreshButton.nextSibling);
|
||
}
|
||
}
|
||
} else if (!shouldShowSwanlab && existingSwanlabButton) {
|
||
// 如果不应该显示按钮但存在,则移除
|
||
existingSwanlabButton.remove();
|
||
} else if (existingSwanlabButton) {
|
||
// 更新现有按钮的点击事件
|
||
existingSwanlabButton.onclick = function() {
|
||
checkAndOpenSwanlab(process.id);
|
||
};
|
||
}
|
||
|
||
// 更新停止按钮
|
||
const stopButton = processItem.querySelector('.btn-stop');
|
||
if (stopButton) {
|
||
if (!process.running) {
|
||
stopButton.remove();
|
||
}
|
||
} else if (process.running && buttonContainer) {
|
||
const newStopButton = document.createElement('button');
|
||
newStopButton.className = 'btn-stop';
|
||
newStopButton.onclick = () => stopProcess(process.id);
|
||
newStopButton.textContent = '停止训练';
|
||
buttonContainer.appendChild(newStopButton);
|
||
}
|
||
|
||
// 处理删除按钮
|
||
const deleteButton = processItem.querySelector('.btn-delete');
|
||
if (!process.running) {
|
||
// 非运行中状态应该有删除按钮
|
||
if (!deleteButton) {
|
||
const buttonContainer = processItem.querySelector('div:last-child');
|
||
if (buttonContainer) {
|
||
const newDeleteButton = document.createElement('button');
|
||
newDeleteButton.className = 'btn-delete';
|
||
newDeleteButton.onclick = () => deleteProcess(process.id);
|
||
newDeleteButton.textContent = '删除';
|
||
buttonContainer.appendChild(newDeleteButton);
|
||
}
|
||
}
|
||
} else if (deleteButton) {
|
||
// 运行中状态不应该有删除按钮
|
||
deleteButton.remove();
|
||
}
|
||
|
||
// 如果进程不再运行中,清除日志定时器
|
||
if (!process.running && logTimers[process.id]) {
|
||
clearInterval(logTimers[process.id]);
|
||
delete logTimers[process.id];
|
||
}
|
||
}
|
||
|
||
// 加载进程列表
|
||
function loadProcesses() {
|
||
fetchWithTimeoutAndRetry('/processes')
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
const processList = document.getElementById('process-list');
|
||
processList.innerHTML = '';
|
||
|
||
if (data.length === 0) {
|
||
processList.innerHTML = '<p>暂无训练进程</p>';
|
||
return;
|
||
}
|
||
|
||
// 按启动时间倒序排序,最新启动的进程显示在前面
|
||
data.sort((a, b) => new Date(b.start_time) - new Date(a.start_time));
|
||
|
||
// 按训练类型分组
|
||
const groupedProcesses = {};
|
||
data.forEach(process => {
|
||
if (!groupedProcesses[process.train_type]) {
|
||
groupedProcesses[process.train_type] = [];
|
||
}
|
||
groupedProcesses[process.train_type].push(process);
|
||
});
|
||
|
||
// 定义训练类型的显示顺序(可以根据需要调整)
|
||
const trainTypeOrder = ['pretrain', 'sft', 'lora', 'dpo'];
|
||
|
||
// 创建类型分组并添加收起/展开功能的辅助函数
|
||
function createTypeGroupWithToggle(trainType, processes) {
|
||
// 创建类型分组容器
|
||
const typeGroup = document.createElement('div');
|
||
typeGroup.className = 'process-type-group';
|
||
|
||
// 创建标题容器(包含标题文本和切换按钮)
|
||
const titleContainer = document.createElement('div');
|
||
titleContainer.className = 'process-type-header';
|
||
titleContainer.dataset.expanded = 'true'; // 默认展开状态
|
||
|
||
// 添加类型标题
|
||
const typeTitle = document.createElement('h3');
|
||
typeTitle.className = 'process-type-title';
|
||
typeTitle.textContent = getTrainTypeDisplayName(trainType);
|
||
|
||
// 添加切换按钮
|
||
const toggleButton = document.createElement('button');
|
||
toggleButton.className = 'toggle-btn';
|
||
toggleButton.innerHTML = '▼'; // 向下箭头表示展开
|
||
toggleButton.onclick = function(e) {
|
||
e.stopPropagation(); // 防止触发标题容器的点击事件
|
||
toggleGroup(titleContainer);
|
||
};
|
||
|
||
// 将标题和按钮添加到容器
|
||
titleContainer.appendChild(typeTitle);
|
||
titleContainer.appendChild(toggleButton);
|
||
|
||
// 添加点击标题也可以切换展开/收起
|
||
titleContainer.onclick = function() {
|
||
toggleGroup(titleContainer);
|
||
};
|
||
|
||
// 创建内容容器,用于容纳进程项
|
||
const contentContainer = document.createElement('div');
|
||
contentContainer.className = 'process-type-content';
|
||
|
||
// 添加该类型的所有进程
|
||
processes.forEach(process => {
|
||
addProcessItemToGroup(contentContainer, process);
|
||
});
|
||
|
||
// 将标题容器和内容容器添加到类型分组
|
||
typeGroup.appendChild(titleContainer);
|
||
typeGroup.appendChild(contentContainer);
|
||
|
||
return typeGroup;
|
||
}
|
||
|
||
// 切换分组展开/收起状态的函数
|
||
function toggleGroup(headerElement) {
|
||
const isExpanded = headerElement.dataset.expanded === 'true';
|
||
const contentContainer = headerElement.nextElementSibling;
|
||
const toggleButton = headerElement.querySelector('.toggle-btn');
|
||
|
||
if (isExpanded) {
|
||
// 收起分组
|
||
headerElement.dataset.expanded = 'false';
|
||
contentContainer.style.maxHeight = '0';
|
||
contentContainer.style.overflow = 'hidden';
|
||
toggleButton.innerHTML = '▶'; // 向右箭头表示收起
|
||
} else {
|
||
// 展开分组前先重置样式
|
||
contentContainer.style.overflow = 'hidden'; // 确保计算高度准确
|
||
contentContainer.style.maxHeight = 'none'; // 临时设置为none以获取真实高度
|
||
|
||
// 获取真实高度
|
||
const actualHeight = contentContainer.scrollHeight;
|
||
|
||
// 然后先设置为0,准备动画
|
||
contentContainer.style.maxHeight = '0';
|
||
|
||
// 强制重排
|
||
contentContainer.offsetHeight;
|
||
|
||
// 展开分组
|
||
headerElement.dataset.expanded = 'true';
|
||
contentContainer.style.maxHeight = actualHeight + 'px';
|
||
|
||
setTimeout(() => {
|
||
// 动画完成后,设置为真实高度并允许溢出
|
||
contentContainer.style.maxHeight = 'none';
|
||
contentContainer.style.overflow = 'visible';
|
||
}, 300); // 动画完成后显示溢出内容
|
||
|
||
toggleButton.innerHTML = '▼'; // 向下箭头表示展开
|
||
}
|
||
}
|
||
|
||
// 首先显示有明确顺序的训练类型
|
||
trainTypeOrder.forEach(trainType => {
|
||
if (groupedProcesses[trainType]) {
|
||
const typeGroup = createTypeGroupWithToggle(trainType, groupedProcesses[trainType]);
|
||
processList.appendChild(typeGroup);
|
||
|
||
// 从分组中移除已处理的类型
|
||
delete groupedProcesses[trainType];
|
||
}
|
||
});
|
||
|
||
// 显示剩余的训练类型(不在预定义顺序中的)
|
||
Object.keys(groupedProcesses).forEach(trainType => {
|
||
const typeGroup = createTypeGroupWithToggle(trainType, groupedProcesses[trainType]);
|
||
processList.appendChild(typeGroup);
|
||
});
|
||
});
|
||
}
|
||
|
||
// 获取训练类型的显示名称
|
||
function getTrainTypeDisplayName(trainType) {
|
||
const typeNames = {
|
||
'pretrain': '预训练 (Pretrain)',
|
||
'sft': '全参数监督微调 (SFT - Full)',
|
||
'lora': 'LoRA监督微调 (SFT - Lora)',
|
||
'dpo': '直接偏好优化 (RL - DPO)'
|
||
};
|
||
return typeNames[trainType] || trainType;
|
||
}
|
||
|
||
// 添加进程项到分组
|
||
function addProcessItemToGroup(parentElement, process) {
|
||
const processItem = document.createElement('div');
|
||
processItem.className = 'process-item';
|
||
|
||
let statusClass = '';
|
||
// 根据后端返回的status字段设置状态类
|
||
if (process.status === '运行中') {
|
||
statusClass = 'status-running';
|
||
} else if (process.status === '手动停止') {
|
||
statusClass = 'status-manual-stop';
|
||
} else if (process.status === '出错') {
|
||
statusClass = 'status-error';
|
||
} else {
|
||
statusClass = 'status-completed';
|
||
}
|
||
|
||
// 设置进程数据属性,用于后续检查状态
|
||
processItem.dataset.processId = process.id;
|
||
processItem.dataset.processStatus = process.status;
|
||
processItem.dataset.trainMonitor = process.train_monitor || 'none';
|
||
processItem.dataset.swanlabUrl = process.swanlab_url || '';
|
||
|
||
// 检查是否显示删除按钮(对于非运行中的进程)
|
||
const showDeleteButton = !process.running;
|
||
|
||
// 只有当train_monitor不是'none'时才显示SwanLab按钮
|
||
const showSwanlabButton = process.train_monitor !== 'none';
|
||
const swanlabButton = showSwanlabButton ? `<button class="btn-swanlab" onclick="checkAndOpenSwanlab('${process.id}')">
|
||
SwanLab
|
||
</button>` : '';
|
||
|
||
processItem.innerHTML = `
|
||
<div class="process-info">
|
||
<div>
|
||
<strong>${process.start_time}</strong>
|
||
</div>
|
||
<div>
|
||
<span class="process-status ${statusClass}">${process.status}</span>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<button class="btn-logs" onclick="showLogs('${process.id}')">查看日志</button>
|
||
<button class="btn-logs" onclick="refreshLog('${process.id}')">刷新日志</button>
|
||
${swanlabButton}
|
||
${process.running ? `<button class="btn-stop" onclick="stopProcess('${process.id}')">停止训练</button>` : ''}
|
||
${showDeleteButton ? `<button class="btn-delete" onclick="deleteProcess('${process.id}')">删除</button>` : ''}
|
||
</div>
|
||
<div id="logs-${process.id}" class="logs-container hidden"></div>
|
||
`;
|
||
|
||
parentElement.appendChild(processItem);
|
||
}
|
||
|
||
// 存储训练日志定时器的ID
|
||
let logTimers = {};
|
||
|
||
// 显示日志
|
||
function showLogs(processId) {
|
||
const logsContainer = document.getElementById(`logs-${processId}`);
|
||
logsContainer.classList.toggle('hidden');
|
||
|
||
if (!logsContainer.classList.contains('hidden')) {
|
||
// 加载日志内容
|
||
loadLogContent(processId, logsContainer);
|
||
|
||
// 清除可能存在的旧定时器
|
||
if (logTimers[processId]) {
|
||
clearInterval(logTimers[processId]);
|
||
}
|
||
|
||
// 查找进程元素,获取其状态
|
||
const processItem = document.querySelector(`[data-process-id="${processId}"]`);
|
||
const isRunning = processItem && processItem.dataset.processStatus === '运行中';
|
||
|
||
// 只有运行中的进程才设置自动刷新定时器
|
||
if (isRunning) {
|
||
logTimers[processId] = setInterval(() => {
|
||
// 检查日志容器是否可见
|
||
if (!logsContainer.classList.contains('hidden')) {
|
||
// 再次检查进程状态,确保仍然是运行中
|
||
const currentProcessItem = document.querySelector(`[data-process-id="${processId}"]`);
|
||
const stillRunning = currentProcessItem && currentProcessItem.dataset.processStatus === '运行中';
|
||
|
||
if (stillRunning) {
|
||
loadLogContent(processId, logsContainer);
|
||
} else {
|
||
// 如果进程不再运行中,清除定时器
|
||
clearInterval(logTimers[processId]);
|
||
delete logTimers[processId];
|
||
}
|
||
}
|
||
}, 1000);
|
||
}
|
||
} else {
|
||
// 隐藏日志时清除定时器
|
||
if (logTimers[processId]) {
|
||
clearInterval(logTimers[processId]);
|
||
delete logTimers[processId];
|
||
}
|
||
}
|
||
}
|
||
|
||
// 刷新日志
|
||
function refreshLog(processId) {
|
||
const logsContainer = document.getElementById(`logs-${processId}`);
|
||
if (!logsContainer.classList.contains('hidden')) {
|
||
loadLogContent(processId, logsContainer);
|
||
|
||
// 查找进程元素,获取其状态
|
||
const processItem = document.querySelector(`[data-process-id="${processId}"]`);
|
||
const isRunning = processItem && processItem.dataset.processStatus === '运行中';
|
||
|
||
// 重置定时器,确保从现在开始每1秒更新一次
|
||
if (logTimers[processId]) {
|
||
clearInterval(logTimers[processId]);
|
||
}
|
||
|
||
// 只有运行中的进程才设置定时器
|
||
if (isRunning) {
|
||
logTimers[processId] = setInterval(() => {
|
||
if (!logsContainer.classList.contains('hidden')) {
|
||
// 再次检查进程状态
|
||
const currentProcessItem = document.querySelector(`[data-process-id="${processId}"]`);
|
||
const stillRunning = currentProcessItem && currentProcessItem.dataset.processStatus === '运行中';
|
||
|
||
if (stillRunning) {
|
||
loadLogContent(processId, logsContainer);
|
||
} else {
|
||
// 如果进程不再运行中,清除定时器
|
||
clearInterval(logTimers[processId]);
|
||
delete logTimers[processId];
|
||
}
|
||
} else {
|
||
// 如果日志容器被隐藏,清除定时器
|
||
clearInterval(logTimers[processId]);
|
||
delete logTimers[processId];
|
||
}
|
||
}, 1000);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 加载日志内容
|
||
function loadLogContent(processId, logsContainer) {
|
||
// 显示加载状态
|
||
const oldContent = logsContainer.textContent;
|
||
const isScrolledToBottom = logsContainer.scrollHeight - logsContainer.scrollTop <= logsContainer.clientHeight + 10;
|
||
|
||
fetchWithTimeoutAndRetry(`/logs/${processId}`)
|
||
.then(response => {
|
||
if (!response.ok) {
|
||
throw new Error('获取日志失败');
|
||
}
|
||
return response.text();
|
||
})
|
||
.then(logs => {
|
||
// 更新日志内容
|
||
logsContainer.textContent = logs;
|
||
|
||
// 如果用户之前滚动到底部,则继续保持在底部
|
||
// 否则保持用户的滚动位置
|
||
if (isScrolledToBottom || oldContent === logsContainer.textContent) {
|
||
logsContainer.scrollTop = logsContainer.scrollHeight;
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('加载日志失败:', error);
|
||
// 只在内容为空或之前不是错误信息时才显示错误
|
||
if (!logsContainer.textContent.includes('加载失败')) {
|
||
logsContainer.textContent = `加载日志失败: ${error.message}`;
|
||
}
|
||
});
|
||
}
|
||
|
||
// 显示右上角消息弹窗
|
||
function showNotification(message, type = 'success') {
|
||
// 创建通知元素
|
||
const notification = document.createElement('div');
|
||
notification.className = `notification notification-${type}`;
|
||
notification.textContent = message;
|
||
|
||
// 添加到页面
|
||
document.body.appendChild(notification);
|
||
|
||
// 显示动画
|
||
setTimeout(() => {
|
||
notification.classList.add('show');
|
||
}, 10);
|
||
|
||
// 自动关闭
|
||
setTimeout(() => {
|
||
notification.classList.remove('show');
|
||
setTimeout(() => {
|
||
document.body.removeChild(notification);
|
||
}, 300);
|
||
}, 3000);
|
||
}
|
||
|
||
// 自定义确认对话框
|
||
function showConfirmDialog(message, onConfirm, onCancel = null) {
|
||
// 检查是否已存在对话框,如果有则移除
|
||
const existingDialog = document.querySelector('.custom-dialog');
|
||
if (existingDialog) {
|
||
document.body.removeChild(existingDialog);
|
||
}
|
||
|
||
// 创建对话框遮罩
|
||
const dialogOverlay = document.createElement('div');
|
||
dialogOverlay.className = 'dialog-overlay';
|
||
|
||
// 创建对话框容器
|
||
const dialogContainer = document.createElement('div');
|
||
dialogContainer.className = 'custom-dialog';
|
||
|
||
// 设置对话框内容
|
||
dialogContainer.innerHTML = `
|
||
<div class="dialog-content">
|
||
<div class="dialog-message">${message}</div>
|
||
<div class="dialog-actions">
|
||
<button class="dialog-button dialog-cancel">取消</button>
|
||
<button class="dialog-button dialog-confirm">确认</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 添加到页面
|
||
dialogOverlay.appendChild(dialogContainer);
|
||
document.body.appendChild(dialogOverlay);
|
||
|
||
// 添加动画类
|
||
setTimeout(() => {
|
||
dialogOverlay.classList.add('show');
|
||
dialogContainer.classList.add('show');
|
||
}, 10);
|
||
|
||
// 绑定确认按钮事件
|
||
const confirmBtn = dialogContainer.querySelector('.dialog-confirm');
|
||
confirmBtn.addEventListener('click', () => {
|
||
if (onConfirm) onConfirm();
|
||
closeDialog(dialogOverlay);
|
||
});
|
||
|
||
// 绑定取消按钮事件
|
||
const cancelBtn = dialogContainer.querySelector('.dialog-cancel');
|
||
cancelBtn.addEventListener('click', () => {
|
||
if (onCancel) onCancel();
|
||
closeDialog(dialogOverlay);
|
||
});
|
||
|
||
// 点击遮罩层也可以取消
|
||
dialogOverlay.addEventListener('click', (e) => {
|
||
if (e.target === dialogOverlay) {
|
||
if (onCancel) onCancel();
|
||
closeDialog(dialogOverlay);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 关闭对话框函数
|
||
function closeDialog(dialogOverlay) {
|
||
dialogOverlay.classList.remove('show');
|
||
const dialogContainer = dialogOverlay.querySelector('.custom-dialog');
|
||
dialogContainer.classList.remove('show');
|
||
|
||
setTimeout(() => {
|
||
if (dialogOverlay.parentNode) {
|
||
document.body.removeChild(dialogOverlay);
|
||
}
|
||
}, 300);
|
||
}
|
||
|
||
// 删除进程
|
||
function deleteProcess(processId) {
|
||
showConfirmDialog(
|
||
'确定要删除这个训练进程吗?此操作不可恢复。',
|
||
() => {
|
||
// 确认删除
|
||
fetchWithTimeoutAndRetry(`/delete/${processId}`, {
|
||
method: 'POST'
|
||
})
|
||
.then(response => {
|
||
// 检查响应状态
|
||
if (!response.ok) {
|
||
throw new Error('删除请求失败');
|
||
}
|
||
return response.json().catch(() => ({})); // 即使没有JSON响应也继续
|
||
})
|
||
.then(() => {
|
||
// 从UI中移除进程项
|
||
const processItem = document.querySelector(`[data-process-id="${processId}"]`);
|
||
if (processItem) {
|
||
// 添加移除动画
|
||
processItem.style.transition = 'opacity 0.3s, transform 0.3s';
|
||
processItem.style.opacity = '0';
|
||
processItem.style.transform = 'translateX(-20px)';
|
||
|
||
// 动画完成后移除
|
||
setTimeout(() => {
|
||
// 获取内容容器和类型分组
|
||
const contentContainer = processItem.closest('.process-type-content');
|
||
const typeGroup = contentContainer ? contentContainer.closest('.process-type-group') : null;
|
||
|
||
if (processItem.parentNode) {
|
||
processItem.parentNode.removeChild(processItem);
|
||
}
|
||
|
||
// 检查内容容器中是否还有进程项
|
||
if (contentContainer) {
|
||
const remainingProcessesInGroup = contentContainer.querySelectorAll('.process-item');
|
||
|
||
// 如果内容容器中没有进程项了,移除整个分组
|
||
if (remainingProcessesInGroup.length === 0 && typeGroup) {
|
||
setTimeout(() => {
|
||
// 添加分组移除动画
|
||
typeGroup.style.transition = 'opacity 0.3s, transform 0.3s';
|
||
typeGroup.style.opacity = '0';
|
||
typeGroup.style.transform = 'translateY(-10px)';
|
||
|
||
setTimeout(() => {
|
||
if (typeGroup.parentNode) {
|
||
typeGroup.parentNode.removeChild(typeGroup);
|
||
}
|
||
|
||
// 检查是否还有进程项
|
||
const remainingProcesses = document.querySelectorAll('.process-item');
|
||
if (remainingProcesses.length === 0) {
|
||
const processList = document.getElementById('process-list');
|
||
processList.innerHTML = '<p>暂无训练进程</p>';
|
||
}
|
||
}, 300);
|
||
}, 100);
|
||
} else {
|
||
// 重新计算内容容器的高度(如果分组是展开的)
|
||
const headerElement = contentContainer.previousElementSibling;
|
||
if (headerElement && headerElement.dataset.expanded === 'true') {
|
||
contentContainer.style.maxHeight = contentContainer.scrollHeight + 'px';
|
||
}
|
||
|
||
// 检查是否还有进程项
|
||
const remainingProcesses = document.querySelectorAll('.process-item');
|
||
if (remainingProcesses.length === 0) {
|
||
const processList = document.getElementById('process-list');
|
||
processList.innerHTML = '<p>暂无训练进程</p>';
|
||
}
|
||
}
|
||
} else {
|
||
// 检查是否还有进程项
|
||
const remainingProcesses = document.querySelectorAll('.process-item');
|
||
if (remainingProcesses.length === 0) {
|
||
const processList = document.getElementById('process-list');
|
||
processList.innerHTML = '<p>暂无训练进程</p>';
|
||
}
|
||
}
|
||
}, 300);
|
||
}
|
||
|
||
// 清除日志定时器
|
||
if (logTimers[processId]) {
|
||
clearInterval(logTimers[processId]);
|
||
delete logTimers[processId];
|
||
}
|
||
|
||
showNotification('训练进程已删除', 'success');
|
||
})
|
||
.catch(error => {
|
||
console.error('删除进程时出错:', error);
|
||
showNotification('删除进程失败,请刷新页面重试', 'error');
|
||
});
|
||
}
|
||
);
|
||
}
|
||
|
||
// 停止进程
|
||
function stopProcess(processId) {
|
||
showConfirmDialog(
|
||
'确定要停止这个训练进程吗?',
|
||
() => {
|
||
// 确认停止
|
||
fetchWithTimeoutAndRetry(`/stop/${processId}`, {
|
||
method: 'POST'
|
||
})
|
||
.then(() => {
|
||
// 立即给用户反馈,设置为手动停止状态
|
||
const processItem = document.querySelector(`[data-process-id="${processId}"]`);
|
||
if (processItem) {
|
||
// 先设置为手动停止状态(即时反馈)
|
||
processItem.dataset.processStatus = '手动停止';
|
||
const statusElement = processItem.querySelector('.process-status');
|
||
if (statusElement) {
|
||
statusElement.classList.remove('status-running', 'status-error', 'status-completed');
|
||
statusElement.classList.add('status-manual-stop');
|
||
statusElement.textContent = '手动停止';
|
||
}
|
||
|
||
// 移除停止按钮
|
||
const stopButton = processItem.querySelector('.btn-stop');
|
||
if (stopButton) {
|
||
stopButton.remove();
|
||
}
|
||
|
||
// 清除日志定时器
|
||
if (logTimers[processId]) {
|
||
clearInterval(logTimers[processId]);
|
||
delete logTimers[processId];
|
||
}
|
||
}
|
||
|
||
// 显示通知
|
||
showNotification('训练进程已停止', 'info');
|
||
|
||
// 为了确保状态准确,仍然获取完整列表并更新单个进程
|
||
fetchWithTimeoutAndRetry('/processes')
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
// 从完整列表中找到特定进程
|
||
const updatedProcess = data.find(p => p.id === processId);
|
||
if (updatedProcess && processItem) {
|
||
// 使用updateProcessItem函数更新进程(如果需要的话)
|
||
updateProcessItem(processItem, updatedProcess);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('获取进程列表更新单个进程失败:', error);
|
||
});
|
||
})
|
||
.catch(() => {
|
||
showNotification('停止进程失败', 'error');
|
||
});
|
||
},
|
||
() => {
|
||
// 取消停止,可选的回调
|
||
showNotification('已取消停止操作', 'info');
|
||
}
|
||
);
|
||
}
|
||
|
||
// 表单提交处理
|
||
document.getElementById('train-form').addEventListener('submit', function(e) {
|
||
e.preventDefault();
|
||
const formData = new FormData(this);
|
||
const data = {};
|
||
const trainingMode = formData.get('training_mode');
|
||
|
||
// 处理表单数据,特别关注训练方式相关参数
|
||
formData.forEach((value, key) => {
|
||
// 对于GPU数量,确保只有当选择了多卡训练时才添加到数据中
|
||
if (key === 'gpu_num') {
|
||
const multiGpuSelection = document.getElementById('multi-gpu-selection');
|
||
if (multiGpuSelection && multiGpuSelection.style.display !== 'none') {
|
||
data[key] = value;
|
||
}
|
||
} else if (key === 'device') {
|
||
// 处理device参数,根据训练方式设置正确格式
|
||
if (trainingMode === 'single_gpu') {
|
||
// 单卡训练时,格式为cuda:序号
|
||
data[key] = `cuda:${value}`;
|
||
} else if (trainingMode === 'cpu') {
|
||
// CPU训练时,固定为cpu
|
||
data[key] = 'cpu';
|
||
}
|
||
// 多卡训练时不需要device参数,会通过torchrun自动设置
|
||
} else if (key !== 'training_mode') {
|
||
// 排除training_mode参数,保留其他参数
|
||
data[key] = value;
|
||
}
|
||
});
|
||
|
||
// 立即显示加载通知
|
||
showNotification('正在启动训练...', 'info');
|
||
|
||
// 延迟1秒后发送请求,确保两个通知之间有间隔
|
||
setTimeout(() => {
|
||
fetchWithTimeoutAndRetry('/train', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Cache-Control': 'no-cache'
|
||
},
|
||
body: JSON.stringify(data)
|
||
})
|
||
.then(response => {
|
||
if (!response.ok) {
|
||
throw new Error('网络响应异常');
|
||
}
|
||
return response.json();
|
||
})
|
||
.then(result => {
|
||
if (result.success) {
|
||
showNotification('训练已开始!', 'success');
|
||
|
||
// 延迟1秒后切换到进程标签页,确保进程信息已更新
|
||
setTimeout(() => {
|
||
// 获取进程标签按钮并触发点击
|
||
const processTab = document.querySelector('.tab[onclick*="processes"]');
|
||
if (processTab) {
|
||
processTab.click();
|
||
}
|
||
}, 1000);
|
||
} else {
|
||
// 显示错误通知
|
||
showNotification('训练启动失败:' + result.error, 'error');
|
||
}
|
||
})
|
||
.catch(error => {
|
||
// 显示等待通知
|
||
showNotification('启动训练中,请耐心等待...', 'info');
|
||
});
|
||
}, 1000); // 1秒延迟确保两个通知之间有间隔
|
||
});
|
||
|
||
// 加载日志文件列表
|
||
function loadLogFiles() {
|
||
fetchWithTimeoutAndRetry('/logfiles')
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
const logfilesList = document.getElementById('logfiles-list');
|
||
logfilesList.innerHTML = '';
|
||
|
||
if (data.length === 0) {
|
||
logfilesList.innerHTML = '<p>暂无日志文件</p>';
|
||
return;
|
||
}
|
||
|
||
// 按日期倒序排序
|
||
data.sort((a, b) => new Date(b.modified_time) - new Date(a.modified_time));
|
||
|
||
// 按训练类型分组
|
||
const groupedLogFiles = {};
|
||
data.forEach(logfile => {
|
||
// 从文件名提取训练类型
|
||
let trainType = '自定义训练';
|
||
if (logfile.filename.includes('train_pretrain_')) {
|
||
trainType = 'pretrain';
|
||
} else if (logfile.filename.includes('train_sft_')) {
|
||
trainType = 'sft';
|
||
} else if (logfile.filename.includes('train_lora_')) {
|
||
trainType = 'lora';
|
||
} else if (logfile.filename.includes('train_dpo_')) {
|
||
trainType = 'dpo';
|
||
} else if (logfile.filename.includes('train_ppo_')) {
|
||
trainType = 'ppo';
|
||
} else if (logfile.filename.includes('train_grpo_')) {
|
||
trainType = 'grpo';
|
||
} else if (logfile.filename.includes('train_spo_')) {
|
||
trainType = 'spo';
|
||
}
|
||
|
||
// 为日志文件添加训练类型信息
|
||
logfile.train_type = trainType;
|
||
|
||
if (!groupedLogFiles[trainType]) {
|
||
groupedLogFiles[trainType] = [];
|
||
}
|
||
groupedLogFiles[trainType].push(logfile);
|
||
});
|
||
|
||
// 定义训练类型的显示顺序(与进程显示顺序保持一致)
|
||
const trainTypeOrder = ['pretrain', 'sft', 'lora', 'dpo', 'ppo', 'grpo', 'spo', '未知'];
|
||
|
||
// 创建类型分组并添加收起/展开功能的辅助函数
|
||
function createTypeGroupWithToggle(trainType, logfiles) {
|
||
// 创建类型分组容器
|
||
const typeGroup = document.createElement('div');
|
||
typeGroup.className = 'process-type-group';
|
||
|
||
// 创建标题容器(包含标题文本和切换按钮)
|
||
const titleContainer = document.createElement('div');
|
||
titleContainer.className = 'process-type-header';
|
||
titleContainer.dataset.expanded = 'true'; // 默认展开状态
|
||
|
||
// 添加类型标题
|
||
const typeTitle = document.createElement('h3');
|
||
typeTitle.className = 'process-type-title';
|
||
typeTitle.textContent = getTrainTypeDisplayName(trainType);
|
||
|
||
// 添加切换按钮
|
||
const toggleButton = document.createElement('button');
|
||
toggleButton.className = 'toggle-btn';
|
||
toggleButton.innerHTML = '▼'; // 向下箭头表示展开
|
||
toggleButton.onclick = function(e) {
|
||
e.stopPropagation(); // 防止触发标题容器的点击事件
|
||
toggleGroup(titleContainer);
|
||
};
|
||
|
||
// 将标题和按钮添加到容器
|
||
titleContainer.appendChild(typeTitle);
|
||
titleContainer.appendChild(toggleButton);
|
||
|
||
// 添加点击标题也可以切换展开/收起
|
||
titleContainer.onclick = function() {
|
||
toggleGroup(titleContainer);
|
||
};
|
||
|
||
// 创建内容容器,用于容纳日志文件项
|
||
const contentContainer = document.createElement('div');
|
||
contentContainer.className = 'process-type-content';
|
||
|
||
// 添加该类型的所有日志文件
|
||
logfiles.forEach(logfile => {
|
||
addLogFileItemToGroup(contentContainer, logfile);
|
||
});
|
||
|
||
// 将标题容器和内容容器添加到类型分组
|
||
typeGroup.appendChild(titleContainer);
|
||
typeGroup.appendChild(contentContainer);
|
||
|
||
return typeGroup;
|
||
}
|
||
|
||
// 切换分组展开/收起状态的函数
|
||
function toggleGroup(headerElement) {
|
||
const isExpanded = headerElement.dataset.expanded === 'true';
|
||
const contentContainer = headerElement.nextElementSibling;
|
||
const toggleButton = headerElement.querySelector('.toggle-btn');
|
||
|
||
if (isExpanded) {
|
||
// 收起分组
|
||
headerElement.dataset.expanded = 'false';
|
||
contentContainer.style.maxHeight = '0';
|
||
contentContainer.style.overflow = 'hidden';
|
||
toggleButton.innerHTML = '▶'; // 向右箭头表示收起
|
||
} else {
|
||
// 展开分组前先重置样式
|
||
contentContainer.style.overflow = 'hidden'; // 确保计算高度准确
|
||
contentContainer.style.maxHeight = 'none'; // 临时设置为none以获取真实高度
|
||
|
||
// 获取真实高度
|
||
const actualHeight = contentContainer.scrollHeight;
|
||
|
||
// 然后先设置为0,准备动画
|
||
contentContainer.style.maxHeight = '0';
|
||
|
||
// 强制重排
|
||
contentContainer.offsetHeight;
|
||
|
||
// 展开分组
|
||
headerElement.dataset.expanded = 'true';
|
||
contentContainer.style.maxHeight = actualHeight + 'px';
|
||
|
||
setTimeout(() => {
|
||
// 动画完成后,设置为真实高度并允许溢出
|
||
contentContainer.style.maxHeight = 'none';
|
||
contentContainer.style.overflow = 'visible';
|
||
}, 300); // 动画完成后显示溢出内容
|
||
|
||
toggleButton.innerHTML = '▼'; // 向下箭头表示展开
|
||
}
|
||
}
|
||
|
||
// 首先显示有明确顺序的训练类型
|
||
trainTypeOrder.forEach(trainType => {
|
||
if (groupedLogFiles[trainType]) {
|
||
const typeGroup = createTypeGroupWithToggle(trainType, groupedLogFiles[trainType]);
|
||
logfilesList.appendChild(typeGroup);
|
||
|
||
// 从分组中移除已处理的类型
|
||
delete groupedLogFiles[trainType];
|
||
}
|
||
});
|
||
|
||
// 显示剩余的训练类型(不在预定义顺序中的)
|
||
Object.keys(groupedLogFiles).forEach(trainType => {
|
||
const typeGroup = createTypeGroupWithToggle(trainType, groupedLogFiles[trainType]);
|
||
logfilesList.appendChild(typeGroup);
|
||
});
|
||
});
|
||
}
|
||
|
||
// 添加日志文件项到分组
|
||
function addLogFileItemToGroup(parentElement, logfile) {
|
||
const fileItem = document.createElement('div');
|
||
fileItem.className = 'process-item';
|
||
|
||
fileItem.innerHTML = `
|
||
<div class="process-info">
|
||
<div>
|
||
<strong>${logfile.filename}</strong>
|
||
</div>
|
||
<div>
|
||
<span class="process-status status-completed">已保存</span>
|
||
<span style="margin-left: 10px; color: #999; font-size: 0.9em;">${logfile.modified_time}</span>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<button class="btn-logs" onclick="viewLogFile('${logfile.filename}', this)">查看日志</button>
|
||
<button class="btn-delete" onclick="deleteLogFile('${logfile.filename}', this)">删除</button>
|
||
</div>
|
||
<div id="log-content-${logfile.filename.replace(/\./g, '-')}" class="logs-container hidden"></div>
|
||
`;
|
||
|
||
parentElement.appendChild(fileItem);
|
||
}
|
||
|
||
// 删除日志文件
|
||
function deleteLogFile(filename, button) {
|
||
// 显示确认对话框
|
||
showConfirmDialog(
|
||
`确定要删除日志文件 "${filename}" 吗?此操作无法恢复。`,
|
||
function() {
|
||
// 用户确认删除
|
||
const processItem = button.closest('.process-item');
|
||
const contentContainer = processItem.closest('.process-type-content');
|
||
const typeGroup = contentContainer.closest('.process-type-group');
|
||
|
||
// 显示加载状态
|
||
const originalText = button.textContent;
|
||
button.textContent = '删除中...';
|
||
button.disabled = true;
|
||
|
||
// 发送删除请求到服务器
|
||
fetchWithTimeoutAndRetry(`/delete-logfile/${encodeURIComponent(filename)}`, {
|
||
method: 'DELETE',
|
||
headers: {
|
||
'Cache-Control': 'no-cache'
|
||
}
|
||
})
|
||
.then(response => {
|
||
if (!response.ok) {
|
||
throw new Error('删除失败');
|
||
}
|
||
return response.json();
|
||
})
|
||
.then(data => {
|
||
if (data.success) {
|
||
// 从UI中移除日志项
|
||
processItem.remove();
|
||
|
||
// 检查是否需要移除整个分组
|
||
if (contentContainer.children.length === 0) {
|
||
typeGroup.remove();
|
||
} else {
|
||
// 更新内容容器的高度
|
||
const headerElement = contentContainer.previousElementSibling;
|
||
if (headerElement && headerElement.dataset.expanded === 'true') {
|
||
// 临时设置为none以获取真实高度
|
||
contentContainer.style.maxHeight = 'none';
|
||
const actualHeight = contentContainer.scrollHeight;
|
||
contentContainer.style.maxHeight = actualHeight + 'px';
|
||
}
|
||
}
|
||
|
||
// 显示成功通知
|
||
showNotification(`日志文件 "${filename}" 已成功删除`);
|
||
} else {
|
||
throw new Error(data.message || '删除失败');
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('删除日志失败:', error);
|
||
showNotification(`删除失败: ${error.message}`, 'error');
|
||
// 恢复按钮状态
|
||
button.textContent = originalText;
|
||
button.disabled = false;
|
||
});
|
||
},
|
||
function() {
|
||
// 用户取消删除,不执行任何操作
|
||
}
|
||
);
|
||
}
|
||
|
||
// 查看日志文件内容
|
||
function viewLogFile(filename, button) {
|
||
// 生成安全的文件名,用于元素ID
|
||
const safeFilename = filename.replace(/[^a-zA-Z0-9_.-]/g, '_');
|
||
const idSafeFilename = safeFilename.replace(/\./g, '-');
|
||
|
||
// 查找对应的日志容器
|
||
const processItem = button.closest('.process-item');
|
||
const logContainer = processItem.querySelector(`#log-content-${idSafeFilename}`);
|
||
|
||
// 获取内容容器和头部元素
|
||
const contentContainer = processItem.closest('.process-type-content');
|
||
const headerElement = contentContainer ? contentContainer.previousElementSibling : null;
|
||
|
||
// 确保日志所在的分组是展开状态
|
||
if (contentContainer && headerElement && headerElement.dataset.expanded !== 'true') {
|
||
// 如果分组是折叠的,先展开分组
|
||
toggleGroup(headerElement);
|
||
}
|
||
|
||
// 切换日志容器的显示状态
|
||
if (logContainer.classList.contains('hidden')) {
|
||
logContainer.classList.remove('hidden');
|
||
|
||
// 读取完整日志内容
|
||
logContainer.textContent = '加载中...';
|
||
|
||
fetchWithTimeoutAndRetry(`/logfile-content/${encodeURIComponent(filename)}`)
|
||
.then(response => {
|
||
if (!response.ok) {
|
||
throw new Error('获取日志失败');
|
||
}
|
||
return response.text();
|
||
})
|
||
.then(logs => {
|
||
// 保留空白格式,使用textContent而不是innerHTML
|
||
logContainer.textContent = logs;
|
||
// 默认滚动到顶部,显示完整历史
|
||
logContainer.scrollTop = 0;
|
||
|
||
// 更新内容容器的高度
|
||
updateContentContainerHeight();
|
||
})
|
||
.catch(error => {
|
||
console.error('获取日志失败:', error);
|
||
logContainer.textContent = `获取日志失败: ${error.message}`;
|
||
|
||
// 更新内容容器的高度
|
||
updateContentContainerHeight();
|
||
});
|
||
} else {
|
||
logContainer.classList.add('hidden');
|
||
|
||
// 更新内容容器的高度
|
||
updateContentContainerHeight();
|
||
}
|
||
|
||
// 辅助函数:更新内容容器的高度
|
||
function updateContentContainerHeight() {
|
||
if (contentContainer && headerElement && headerElement.dataset.expanded === 'true') {
|
||
// 保存当前的maxHeight设置
|
||
const currentMaxHeight = contentContainer.style.maxHeight;
|
||
|
||
// 暂时设置为none以获取真实高度
|
||
contentContainer.style.maxHeight = 'none';
|
||
|
||
// 获取真实高度
|
||
const actualHeight = contentContainer.scrollHeight;
|
||
|
||
// 检查是否需要重新设置高度
|
||
if (currentMaxHeight === 'none' || parseInt(currentMaxHeight) !== actualHeight) {
|
||
contentContainer.style.maxHeight = actualHeight + 'px';
|
||
|
||
// 短暂延迟后再设置为none,确保高度变化正确应用
|
||
setTimeout(() => {
|
||
if (headerElement.dataset.expanded === 'true') {
|
||
contentContainer.style.maxHeight = 'none';
|
||
}
|
||
}, 300);
|
||
} else {
|
||
// 如果高度没有变化,恢复之前的设置
|
||
contentContainer.style.maxHeight = currentMaxHeight;
|
||
}
|
||
}
|
||
}
|
||
} |