From e212de16931448b9f8fa46caf3101bb92e781212 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Mon, 13 Jul 2026 15:29:49 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E8=AE=AD=E7=BB=83=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E7=BB=84=E4=BB=B6=E5=8C=96=E4=B8=8E=20Mock=20?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拆分 TrainingTaskOverview 组件与 trainingLogModel 状态模型,TrainingLogView 大幅瘦身;Mock 新增按文件路由的训练日志内容与更真实的 GPU 进程占用数据,adapter 类型收敛为 AxiosAdapter,配套新增 mock 内容回归脚本。 --- .../regression-training-log-layout.mjs | 189 +- .../scripts/regression-training-log-mock.mjs | 77 + frontend/src/mock/adapter.ts | 31 +- frontend/src/mock/data.ts | 270 ++- frontend/src/views/system/TrainingLogView.vue | 1567 ++++++++--------- .../training-log/TrainingTaskOverview.vue | 88 + .../system/training-log/trainingLogModel.ts | 165 ++ 7 files changed, 1473 insertions(+), 914 deletions(-) create mode 100644 frontend/scripts/regression-training-log-mock.mjs create mode 100644 frontend/src/views/system/training-log/TrainingTaskOverview.vue create mode 100644 frontend/src/views/system/training-log/trainingLogModel.ts diff --git a/frontend/scripts/regression-training-log-layout.mjs b/frontend/scripts/regression-training-log-layout.mjs index c4fa4ae..05245ea 100644 --- a/frontend/scripts/regression-training-log-layout.mjs +++ b/frontend/scripts/regression-training-log-layout.mjs @@ -2,17 +2,35 @@ import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import path from 'node:path' +import ts from 'typescript' import { parse as parseTemplate } from '@vue/compiler-dom' import { parse as parseSfc } from '@vue/compiler-sfc' const scriptDir = path.dirname(fileURLToPath(import.meta.url)) const viewPath = path.resolve(scriptDir, '../src/views/system/TrainingLogView.vue') -const source = await readFile(viewPath, 'utf8') +const overviewPath = path.resolve(scriptDir, '../src/views/system/training-log/TrainingTaskOverview.vue') +const modelPath = path.resolve(scriptDir, '../src/views/system/training-log/trainingLogModel.ts') +const [source, overviewSource, modelSource] = await Promise.all([ + readFile(viewPath, 'utf8'), + readFile(overviewPath, 'utf8'), + readFile(modelPath, 'utf8'), +]) const { descriptor } = parseSfc(source, { filename: viewPath }) -const template = descriptor.template?.content || '' -const style = descriptor.styles.map((item) => item.content).join('\n') +const { descriptor: overviewDescriptor } = parseSfc(overviewSource, { filename: overviewPath }) +const template = [descriptor.template?.content, overviewDescriptor.template?.content].filter(Boolean).join('\n') +const viewStyle = descriptor.styles.map((item) => item.content).join('\n') +const overviewStyle = overviewDescriptor.styles.map((item) => item.content).join('\n') +const style = `${viewStyle}\n${overviewStyle}` const templateAst = parseTemplate(template) +const modelModuleCode = ts.transpileModule(modelSource, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, +}).outputText +const { + parseTrainingLog, + resolveTrainingLogFile, +} = await import(`data:text/javascript;base64,${Buffer.from(modelModuleCode).toString('base64')}`) + function findElements(node, predicate, result = []) { if (node?.type === 1 && predicate(node)) result.push(node) for (const child of node?.children || []) findElements(child, predicate, result) @@ -50,41 +68,71 @@ function extractCssBlock(css, marker) { assert.fail(`样式规则缺少右花括号:${marker}`) } -function relativeLuminance(hex) { - const channels = hex - .replace('#', '') - .match(/.{2}/g) - .map((channel) => Number.parseInt(channel, 16) / 255) - .map((value) => (value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4)) - return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2] -} +const logFiles = [ + { file: 'first_pid111.log', name: 'first-task', size: '1 KB', pid: 111 }, + { file: 'target_pid222.log', name: 'target-task', size: '1 KB', pid: 222 }, +] +assert.equal( + resolveTrainingLogFile(logFiles, { process_id: 222, name: 'target-task' })?.file, + 'target_pid222.log', + '训练日志必须按 task.process_id 精确匹配文件 pid,不能只判断两者是否存在', +) +assert.equal( + resolveTrainingLogFile(logFiles, { process_id: 999, name: 'target-task' })?.file, + 'target_pid222.log', + 'PID 无匹配时应按任务名回退选择日志', +) +assert.equal( + resolveTrainingLogFile(logFiles, { process_id: 999, name: 'missing-task' }), + undefined, + '任务无匹配日志时不得静默退回第一份日志', +) -function contrastRatio(foreground, background) { - const lighter = Math.max(relativeLuminance(foreground), relativeLuminance(background)) - const darker = Math.min(relativeLuminance(foreground), relativeLuminance(background)) - return (lighter + 0.05) / (darker + 0.05) -} +const parsed = parseTrainingLog([ + "INFO {'epoch': .5, 'learning_rate': 1.2E-5, 'grad_norm': -2.5e+0, 'loss': +3.25}", + '***** train metrics *****', + 'train_runtime = 1.7852e3', + "'train_loss': -3.42e-1", + 'epoch: 1.0', + '***** train metrics end *****', +].join('\n')) +assert.deepEqual(parsed.metrics.loss, [3.25], '指标解析应允许字段乱序和带符号数值') +assert.deepEqual(parsed.metrics.gradNorm, [-2.5], '梯度范数应支持科学计数法') +assert.deepEqual(parsed.metrics.lr, [1.2e-5], '学习率应支持大小写科学计数法') +assert.deepEqual( + parsed.summary, + { epoch: '1.0', trainLoss: '-3.42e-1', runtime: '1.7852e3' }, + '训练汇总应同时支持等号、冒号、可选引号和科学计数法', +) +assert.deepEqual( + parseTrainingLog('普通日志,无训练指标').summary, + { epoch: '', trainLoss: '', runtime: '' }, + '每次解析必须返回全新的空汇总,避免保留上一份日志的旧值', +) +assert.match( + source, + /const currentTask = await loadTask\(\)[\s\S]*?loadLog\(currentTask\)/, + '刷新流程必须先加载 task,再使用该 task 选择日志', +) +assert.match(source, /import \{ getSystemInfo \} from '@\/api\/modules\/system'/, '训练概览必须复用系统 GPU 监控数据源') +assert.match(source, /Promise\.all\(\[datasetPromise, loadLog\(currentTask\), loadGpuStatus\(\)\]\)/, 'GPU 状态必须和训练日志一起刷新') +assert.match(source, /if \(refreshInFlight\) return/, '轮询刷新必须阻止并发重叠') +assert.match(source, /onUnmounted\([\s\S]*?clearInterval\(timer\)/, '组件卸载时必须清理轮询定时器') const overview = findElements( templateAst, (node) => staticAttribute(node, 'class')?.split(/\s+/).includes('overview-layout'), ) -assert.equal(overview.length, 1, '双栏任务档案容器必须且只能存在一个') - -for (const expectedClass of ['task-profile', 'dataset-profile', 'runtime-panel', 'parameter-groups']) { - const matched = findElements( - templateAst, - (node) => staticAttribute(node, 'class')?.split(/\s+/).includes(expectedClass), - ) - assert.equal(matched.length, 1, `缺少或重复布局结构:${expectedClass}`) -} +assert.equal(overview.length, 1, '标准任务概况容器必须且只能存在一个') +assert.doesNotMatch(overviewSource, /') assert.notEqual(firstChartIndex, -1, '未找到训练曲线边界,无法限定首屏检查范围') -assert.equal( - template.slice(0, firstChartIndex).includes(' GPU_PREVIEW_LIMIT'), 'GPU 数量超过预览上限时没有展开入口') + +const gpuToggleButtons = findElements( + templateAst, + (node) => node.tag === 'el-button' + && staticAttribute(node, 'class')?.split(/\s+/).includes('gpu-toggle-button'), +) +assert.equal(gpuToggleButtons.length, 1, '多 GPU 展开控制必须且只能存在一个') +assert.equal(boundExpression(gpuToggleButtons[0], 'aria-expanded'), 'gpuExpanded', 'GPU 展开按钮未绑定 aria-expanded') +assert.equal(staticAttribute(gpuToggleButtons[0], 'aria-controls'), 'gpu-device-list', 'GPU 展开按钮缺少正确的 aria-controls') +assert.ok(template.includes('title="训练曲线"'), '训练曲线没有使用标准 PageCard 标题') +assert.ok(template.includes('title="训练日志"'), '训练日志没有使用标准 PageCard 标题') +assert.ok(template.includes('每 5 秒刷新'), '训练监控区缺少自动刷新状态说明') +assert.ok((template.match(/= 3, '任务概况和训练参数没有使用标准详情表格') +assert.equal((template.match(/class="chart-section"/g) || []).length, 3, '三组训练曲线没有按上下结构完整展示') +assert.doesNotMatch(template, /= 4.5, - `未配置文本颜色 ${mutedColor} 与白色背景对比度不足 4.5:1`, -) +const overviewMedia700 = extractCssBlock(overviewStyle, '@media (max-width: 700px)') +assert.ok(overviewMedia700.includes('.task-descriptions'), '700px 断点缺少任务详情表单列规则') +assert.match(overviewMedia700, /display:\s*block/, '移动端任务详情表没有转换为单列') +const viewMedia600 = extractCssBlock(viewStyle, '@media (max-width: 600px)') +assert.ok(viewMedia600.includes('.parameter-descriptions'), '600px 断点缺少训练参数表单列规则') +assert.match(viewMedia600, /display:\s*block/, '移动端训练参数表没有转换为单列') console.log('训练日志详情布局回归检查通过') diff --git a/frontend/scripts/regression-training-log-mock.mjs b/frontend/scripts/regression-training-log-mock.mjs new file mode 100644 index 0000000..5af89f4 --- /dev/null +++ b/frontend/scripts/regression-training-log-mock.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import path from 'node:path' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const mockDataPath = path.resolve(scriptDir, '../src/mock/data.ts') +const mockAdapterPath = path.resolve(scriptDir, '../src/mock/adapter.ts') +const modelPath = path.resolve(scriptDir, '../src/views/system/training-log/trainingLogModel.ts') +const [mockData, mockAdapter, modelSource] = await Promise.all([ + readFile(mockDataPath, 'utf8'), + readFile(mockAdapterPath, 'utf8'), + readFile(modelPath, 'utf8'), +]) + +function objectFromMarker(source, marker) { + const markerIndex = source.indexOf(marker) + assert.notEqual(markerIndex, -1, `未找到 Mock 数据标记:${marker}`) + const openBrace = source.lastIndexOf('{', markerIndex) + assert.notEqual(openBrace, -1, `Mock 数据缺少对象起点:${marker}`) + + let depth = 0 + for (let index = openBrace; index < source.length; index += 1) { + if (source[index] === '{') depth += 1 + if (source[index] === '}') depth -= 1 + if (depth === 0) return source.slice(openBrace, index + 1) + } + + assert.fail(`Mock 数据缺少对象终点:${marker}`) +} + +const taskNames = [ + 'finance-sft-001', + 'legal-sft-002', + 'medical-cpt-001', + 'service-dpo-001', + 'finance-sft-002', + 'general-sft-001', +] +const commonTrainingFields = [ + 'output_model_name', + 'batch_size', + 'learning_rate', + 'n_epochs', + 'save_steps', + 'lr_scheduler_type', + 'max_length', + 'warmup_ratio', + 'weight_decay', +] + +for (const taskName of taskNames) { + const task = objectFromMarker(mockData, `name: '${taskName}'`) + for (const field of commonTrainingFields) { + assert.match(task, new RegExp(`\\b${field}\\s*:`), `${taskName} 缺少训练参数:${field}`) + } + + if (/train_method:\s*'lora'/.test(task)) { + for (const field of ['lora_rank', 'lora_alpha', 'lora_dropout']) { + assert.match(task, new RegExp(`\\b${field}\\s*:`), `${taskName} 缺少 LoRA 参数:${field}`) + } + } +} + +const medicalTask = objectFromMarker(mockData, "name: 'medical-cpt-001'") +assert.match(medicalTask, /\bprocess_id\s*:/, '运行中的医疗训练任务缺少进程 ID') +assert.match(mockData, /medical-cpt-001_pid28741\.log/, '医疗训练任务缺少 PID 对应的日志文件') +assert.match(mockData, /export const mockTrainingLogContents/, '训练日志没有按文件提供独立 Mock 内容') +assert.match(mockData, /Num examples\s*=\s*9,800/, '医疗训练日志缺少样本规模信息') +assert.match(mockData, /Total optimization steps\s*=\s*921/, '医疗训练日志缺少真实训练步数信息') +assert.match(mockData, /\\'epoch\\':\s*1\.92/, '医疗训练日志的 Epoch 未与 64% 进度对齐') +assert.match(mockData, /\\'loss\\':\s*1\.146/, '医疗训练日志缺少当前 Loss 指标') +assert.match(mockAdapter, /const file = String\(params\.file/, '训练日志接口没有读取请求中的日志文件名') +assert.match(mockAdapter, /mockTrainingLogContents\[file\]/, '训练日志接口没有按请求文件返回对应内容') +assert.match(modelSource, /epoch:\s*number\[\]/, '训练指标模型没有保留逐步 Epoch') + +console.log('训练日志 Mock 数据回归检查通过') diff --git a/frontend/src/mock/adapter.ts b/frontend/src/mock/adapter.ts index c829df5..f59e607 100644 --- a/frontend/src/mock/adapter.ts +++ b/frontend/src/mock/adapter.ts @@ -3,7 +3,7 @@ * 拦截所有 API 请求并返回 mock 数据 * 通过 URL + method 路由到对应的 mock 响应 */ -import type { AxiosInstance, AxiosRequestConfig } from 'axios' +import type { AxiosAdapter, AxiosInstance, AxiosRequestConfig } from 'axios' import { mockLoginOk, mockHealth, @@ -21,6 +21,7 @@ import { mockLogFiles, mockTrainingLogFiles, mockLogContent, + mockTrainingLogContents, } from './data' import { activateDatasetVersion, @@ -164,7 +165,8 @@ async function handleMock(config: AxiosRequestConfig) { } m = url.match(/^\/dataset-manage\/([^/]+)$/) if (m && method === 'get') { - const found = mockDatasets.find((x) => String(x.id) === m[1]) + const datasetId = m[1] + const found = mockDatasets.find((x) => String(x.id) === datasetId) return found ? ok(found) : fail('数据集不存在', 404) } if (m && (method === 'put' || method === 'delete')) { @@ -261,7 +263,8 @@ async function handleMock(config: AxiosRequestConfig) { } m = url.match(/^\/fine-tune\/progress\/([^/]+)$/) if (m && method === 'get') { - const task = mockFineTuneList.find((t) => String(t.id) === m[1]) + const taskId = m[1] + const task = mockFineTuneList.find((t) => String(t.id) === taskId) if (!task) return fail('任务不存在', 404) if (task.status === 'running') { return ok({ @@ -276,7 +279,8 @@ async function handleMock(config: AxiosRequestConfig) { } m = url.match(/^\/fine-tune\/([^/]+)$/) if (m && method === 'get') { - const found = mockFineTuneList.find((x) => String(x.id) === m[1]) + const taskId = m[1] + const found = mockFineTuneList.find((x) => String(x.id) === taskId) return found ? ok(found) : fail('任务不存在', 404) } m = url.match(/^\/fine-tune\/stop\/([^/]+)$/) @@ -296,7 +300,8 @@ async function handleMock(config: AxiosRequestConfig) { } m = url.match(/^\/model-compare\/([^/]+)$/) if (m && method === 'get') { - const found = mockCompareList.find((x) => String(x.id) === m[1]) + const compareId = m[1] + const found = mockCompareList.find((x) => String(x.id) === compareId) return found ? ok(found) : fail('任务不存在', 404) } if (m && method === 'delete') return ok({ deleted: m[1] }) @@ -363,7 +368,14 @@ async function handleMock(config: AxiosRequestConfig) { if (url === '/log-files' && method === 'get') return ok(mockLogFiles) if (url === '/log-content' && method === 'get') return ok(mockLogContent) if (url === '/training-log-files' && method === 'get') return ok(mockTrainingLogFiles) - if (url === '/training-log-content' && method === 'get') return ok(mockLogContent) + if (url === '/training-log-content' && method === 'get') { + const file = String(params.file || '') + return ok(mockTrainingLogContents[file] || { + file, + size: '0 KB', + content: `[Mock] 未找到训练日志内容:${file}`, + }) + } // 未匹配的请求 → 兜底返回空成功(避免阻断 UI) console.warn('[Mock] 未匹配路由:', method.toUpperCase(), url, params) @@ -380,12 +392,13 @@ function safeJSON(str: string) { /** 给 axios instance 安装 mock adapter */ export function installMockAdapter(instance: AxiosInstance) { - instance.defaults.adapter = async (config: AxiosRequestConfig) => { + const adapter = async (config: AxiosRequestConfig) => { try { const response = await handleMock(config) return response - } catch (e: any) { - return fail(e.message || 'Mock 错误', 500, config) + } catch (error: unknown) { + return fail(error instanceof Error ? error.message : 'Mock 错误', 500, config) } } + instance.defaults.adapter = adapter as AxiosAdapter } diff --git a/frontend/src/mock/data.ts b/frontend/src/mock/data.ts index ad9edce..cf34fca 100644 --- a/frontend/src/mock/data.ts +++ b/frontend/src/mock/data.ts @@ -58,37 +58,40 @@ export const mockSystemInfo: SystemInfo = { id: 0, uuid: 'GPU-MOCK-A800-00', name: 'NVIDIA A800', - status: 'idle', - gpu_percent: 0, - memory_used_gb: 0, + status: 'busy', + gpu_percent: 74, + memory_used_gb: 41.8, memory_total_gb: 80, - memory_percent: 0, - temperature: 32, - power_w: 38, + memory_percent: 52.3, + temperature: 63, + power_w: 286, power_limit_w: 400, - fan_speed: 0, + fan_speed: 51, clock_mhz: 1410, driver_version: '535.86.10', - processes: [], + processes: [ + { pid: 28741, name: 'python', task_name: 'medical-cpt-001 / rank 0', user: 'trainer', memory_used_gb: 39.6 }, + { pid: 28768, name: 'python', task_name: '训练指标采集', user: 'trainer', memory_used_gb: 2.2 }, + ], }, { id: 1, uuid: 'GPU-MOCK-A800-01', name: 'NVIDIA A800', status: 'busy', - gpu_percent: 28, - memory_used_gb: 22.5, + gpu_percent: 71, + memory_used_gb: 42.1, memory_total_gb: 80, - memory_percent: 28.1, - temperature: 52, - power_w: 165, + memory_percent: 52.6, + temperature: 62, + power_w: 279, power_limit_w: 400, - fan_speed: 32, + fan_speed: 49, clock_mhz: 1410, driver_version: '535.86.10', processes: [ - { pid: 18421, name: 'python', task_name: '指令微调任务', user: 'trainer', memory_used_gb: 18.6 }, - { pid: 18503, name: 'python', task_name: '训练指标采集', user: 'trainer', memory_used_gb: 3.9 }, + { pid: 28742, name: 'python', task_name: 'medical-cpt-001 / rank 1', user: 'trainer', memory_used_gb: 39.9 }, + { pid: 28769, name: 'python', task_name: '训练指标采集', user: 'trainer', memory_used_gb: 2.2 }, ], }, { @@ -249,7 +252,7 @@ export const mockLocalModels = { } // ============ 数据集 ============ -export const mockDatasets: DatasetItem[] = [ +export const mockDatasets: DatasetItem[] = ([ { id: 1, name: '金融问答-训练集', @@ -273,7 +276,7 @@ export const mockDatasets: DatasetItem[] = [ { id: 8, name: '通用指令构造集', type: 'train', storage_type: 'local', source: 'task', task_id: 492015, size: '148 MB', count: 12600, description: '由指令微调数据构造任务生成', create_time: '2026-07-09T01:42:00Z' }, { id: 9, name: '用户反馈脱敏集', type: 'test', storage_type: 'minio', source: 'task', task_id: 731948, size: '72 MB', count: 9340, description: '由敏感信息脱敏任务生成', create_time: '2026-07-09T09:18:00Z' }, { id: 10, name: '多轮对话增强集', type: 'eval', storage_type: 'local', source: 'task', task_id: 582012, size: '41 MB', count: 2780, description: '由多轮对话拼接任务生成', create_time: '2026-07-10T02:06:00Z' }, -].map((dataset) => ({ +] satisfies DatasetItem[]).map((dataset) => ({ ...dataset, files: dataset.files?.length ? dataset.files @@ -339,12 +342,181 @@ export const mockDatasetPreviews: Record = { // ============ 训练任务 ============ export const mockFineTuneList: FineTuneTask[] = [ - { id: 103942, name: 'finance-sft-001', description: '金融领域 SFT 训练', status: 'completed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 1, gpus: [0], progress: 100, train_duration: '2小时18分钟', create_time: '2026-01-15T08:00:00Z' }, - { id: 349102, name: 'legal-sft-002', description: '法律文书 SFT', status: 'completed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 2, gpus: [1], progress: 100, train_duration: '1小时46分钟', create_time: '2026-01-18T10:00:00Z' }, - { id: 849301, name: 'medical-cpt-001', description: '医疗领域继续预训练', status: 'running', train_type: 'CPT', train_method: 'lora', template: 'qwen2_5', base_model: 2, train_dataset_id: 6, gpus: [0, 1], progress: 64, train_duration: '36分钟', create_time: '2026-02-05T09:00:00Z' }, - { id: 593021, name: 'service-dpo-001', description: '客服对话偏好训练', status: 'pending', train_type: 'DPO', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 3, gpus: [2], progress: 0, train_duration: '-', create_time: '2026-02-08T14:00:00Z' }, - { id: 201948, name: 'finance-sft-002', description: '金融领域二轮微调', status: 'failed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 1, gpus: [3], progress: 32, train_duration: '18分钟', create_time: '2026-02-10T11:00:00Z' }, - { id: 940212, name: 'general-sft-001', description: '通用能力微调', status: 'completed', train_type: 'SFT', train_method: 'full', template: 'llama3', base_model: 3, train_dataset_id: 3, gpus: [0, 2], progress: 100, train_duration: '3小时05分钟', create_time: '2026-02-12T13:00:00Z' }, + { + id: 103942, + name: 'finance-sft-001', + description: '金融领域 SFT 训练', + status: 'completed', + train_type: 'SFT', + train_method: 'lora', + template: 'qwen', + base_model: 1, + train_dataset_id: 1, + output_model_name: 'qwen2.5-7b-finance-sft-v1', + auto_merge: true, + gpus: [0], + batch_size: 8, + learning_rate: 0.00002, + n_epochs: 3, + save_steps: 100, + lr_scheduler_type: 'cosine', + max_length: 2048, + warmup_ratio: 0.05, + weight_decay: 0.01, + lora_rank: 16, + lora_alpha: 32, + lora_dropout: 0.05, + quantization_bit: 0, + process_id: 12345, + progress: 100, + train_duration: '2小时18分钟', + create_time: '2026-01-15T08:00:00Z', + }, + { + id: 349102, + name: 'legal-sft-002', + description: '法律文书 SFT', + status: 'completed', + train_type: 'SFT', + train_method: 'lora', + template: 'qwen', + base_model: 1, + train_dataset_id: 2, + output_model_name: 'qwen2.5-7b-legal-sft-v2', + auto_merge: true, + gpus: [1], + batch_size: 4, + learning_rate: 0.000015, + n_epochs: 4, + save_steps: 120, + lr_scheduler_type: 'linear', + max_length: 4096, + warmup_ratio: 0.03, + weight_decay: 0.01, + lora_rank: 32, + lora_alpha: 64, + lora_dropout: 0.05, + quantization_bit: 0, + process_id: 12350, + progress: 100, + train_duration: '1小时46分钟', + create_time: '2026-01-18T10:00:00Z', + }, + { + id: 849301, + name: 'medical-cpt-001', + description: '医疗领域继续预训练', + status: 'running', + train_type: 'CPT', + train_method: 'lora', + template: 'qwen2_5', + base_model: 2, + train_dataset_id: 6, + output_model_name: 'qwen2.5-14b-medical-cpt-v1', + auto_merge: true, + gpus: [0, 1], + batch_size: 2, + learning_rate: 0.0001, + n_epochs: 3, + save_steps: 200, + lr_scheduler_type: 'cosine', + max_length: 4096, + warmup_ratio: 0.03, + weight_decay: 0.01, + lora_rank: 16, + lora_alpha: 32, + lora_dropout: 0.05, + quantization_bit: 0, + process_id: 28741, + progress: 64, + train_duration: '36分钟', + create_time: '2026-07-13T06:40:00Z', + }, + { + id: 593021, + name: 'service-dpo-001', + description: '客服对话偏好训练', + status: 'pending', + train_type: 'DPO', + train_method: 'lora', + template: 'qwen', + base_model: 1, + train_dataset_id: 3, + output_model_name: 'qwen2.5-7b-service-dpo-v1', + auto_merge: true, + gpus: [2], + batch_size: 4, + learning_rate: 0.000005, + n_epochs: 2, + save_steps: 100, + lr_scheduler_type: 'cosine', + max_length: 2048, + warmup_ratio: 0.1, + weight_decay: 0, + lora_rank: 16, + lora_alpha: 32, + lora_dropout: 0.1, + quantization_bit: 0, + progress: 0, + train_duration: '等待调度', + create_time: '2026-02-08T14:00:00Z', + }, + { + id: 201948, + name: 'finance-sft-002', + description: '金融领域二轮微调', + status: 'failed', + train_type: 'SFT', + train_method: 'lora', + template: 'qwen', + base_model: 1, + train_dataset_id: 1, + output_model_name: 'qwen2.5-7b-finance-sft-v2', + auto_merge: false, + gpus: [3], + batch_size: 8, + learning_rate: 0.00002, + n_epochs: 3, + save_steps: 100, + lr_scheduler_type: 'cosine', + max_length: 2048, + warmup_ratio: 0.05, + weight_decay: 0.01, + lora_rank: 16, + lora_alpha: 32, + lora_dropout: 0.05, + quantization_bit: 0, + process_id: 27654, + progress: 32, + train_duration: '18分钟', + create_time: '2026-02-10T11:00:00Z', + }, + { + id: 940212, + name: 'general-sft-001', + description: '通用能力全参数微调', + status: 'completed', + train_type: 'SFT', + train_method: 'full', + template: 'llama3', + base_model: 3, + train_dataset_id: 3, + output_model_name: 'llama3-8b-general-sft-v1', + auto_merge: false, + gpus: [0, 2], + batch_size: 2, + learning_rate: 0.00001, + n_epochs: 2, + save_steps: 250, + lr_scheduler_type: 'cosine', + max_length: 4096, + warmup_ratio: 0.03, + weight_decay: 0.1, + process_id: 26318, + progress: 100, + train_duration: '3小时05分钟', + create_time: '2026-02-12T13:00:00Z', + }, ] // ============ 模型推理/对比 ============ @@ -549,11 +721,48 @@ export const mockLogFiles: LogFile[] = [ ] export const mockTrainingLogFiles: TrainingLogFile[] = [ + { file: 'medical-cpt-001_pid28741.log', name: 'medical-cpt-001', size: '6.8 MB', pid: 28741, date: '2026-07-13' }, { file: 'qwen-ft-finance-001_pid12345.log', name: 'finance-sft-001', size: '4.5 MB', pid: 12345, date: '2026-02-15' }, { file: 'llama3-ft-customer-service_pid12346.log', name: 'service-dpo-001', size: '2.1 MB', pid: 12346, date: '2026-02-18' }, { file: 'qwen-ft-legal-002_pid12350.log', name: 'legal-sft-002', size: '5.8 MB', pid: 12350, date: '2026-02-20' }, ] +const medicalTrainingLogLines = [ + '[2026-07-13 14:40:01] INFO: Launching distributed training with torchrun --nproc_per_node=2', + '[2026-07-13 14:40:02] INFO: Process rank: 0, world size: 2, device: cuda:0, distributed training: True', + '[2026-07-13 14:40:04] INFO: Loading tokenizer from /data/models/qwen2.5-14b-instruct', + '[2026-07-13 14:40:16] INFO: Loading checkpoint shards: 100% | 8/8 | 00:12', + '[2026-07-13 14:40:18] INFO: Loading dataset 医疗问答-训练集 (9,800 samples)', + '[2026-07-13 14:40:27] INFO: Tokenizing dataset: 100% | 9,800/9,800 | 00:09', + '[2026-07-13 14:40:28] INFO: LoRA config: rank=16, alpha=32, dropout=0.05, target_modules=q_proj,k_proj,v_proj,o_proj', + '[2026-07-13 14:40:29] INFO: Trainable params: 83,886,080 / 14,787,584,000 (0.5673%)', + '[2026-07-13 14:40:30] INFO: ***** Running training *****', + '[2026-07-13 14:40:30] INFO: Num examples = 9,800', + '[2026-07-13 14:40:30] INFO: Num Epochs = 3', + '[2026-07-13 14:40:30] INFO: Instantaneous batch size per device = 2', + '[2026-07-13 14:40:30] INFO: Total train batch size = 32', + '[2026-07-13 14:40:30] INFO: Gradient Accumulation steps = 8', + '[2026-07-13 14:40:30] INFO: Total optimization steps = 921', + '[2026-07-13 14:42:18] INFO: step=40 {\'loss\': 2.684, \'grad_norm\': 1.184, \'learning_rate\': 9.82e-05, \'epoch\': 0.13}', + '[2026-07-13 14:44:26] INFO: step=80 {\'loss\': 2.312, \'grad_norm\': 1.092, \'learning_rate\': 9.68e-05, \'epoch\': 0.26}', + '[2026-07-13 14:46:34] INFO: step=120 {\'loss\': 2.084, \'grad_norm\': 1.037, \'learning_rate\': 9.43e-05, \'epoch\': 0.39}', + '[2026-07-13 14:48:42] INFO: step=160 {\'loss\': 1.932, \'grad_norm\': 0.986, \'learning_rate\': 9.08e-05, \'epoch\': 0.52}', + '[2026-07-13 14:50:49] INFO: step=200 {\'loss\': 1.801, \'grad_norm\': 0.944, \'learning_rate\': 8.64e-05, \'epoch\': 0.65}', + '[2026-07-13 14:50:54] INFO: Saving checkpoint to /data/checkpoints/medical-cpt-001/checkpoint-200', + '[2026-07-13 14:52:58] INFO: step=240 {\'loss\': 1.696, \'grad_norm\': 0.913, \'learning_rate\': 8.15e-05, \'epoch\': 0.78}', + '[2026-07-13 14:55:06] INFO: step=280 {\'loss\': 1.611, \'grad_norm\': 0.887, \'learning_rate\': 7.61e-05, \'epoch\': 0.91}', + '[2026-07-13 14:57:13] INFO: step=320 {\'loss\': 1.532, \'grad_norm\': 0.852, \'learning_rate\': 7.06e-05, \'epoch\': 1.04}', + '[2026-07-13 14:59:21] INFO: step=360 {\'loss\': 1.461, \'grad_norm\': 0.829, \'learning_rate\': 6.49e-05, \'epoch\': 1.17}', + '[2026-07-13 15:01:29] INFO: step=400 {\'loss\': 1.396, \'grad_norm\': 0.811, \'learning_rate\': 5.91e-05, \'epoch\': 1.30}', + '[2026-07-13 15:01:34] INFO: Saving checkpoint to /data/checkpoints/medical-cpt-001/checkpoint-400', + '[2026-07-13 15:03:37] INFO: step=440 {\'loss\': 1.337, \'grad_norm\': 0.795, \'learning_rate\': 5.35e-05, \'epoch\': 1.43}', + '[2026-07-13 15:05:45] INFO: step=480 {\'loss\': 1.286, \'grad_norm\': 0.776, \'learning_rate\': 4.80e-05, \'epoch\': 1.56}', + '[2026-07-13 15:07:53] INFO: step=520 {\'loss\': 1.232, \'grad_norm\': 0.758, \'learning_rate\': 4.28e-05, \'epoch\': 1.69}', + '[2026-07-13 15:11:59] INFO: step=560 {\'loss\': 1.184, \'grad_norm\': 0.741, \'learning_rate\': 3.79e-05, \'epoch\': 1.82}', + '[2026-07-13 15:16:05] INFO: step=590 {\'loss\': 1.146, \'grad_norm\': 0.728, \'learning_rate\': 3.44e-05, \'epoch\': 1.92}', + '[2026-07-13 15:16:06] INFO: Training is running normally, estimated remaining time: 00:20:15', +] + const fakeLogLines = [ "[2026-02-15 08:30:12] INFO: Loading model from /data/models/qwen2.5-7b", "[2026-02-15 08:30:13] INFO: Loading dataset finance-train-001 (8560 samples)", @@ -591,3 +800,16 @@ export const mockLogContent: LogContent = { size: '2.3 MB', content: fakeLogLines.join('\n'), } + +export const mockTrainingLogContents: Record = { + 'medical-cpt-001_pid28741.log': { + file: 'medical-cpt-001_pid28741.log', + size: '6.8 MB', + content: medicalTrainingLogLines.join('\n'), + }, + 'qwen-ft-finance-001_pid12345.log': { + file: 'qwen-ft-finance-001_pid12345.log', + size: '4.5 MB', + content: fakeLogLines.join('\n'), + }, +} diff --git a/frontend/src/views/system/TrainingLogView.vue b/frontend/src/views/system/TrainingLogView.vue index 8cd5e82..c17800a 100644 --- a/frontend/src/views/system/TrainingLogView.vue +++ b/frontend/src/views/system/TrainingLogView.vue @@ -3,20 +3,24 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue' import { useRoute } from 'vue-router' import PageCard from '@/components/PageCard.vue' import ModelStatusTag from '@/components/ModelStatusTag.vue' +import TrainingTaskOverview from './training-log/TrainingTaskOverview.vue' import { useModelsStore } from '@/stores/models' -import { - getFineTune, -} from '@/api/modules/fineTune' +import { getFineTune } from '@/api/modules/fineTune' import { getTrainingLogFiles, getTrainingLogContent } from '@/api/modules/log' import { getDataset } from '@/api/modules/dataset' +import { getSystemInfo } from '@/api/modules/system' +import { TRAIN_TYPE_MAP, TRAIN_METHOD_MAP } from '@/constants' import { - TRAIN_TYPE_MAP, - TRAIN_METHOD_MAP, - DATASET_TYPE_MAP, - STORAGE_MAP, -} from '@/constants' -import type { EChartsOption } from 'echarts' -import type { FineTuneTask, DatasetItem } from '@/types' + buildMetricChartOption, + parseTrainingLog, + resolveTrainingLogFile, +} from './training-log/trainingLogModel' +import type { FineTuneTask, DatasetItem, GpuInfo } from '@/types' + +interface TaskGpuItem { + index: number + gpu?: GpuInfo +} const route = useRoute() const taskId = route.params.id as string @@ -25,7 +29,9 @@ const modelsStore = useModelsStore() const task = ref(null) const dataset = ref(null) const logContent = ref('') -const logFiles = ref<{ file: string; name: string; pid?: number }[]>([]) +const gpuPool = ref([]) +const gpuUpdatedAt = ref(null) +const gpuLoadError = ref('') /** 初始加载状态:首次数据返回前显示 loading,避免空白闪烁 */ const loading = ref(true) @@ -35,6 +41,7 @@ const metricData = reactive({ loss: [] as number[], gradNorm: [] as number[], lr: [] as number[], + epoch: [] as number[], }) const summary = reactive({ @@ -45,111 +52,136 @@ const summary = reactive({ /** 训练参数折叠状态(默认收起,点击展开查看全部参数) */ const paramsExpanded = ref(false) +const GPU_PREVIEW_LIMIT = 4 +const gpuExpanded = ref(false) let timer: ReturnType | null = null - -/** 从日志解析训练指标(移植自原项目正则) */ -function parseMetricsFromLog(text: string) { - const regex = /\{'loss':\s*([\d.]+),\s*'grad_norm':\s*([\d.]+),\s*'learning_rate':\s*([\d.e+-]+),\s*'epoch':\s*([\d.]+)\}/g - let match - while ((match = regex.exec(text)) !== null) { - metricData.loss.push(parseFloat(match[1])) - metricData.gradNorm.push(parseFloat(match[2])) - metricData.lr.push(parseFloat(match[3])) - } -} - -/** 解析训练汇总 */ -function parseTrainSummary(text: string) { - const block = text.match(/\*\*\*\*\* train metrics \*\*\*\*\*([\s\S]*?)(?:\n\n|$)/) - if (!block) return - const body = block[1] - const epoch = body.match(/'epoch':\s*([\d.]+)/) - const loss = body.match(/'train_loss':\s*([\d.]+)/) - const runtime = body.match(/'train_runtime':\s*([\d.]+)/) - summary.epoch = epoch ? epoch[1] : '' - summary.trainLoss = loss ? loss[1] : '' - summary.runtime = runtime ? runtime[1] : '' -} - -/** 构建单条曲线的 ECharts 配置(渐变填充 + 十字线 tooltip + dataZoom) */ -function buildLineOption(label: string, data: number[], color: string, logScale = false): EChartsOption { - const hasData = data.length > 0 - return { - grid: { top: 24, right: 20, bottom: 56, left: 56 }, - tooltip: { - trigger: 'axis', - axisPointer: { type: 'cross' }, - backgroundColor: 'rgba(15, 23, 42, 0.9)', - borderWidth: 0, - textStyle: { color: '#fff', fontSize: 12 }, - }, - xAxis: { - type: 'category', - boundaryGap: false, - name: 'Step', - nameTextStyle: { color: '#94a3b8', fontSize: 11 }, - axisLine: { lineStyle: { color: '#e2e8f0' } }, - axisLabel: { color: '#94a3b8', fontSize: 11 }, - splitLine: { show: false }, - }, - yAxis: { - type: logScale ? 'log' : 'value', - name: label, - nameTextStyle: { color: '#94a3b8', fontSize: 11 }, - axisLine: { show: false }, - axisTick: { show: false }, - axisLabel: { color: '#94a3b8', fontSize: 11 }, - splitLine: { lineStyle: { color: '#f1f5f9' } }, - }, - dataZoom: hasData && data.length > 30 - ? [ - { type: 'inside', start: 0, end: 100 }, - { type: 'slider', height: 16, bottom: 8, borderColor: 'transparent', fillerColor: 'rgba(79,70,229,0.08)', handleStyle: { color: '#4f46e5' } }, - ] - : [], - series: [ - { - name: label, - type: 'line', - data, - smooth: true, - symbol: 'none', - lineStyle: { width: 2, color }, - areaStyle: { - color: { - type: 'linear', - x: 0, y: 0, x2: 0, y2: 1, - colorStops: [ - { offset: 0, color: color + '55' }, - { offset: 1, color: color + '05' }, - ], - }, - }, - }, - ], - } -} +let refreshInFlight = false /** 三个曲线的 ECharts 配置(响应式,数据变化自动重绘) */ -const lossChartOption = computed(() => buildLineOption('Loss', metricData.loss, '#f56c6c')) -const gradChartOption = computed(() => buildLineOption('Grad Norm', metricData.gradNorm, '#1890ff')) -const lrChartOption = computed(() => buildLineOption('Learning Rate', metricData.lr, '#67c23a', true)) +const lossChartOption = computed(() => buildMetricChartOption('Loss', metricData.loss, '#4f46e5')) +const gradChartOption = computed(() => buildMetricChartOption('Grad Norm', metricData.gradNorm, '#3b82f6')) +const lrChartOption = computed(() => buildMetricChartOption('Learning Rate', metricData.lr, '#14b8a6', true)) +const baseModelName = computed(() => task.value?.base_model != null + ? modelsStore.getModelName(task.value.base_model) + : '未配置') +const trainingTypeName = computed(() => task.value?.train_type + ? (TRAIN_TYPE_MAP[task.value.train_type] || task.value.train_type) + : '未配置') +const trainingMethodName = computed(() => task.value?.train_method + ? (TRAIN_METHOD_MAP[task.value.train_method] || task.value.train_method) + : '未配置') +const taskGpuLabel = computed(() => task.value?.gpus?.length + ? task.value.gpus.map((gpuId) => `GPU ${gpuId}`).join('、') + : '未配置') +const latestLoss = computed(() => metricData.loss[metricData.loss.length - 1]) +const latestGradNorm = computed(() => metricData.gradNorm[metricData.gradNorm.length - 1]) +const latestLearningRate = computed(() => metricData.lr[metricData.lr.length - 1]) +const latestEpoch = computed(() => metricData.epoch[metricData.epoch.length - 1]) +const logLineCount = computed(() => logContent.value ? logContent.value.split(/\r?\n/).length : 0) +const taskGpuItems = computed(() => (task.value?.gpus ?? []).map((gpuId) => { + const index = Number(gpuId) + const gpu = gpuPool.value.find((item) => item.id != null && Number(item.id) === index) + ?? gpuPool.value[index] + return { index, gpu } +})) +const gpuSummaryText = computed(() => { + const items = taskGpuItems.value + if (!items.length) return '当前任务未配置 GPU' + + const availableItems = items.filter((item) => item.gpu) + const averageUsage = availableItems.length + ? Math.round(availableItems.reduce((sum, item) => sum + safePercent(item.gpu?.gpu_percent), 0) / availableItems.length) + : 0 + const states = items.map((item) => gpuRuntimeState(item.gpu).className) + const busyCount = states.filter((state) => state === 'is-busy').length + const attentionCount = states.filter((state) => ['is-danger', 'is-warning', 'is-unavailable'].includes(state)).length + const attentionText = attentionCount ? ` · ${attentionCount} 张需关注` : '' + return `${items.length} 张 GPU · ${busyCount} 张运行中${attentionText} · 平均利用率 ${averageUsage}%` +}) +const visibleGpuItems = computed(() => { + const items = taskGpuItems.value + if (gpuExpanded.value || items.length <= GPU_PREVIEW_LIMIT) return items + + // 折叠状态优先暴露异常、不可用和运行中的设备,避免关键状态被隐藏。 + return [...items] + .sort((left, right) => gpuRuntimePriority(left.gpu) - gpuRuntimePriority(right.gpu) || left.index - right.index) + .slice(0, GPU_PREVIEW_LIMIT) + .sort((left, right) => left.index - right.index) +}) +const hiddenGpuCount = computed(() => Math.max(0, taskGpuItems.value.length - GPU_PREVIEW_LIMIT)) +const gpuRefreshState = computed(() => { + if (!gpuUpdatedAt.value) return gpuLoadError.value || '正在获取 GPU 状态' + const updateTime = gpuUpdatedAt.value.toLocaleTimeString('zh-CN', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + return gpuLoadError.value + ? `更新失败 · 最后更新 ${updateTime}` + : `${updateTime} 更新 · 每 5 秒刷新` +}) + +function formatMetric(value?: number, scientific = false) { + if (value == null || !Number.isFinite(value)) return '-' + return scientific ? value.toExponential(2) : value.toFixed(4).replace(/0+$/, '').replace(/\.$/, '') +} + +function safePercent(value?: number) { + return Math.round(Math.min(100, Math.max(0, Number(value || 0)))) +} + +function formatResourceNumber(value?: number) { + if (value == null || !Number.isFinite(Number(value))) return '-' + return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 1 }) +} + +function gpuMemoryPercent(gpu?: GpuInfo) { + if (!gpu) return 0 + if (gpu.memory_percent != null) return safePercent(gpu.memory_percent) + if (!gpu.memory_total_gb) return 0 + return safePercent((gpu.memory_used_gb / gpu.memory_total_gb) * 100) +} + +function gpuRuntimeState(gpu?: GpuInfo) { + if (!gpu) return { label: '数据不可用', className: 'is-unavailable' } + if (gpu.status === 'offline') return { label: '离线', className: 'is-danger' } + if (gpu.status === 'warning' || gpu.temperature >= 80 || gpuMemoryPercent(gpu) >= 90) { + return { label: '需关注', className: 'is-warning' } + } + if (gpu.status === 'busy' || gpu.gpu_percent >= 10) { + return { label: '运行中', className: 'is-busy' } + } + return { label: '空闲', className: 'is-idle' } +} + +function gpuRuntimePriority(gpu?: GpuInfo) { + const priorityMap: Record = { + 'is-danger': 0, + 'is-warning': 1, + 'is-unavailable': 2, + 'is-busy': 3, + 'is-idle': 4, + } + return priorityMap[gpuRuntimeState(gpu).className] ?? 5 +} + +function gpuProgressColor(value?: number) { + const percent = safePercent(value) + if (percent >= 90) return 'var(--el-color-danger)' + if (percent >= 75) return 'var(--el-color-warning)' + return 'var(--el-color-primary)' +} async function loadTask() { try { const t = await getFineTune(taskId) task.value = t - // 基座模型解析依赖 store 中的列表 - modelsStore.load() - // 加载训练数据集 - if (t?.train_dataset_id) { - loadDataset(t.train_dataset_id) - } else { - dataset.value = null - } + return t } catch { - // ignore + task.value = null + return null } } @@ -161,12 +193,16 @@ async function loadDataset(datasetId: string | number) { } } -/** 格式化时间戳为本地字符串 */ -function formatDateTime(value?: string) { - if (!value) return '-' - const date = new Date(value) - if (Number.isNaN(date.getTime())) return value - return date.toLocaleString('zh-CN', { hour12: false }) +async function loadGpuStatus() { + try { + const systemInfo = await getSystemInfo() + gpuPool.value = systemInfo.gpu ?? [] + gpuUpdatedAt.value = new Date() + gpuLoadError.value = '' + } catch { + gpuLoadError.value = 'GPU 监控数据暂时不可用' + if (!gpuUpdatedAt.value) gpuPool.value = [] + } } /** 当前任务是否为 LoRA 系列训练方法(lora/qlora/adalora/longlora 等) */ @@ -174,24 +210,25 @@ const isLoraMethod = computed(() => String(task.value?.train_method || '').toLowerCase().includes('lora'), ) -async function loadLog() { +function applyLogContent(content: string) { + const parsed = parseTrainingLog(content) + logContent.value = content + metricData.loss = parsed.metrics.loss + metricData.gradNorm = parsed.metrics.gradNorm + metricData.lr = parsed.metrics.lr + metricData.epoch = parsed.metrics.epoch + Object.assign(summary, parsed.summary) +} + +async function loadLog(currentTask: FineTuneTask) { try { const files = (await getTrainingLogFiles()) || [] - logFiles.value = files - // 按 PID 匹配日志文件 - const matched = - files.find((f) => f.pid && task.value?.process_id && (f.file.includes(`_${f.pid}_`) || f.file.endsWith(`_${f.pid}.log`))) || - files.find((f) => task.value?.name && f.name.includes(task.value.name)) || - files[0] + const matched = resolveTrainingLogFile(files, currentTask) if (matched) { const res = await getTrainingLogContent(matched.file) - logContent.value = res.content || '' - // 解析指标 - metricData.loss = [] - metricData.gradNorm = [] - metricData.lr = [] - parseMetricsFromLog(logContent.value) - parseTrainSummary(logContent.value) + applyLogContent(res.content || '') + } else { + applyLogContent('') } } catch { // ignore @@ -199,14 +236,29 @@ async function loadLog() { } async function refreshAll() { + if (refreshInFlight) return + refreshInFlight = true try { - await Promise.all([loadTask(), loadLog()]) + const currentTask = await loadTask() + if (!currentTask) { + dataset.value = null + applyLogContent('') + return + } + + if (!currentTask.train_dataset_id) dataset.value = null + const datasetPromise = currentTask.train_dataset_id + ? loadDataset(currentTask.train_dataset_id) + : Promise.resolve() + await Promise.all([datasetPromise, loadLog(currentTask), loadGpuStatus()]) } finally { loading.value = false + refreshInFlight = false } } onMounted(() => { + modelsStore.load() refreshAll() timer = setInterval(refreshAll, 5000) }) @@ -223,229 +275,218 @@ onUnmounted(() => { diff --git a/frontend/src/views/system/training-log/TrainingTaskOverview.vue b/frontend/src/views/system/training-log/TrainingTaskOverview.vue new file mode 100644 index 0000000..2f0e493 --- /dev/null +++ b/frontend/src/views/system/training-log/TrainingTaskOverview.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/frontend/src/views/system/training-log/trainingLogModel.ts b/frontend/src/views/system/training-log/trainingLogModel.ts new file mode 100644 index 0000000..bc838d2 --- /dev/null +++ b/frontend/src/views/system/training-log/trainingLogModel.ts @@ -0,0 +1,165 @@ +import type { EChartsOption } from 'echarts' +import type { FineTuneTask, TrainingLogFile } from '@/types' + +export interface TrainingMetricData { + loss: number[] + gradNorm: number[] + lr: number[] + epoch: number[] +} + +export interface TrainingSummary { + epoch: string + trainLoss: string + runtime: string +} + +export interface ParsedTrainingLog { + metrics: TrainingMetricData + summary: TrainingSummary +} + +const NUMBER_SOURCE = '[-+]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][-+]?\\d+)?' + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function extractNumber(source: string, key: string) { + const match = source.match(new RegExp(`['"]?${escapeRegExp(key)}['"]?\\s*:\\s*(${NUMBER_SOURCE})`, 'i')) + return match ? Number(match[1]) : undefined +} + +function extractSummaryValue(source: string, key: string) { + const match = source.match(new RegExp(`['"]?${escapeRegExp(key)}['"]?\\s*(?:=|:)\\s*(${NUMBER_SOURCE})`, 'i')) + return match?.[1] || '' +} + +/** 根据任务精确选择日志;PID 优先,任务名仅作为明确兜底。 */ +export function resolveTrainingLogFile( + files: TrainingLogFile[], + task: Pick, +) { + const processId = task.process_id + if (processId != null) { + const pidMatch = files.find((file) => file.pid === processId) + if (pidMatch) return pidMatch + + const pidPattern = new RegExp(`(?:^|[^0-9])(?:pid)?${processId}(?:[^0-9]|$)`, 'i') + const filenameMatch = files.find((file) => pidPattern.test(file.file)) + if (filenameMatch) return filenameMatch + } + + const taskName = task.name.trim() + if (!taskName) return undefined + return files.find((file) => file.name.includes(taskName) || file.file.includes(taskName)) +} + +/** 解析日志中的逐步指标。字段顺序和常见数值格式均不受限制。 */ +export function parseTrainingMetrics(text: string): TrainingMetricData { + const metrics: TrainingMetricData = { loss: [], gradNorm: [], lr: [], epoch: [] } + const blocks = text.match(/\{[^{}\r\n]*\}/g) || [] + + for (const block of blocks) { + const loss = extractNumber(block, 'loss') + const gradNorm = extractNumber(block, 'grad_norm') + const learningRate = extractNumber(block, 'learning_rate') + const epoch = extractNumber(block, 'epoch') + if (loss == null || gradNorm == null || learningRate == null) continue + metrics.loss.push(loss) + metrics.gradNorm.push(gradNorm) + metrics.lr.push(learningRate) + if (epoch != null) metrics.epoch.push(epoch) + } + + return metrics +} + +/** 每次都返回新对象,日志截断或切换时不会残留上一轮汇总。 */ +export function parseTrainingSummary(text: string): TrainingSummary { + const emptySummary: TrainingSummary = { epoch: '', trainLoss: '', runtime: '' } + const startMatch = /\*{5}\s*train metrics\s*\*{5}/i.exec(text) + if (!startMatch) return emptySummary + + const tail = text.slice(startMatch.index + startMatch[0].length) + const endMatch = /\*{5}\s*train metrics end\s*\*{5}/i.exec(tail) + const body = endMatch ? tail.slice(0, endMatch.index) : tail + return { + epoch: extractSummaryValue(body, 'epoch'), + trainLoss: extractSummaryValue(body, 'train_loss'), + runtime: extractSummaryValue(body, 'train_runtime'), + } +} + +export function parseTrainingLog(text: string): ParsedTrainingLog { + return { + metrics: parseTrainingMetrics(text), + summary: parseTrainingSummary(text), + } +} + +/** 构建单条训练指标曲线。 */ +export function buildMetricChartOption( + label: string, + data: number[], + color: string, + logScale = false, +): EChartsOption { + return { + grid: { top: 24, right: 20, bottom: 56, left: 56 }, + tooltip: { + trigger: 'axis', + axisPointer: { type: 'cross' }, + backgroundColor: 'rgba(15, 23, 42, 0.9)', + borderWidth: 0, + textStyle: { color: '#fff', fontSize: 12 }, + }, + xAxis: { + type: 'category', + boundaryGap: false, + name: 'Step', + nameTextStyle: { color: '#94a3b8', fontSize: 11 }, + axisLine: { lineStyle: { color: '#e2e8f0' } }, + axisLabel: { color: '#94a3b8', fontSize: 11 }, + splitLine: { show: false }, + }, + yAxis: { + type: logScale ? 'log' : 'value', + name: label, + nameTextStyle: { color: '#94a3b8', fontSize: 11 }, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: '#94a3b8', fontSize: 11 }, + splitLine: { lineStyle: { color: '#f1f5f9' } }, + }, + dataZoom: data.length > 30 + ? [ + { type: 'inside', start: 0, end: 100 }, + { type: 'slider', height: 16, bottom: 8, borderColor: 'transparent', fillerColor: 'rgba(79,70,229,0.08)', handleStyle: { color: '#4f46e5' } }, + ] + : [], + series: [ + { + name: label, + type: 'line', + data, + smooth: true, + symbol: 'none', + lineStyle: { width: 2, color }, + areaStyle: { + color: { + type: 'linear', + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: `${color}55` }, + { offset: 1, color: `${color}05` }, + ], + }, + }, + }, + ], + } +}