refactor: 训练日志组件化与 Mock 数据增强
拆分 TrainingTaskOverview 组件与 trainingLogModel 状态模型,TrainingLogView 大幅瘦身;Mock 新增按文件路由的训练日志内容与更真实的 GPU 进程占用数据,adapter 类型收敛为 AxiosAdapter,配套新增 mock 内容回归脚本。
This commit is contained in:
@@ -2,17 +2,35 @@ import assert from 'node:assert/strict'
|
|||||||
import { readFile } from 'node:fs/promises'
|
import { readFile } from 'node:fs/promises'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import ts from 'typescript'
|
||||||
import { parse as parseTemplate } from '@vue/compiler-dom'
|
import { parse as parseTemplate } from '@vue/compiler-dom'
|
||||||
import { parse as parseSfc } from '@vue/compiler-sfc'
|
import { parse as parseSfc } from '@vue/compiler-sfc'
|
||||||
|
|
||||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const viewPath = path.resolve(scriptDir, '../src/views/system/TrainingLogView.vue')
|
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 { descriptor } = parseSfc(source, { filename: viewPath })
|
||||||
const template = descriptor.template?.content || ''
|
const { descriptor: overviewDescriptor } = parseSfc(overviewSource, { filename: overviewPath })
|
||||||
const style = descriptor.styles.map((item) => item.content).join('\n')
|
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 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 = []) {
|
function findElements(node, predicate, result = []) {
|
||||||
if (node?.type === 1 && predicate(node)) result.push(node)
|
if (node?.type === 1 && predicate(node)) result.push(node)
|
||||||
for (const child of node?.children || []) findElements(child, predicate, result)
|
for (const child of node?.children || []) findElements(child, predicate, result)
|
||||||
@@ -50,41 +68,71 @@ function extractCssBlock(css, marker) {
|
|||||||
assert.fail(`样式规则缺少右花括号:${marker}`)
|
assert.fail(`样式规则缺少右花括号:${marker}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function relativeLuminance(hex) {
|
const logFiles = [
|
||||||
const channels = hex
|
{ file: 'first_pid111.log', name: 'first-task', size: '1 KB', pid: 111 },
|
||||||
.replace('#', '')
|
{ file: 'target_pid222.log', name: 'target-task', size: '1 KB', pid: 222 },
|
||||||
.match(/.{2}/g)
|
]
|
||||||
.map((channel) => Number.parseInt(channel, 16) / 255)
|
assert.equal(
|
||||||
.map((value) => (value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4))
|
resolveTrainingLogFile(logFiles, { process_id: 222, name: 'target-task' })?.file,
|
||||||
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]
|
'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 parsed = parseTrainingLog([
|
||||||
const lighter = Math.max(relativeLuminance(foreground), relativeLuminance(background))
|
"INFO {'epoch': .5, 'learning_rate': 1.2E-5, 'grad_norm': -2.5e+0, 'loss': +3.25}",
|
||||||
const darker = Math.min(relativeLuminance(foreground), relativeLuminance(background))
|
'***** train metrics *****',
|
||||||
return (lighter + 0.05) / (darker + 0.05)
|
'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(
|
const overview = findElements(
|
||||||
templateAst,
|
templateAst,
|
||||||
(node) => staticAttribute(node, 'class')?.split(/\s+/).includes('overview-layout'),
|
(node) => staticAttribute(node, 'class')?.split(/\s+/).includes('overview-layout'),
|
||||||
)
|
)
|
||||||
assert.equal(overview.length, 1, '双栏任务档案容器必须且只能存在一个')
|
assert.equal(overview.length, 1, '标准任务概况容器必须且只能存在一个')
|
||||||
|
assert.doesNotMatch(overviewSource, /<aside\b/, '任务概况仍保留左右侧栏结构')
|
||||||
for (const expectedClass of ['task-profile', 'dataset-profile', 'runtime-panel', 'parameter-groups']) {
|
assert.doesNotMatch(overviewSource, /<i class="fa\b/, '任务概况仍包含过多装饰性图标')
|
||||||
const matched = findElements(
|
|
||||||
templateAst,
|
|
||||||
(node) => staticAttribute(node, 'class')?.split(/\s+/).includes(expectedClass),
|
|
||||||
)
|
|
||||||
assert.equal(matched.length, 1, `缺少或重复布局结构:${expectedClass}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleButtons = findElements(
|
const toggleButtons = findElements(
|
||||||
templateAst,
|
templateAst,
|
||||||
(node) => node.tag === 'button'
|
(node) => node.tag === 'el-button'
|
||||||
&& staticAttribute(node, 'class')?.split(/\s+/).includes('params-toggle-button'),
|
&& staticAttribute(node, 'class')?.split(/\s+/).includes('params-toggle-button'),
|
||||||
)
|
)
|
||||||
assert.equal(toggleButtons.length, 1, '参数折叠必须使用唯一的原生 button')
|
assert.equal(toggleButtons.length, 1, '参数折叠必须使用唯一的标准按钮')
|
||||||
|
|
||||||
const toggleButton = toggleButtons[0]
|
const toggleButton = toggleButtons[0]
|
||||||
assert.equal(boundExpression(toggleButton, 'aria-expanded'), 'paramsExpanded', '折叠按钮未绑定 aria-expanded')
|
assert.equal(boundExpression(toggleButton, 'aria-expanded'), 'paramsExpanded', '折叠按钮未绑定 aria-expanded')
|
||||||
@@ -96,33 +144,80 @@ const controlledRegions = findElements(
|
|||||||
)
|
)
|
||||||
assert.equal(controlledRegions.length, 1, 'aria-controls 指向的参数内容区域不存在或重复')
|
assert.equal(controlledRegions.length, 1, 'aria-controls 指向的参数内容区域不存在或重复')
|
||||||
|
|
||||||
|
const pageTitleIndex = source.indexOf('id="task-page-title"')
|
||||||
|
const summaryCardIndex = source.indexOf('id="training-overview-title"')
|
||||||
|
const taskOverviewIndex = source.indexOf('<TrainingTaskOverview')
|
||||||
|
assert.notEqual(pageTitleIndex, -1, '页面缺少独立的训练任务标题')
|
||||||
|
assert.ok(pageTitleIndex < summaryCardIndex, '训练任务标题必须出现在训练概览之前')
|
||||||
|
assert.ok(summaryCardIndex < taskOverviewIndex, '训练概览必须出现在任务信息之前')
|
||||||
|
assert.equal((source.match(/<h1\b/g) || []).length, 1, '训练详情页必须且只能有一个一级标题')
|
||||||
|
assert.doesNotMatch(overviewSource, /<h1\b/, '任务信息卡片不得重复渲染页面一级标题')
|
||||||
|
assert.ok(overviewSource.includes('title="任务信息"'), '任务详情卡片缺少明确的“任务信息”标题')
|
||||||
|
|
||||||
const firstChartIndex = template.indexOf('<!-- 训练曲线 -->')
|
const firstChartIndex = template.indexOf('<!-- 训练曲线 -->')
|
||||||
assert.notEqual(firstChartIndex, -1, '未找到训练曲线边界,无法限定首屏检查范围')
|
assert.notEqual(firstChartIndex, -1, '未找到训练曲线边界,无法限定首屏检查范围')
|
||||||
assert.equal(
|
assert.ok(template.includes('training-progress-panel'), '训练概览缺少独立的主进度区域')
|
||||||
template.slice(0, firstChartIndex).includes('<el-descriptions'),
|
assert.ok(template.includes('training-metric-grid'), '训练概览缺少次级训练指标摘要')
|
||||||
false,
|
assert.ok(template.includes('gpu-device-list'), '训练概览缺少紧凑的 GPU 设备列表')
|
||||||
'任务概览、数据集和训练参数仍使用带表格感的 el-descriptions',
|
assert.ok(template.includes('gpu-list-header'), 'GPU 设备列表缺少企业表格式列标题')
|
||||||
|
assert.ok(template.includes('gpu-device-row'), 'GPU 设备列表缺少统一行结构')
|
||||||
|
assert.doesNotMatch(template, /training-summary-strip|gpu-device-card/, '训练概览仍保留同级宫格或 GPU 嵌套卡片结构')
|
||||||
|
assert.ok(template.includes('id="training-overview-title"'), '训练概览缺少可访问的卡片标题')
|
||||||
|
assert.ok(
|
||||||
|
template.indexOf('id="training-overview-title"') < template.indexOf('<TrainingTaskOverview'),
|
||||||
|
'训练概览必须位于任务档案之前,成为页面第一块内容',
|
||||||
)
|
)
|
||||||
|
assert.ok(
|
||||||
|
template.indexOf('training-progress-panel') < template.indexOf('training-metric-grid')
|
||||||
|
&& template.indexOf('training-metric-grid') < template.indexOf('gpu-device-list'),
|
||||||
|
'训练概览必须按主进度、次级指标、GPU 设备列表的顺序展示',
|
||||||
|
)
|
||||||
|
assert.ok(template.includes('id="gpu-monitor-title"'), '训练概览缺少 GPU 运行状态区域')
|
||||||
|
assert.ok(template.includes('计算利用率'), 'GPU 运行状态缺少计算利用率')
|
||||||
|
assert.ok(template.includes('显存占用'), 'GPU 运行状态缺少显存占用')
|
||||||
|
assert.ok(template.includes('每 5 秒刷新'), 'GPU 运行状态缺少刷新频率说明')
|
||||||
|
assert.ok(template.includes(':aria-label="`GPU ${item.index} 计算利用率'), 'GPU 强度条缺少可访问说明')
|
||||||
|
assert.match(source, /const GPU_PREVIEW_LIMIT = 4/, '多 GPU 默认预览数量必须限制为 4 张')
|
||||||
|
assert.match(source, /const visibleGpuItems = computed/, '多 GPU 缺少渐进披露列表计算')
|
||||||
|
assert.match(source, /gpuRuntimePriority/, '折叠状态必须优先展示异常和运行中的 GPU')
|
||||||
|
assert.ok(template.includes('v-for="item in visibleGpuItems"'), 'GPU 列表没有使用受控预览数据')
|
||||||
|
assert.ok(template.includes('taskGpuItems.length > 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(/<el-descriptions\b/g) || []).length >= 3, '任务概况和训练参数没有使用标准详情表格')
|
||||||
|
assert.equal((template.match(/class="chart-section"/g) || []).length, 3, '三组训练曲线没有按上下结构完整展示')
|
||||||
|
assert.doesNotMatch(template, /<el-row\b|<el-col\b/, '训练曲线仍保留左右分栏布局')
|
||||||
|
assert.doesNotMatch(template, /class="chart-card"/, '训练曲线仍存在卡片嵌套')
|
||||||
|
assert.doesNotMatch(template, /summary-metric is-primary/, '训练概览仍保留大块装饰性主色背景')
|
||||||
|
|
||||||
|
for (const selector of ['.summary-card', '.parameters-card', '.metrics-panel', '.log-card']) {
|
||||||
|
const cardBlock = extractCssBlock(style, selector)
|
||||||
|
assert.match(cardBlock, /border-radius:\s*8px/, `${selector} 未统一为 8px 企业卡片圆角`)
|
||||||
|
assert.match(cardBlock, /box-shadow:\s*none/, `${selector} 仍保留不统一的浮层阴影`)
|
||||||
|
}
|
||||||
|
|
||||||
assert.ok(template.includes("task?.output_model_name || '暂未生成'"), '输出模型缺失值文案不正确')
|
assert.ok(template.includes("task?.output_model_name || '暂未生成'"), '输出模型缺失值文案不正确')
|
||||||
assert.ok(template.includes("task?.batch_size ?? '未配置'"), '训练参数缺失值文案不正确')
|
assert.ok(template.includes("task?.batch_size ?? '未配置'"), '训练参数缺失值文案不正确')
|
||||||
|
|
||||||
const media1100 = extractCssBlock(style, '@media (max-width: 1100px)')
|
const overviewLayoutBlock = extractCssBlock(overviewStyle, '.overview-layout')
|
||||||
const overviewAt1100 = extractCssBlock(media1100, '.overview-layout')
|
assert.match(overviewLayoutBlock, /width:\s*100%/, '任务概况没有使用全宽单列布局')
|
||||||
assert.match(overviewAt1100, /grid-template-columns:\s*minmax\(0,\s*1fr\)/, '1100px 断点未将双栏改为单栏')
|
assert.doesNotMatch(overviewLayoutBlock, /grid-template-columns/, '任务档案仍保留左右分栏规则')
|
||||||
|
|
||||||
const media700 = extractCssBlock(style, '@media (max-width: 700px)')
|
const overviewMedia700 = extractCssBlock(overviewStyle, '@media (max-width: 700px)')
|
||||||
for (const selector of ['.dataset-metrics', '.runtime-list', '.parameter-grid']) {
|
assert.ok(overviewMedia700.includes('.task-descriptions'), '700px 断点缺少任务详情表单列规则')
|
||||||
assert.ok(media700.includes(selector), `700px 断点缺少单列规则:${selector}`)
|
assert.match(overviewMedia700, /display:\s*block/, '移动端任务详情表没有转换为单列')
|
||||||
}
|
const viewMedia600 = extractCssBlock(viewStyle, '@media (max-width: 600px)')
|
||||||
assert.match(media700, /grid-template-columns:\s*minmax\(0,\s*1fr\)/, '700px 断点未设置单列网格')
|
assert.ok(viewMedia600.includes('.parameter-descriptions'), '600px 断点缺少训练参数表单列规则')
|
||||||
|
assert.match(viewMedia600, /display:\s*block/, '移动端训练参数表没有转换为单列')
|
||||||
const mutedBlock = extractCssBlock(style, '.is-muted')
|
|
||||||
const mutedColor = mutedBlock.match(/color:\s*(#[0-9a-f]{6})/i)?.[1]
|
|
||||||
assert.ok(mutedColor, '未配置文本缺少明确颜色')
|
|
||||||
assert.ok(
|
|
||||||
contrastRatio(mutedColor, '#ffffff') >= 4.5,
|
|
||||||
`未配置文本颜色 ${mutedColor} 与白色背景对比度不足 4.5:1`,
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log('训练日志详情布局回归检查通过')
|
console.log('训练日志详情布局回归检查通过')
|
||||||
|
|||||||
77
frontend/scripts/regression-training-log-mock.mjs
Normal file
77
frontend/scripts/regression-training-log-mock.mjs
Normal file
@@ -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 数据回归检查通过')
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* 拦截所有 API 请求并返回 mock 数据
|
* 拦截所有 API 请求并返回 mock 数据
|
||||||
* 通过 URL + method 路由到对应的 mock 响应
|
* 通过 URL + method 路由到对应的 mock 响应
|
||||||
*/
|
*/
|
||||||
import type { AxiosInstance, AxiosRequestConfig } from 'axios'
|
import type { AxiosAdapter, AxiosInstance, AxiosRequestConfig } from 'axios'
|
||||||
import {
|
import {
|
||||||
mockLoginOk,
|
mockLoginOk,
|
||||||
mockHealth,
|
mockHealth,
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
mockLogFiles,
|
mockLogFiles,
|
||||||
mockTrainingLogFiles,
|
mockTrainingLogFiles,
|
||||||
mockLogContent,
|
mockLogContent,
|
||||||
|
mockTrainingLogContents,
|
||||||
} from './data'
|
} from './data'
|
||||||
import {
|
import {
|
||||||
activateDatasetVersion,
|
activateDatasetVersion,
|
||||||
@@ -164,7 +165,8 @@ async function handleMock(config: AxiosRequestConfig) {
|
|||||||
}
|
}
|
||||||
m = url.match(/^\/dataset-manage\/([^/]+)$/)
|
m = url.match(/^\/dataset-manage\/([^/]+)$/)
|
||||||
if (m && method === 'get') {
|
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)
|
return found ? ok(found) : fail('数据集不存在', 404)
|
||||||
}
|
}
|
||||||
if (m && (method === 'put' || method === 'delete')) {
|
if (m && (method === 'put' || method === 'delete')) {
|
||||||
@@ -261,7 +263,8 @@ async function handleMock(config: AxiosRequestConfig) {
|
|||||||
}
|
}
|
||||||
m = url.match(/^\/fine-tune\/progress\/([^/]+)$/)
|
m = url.match(/^\/fine-tune\/progress\/([^/]+)$/)
|
||||||
if (m && method === 'get') {
|
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) return fail('任务不存在', 404)
|
||||||
if (task.status === 'running') {
|
if (task.status === 'running') {
|
||||||
return ok({
|
return ok({
|
||||||
@@ -276,7 +279,8 @@ async function handleMock(config: AxiosRequestConfig) {
|
|||||||
}
|
}
|
||||||
m = url.match(/^\/fine-tune\/([^/]+)$/)
|
m = url.match(/^\/fine-tune\/([^/]+)$/)
|
||||||
if (m && method === 'get') {
|
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)
|
return found ? ok(found) : fail('任务不存在', 404)
|
||||||
}
|
}
|
||||||
m = url.match(/^\/fine-tune\/stop\/([^/]+)$/)
|
m = url.match(/^\/fine-tune\/stop\/([^/]+)$/)
|
||||||
@@ -296,7 +300,8 @@ async function handleMock(config: AxiosRequestConfig) {
|
|||||||
}
|
}
|
||||||
m = url.match(/^\/model-compare\/([^/]+)$/)
|
m = url.match(/^\/model-compare\/([^/]+)$/)
|
||||||
if (m && method === 'get') {
|
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)
|
return found ? ok(found) : fail('任务不存在', 404)
|
||||||
}
|
}
|
||||||
if (m && method === 'delete') return ok({ deleted: m[1] })
|
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-files' && method === 'get') return ok(mockLogFiles)
|
||||||
if (url === '/log-content' && method === 'get') return ok(mockLogContent)
|
if (url === '/log-content' && method === 'get') return ok(mockLogContent)
|
||||||
if (url === '/training-log-files' && method === 'get') return ok(mockTrainingLogFiles)
|
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)
|
// 未匹配的请求 → 兜底返回空成功(避免阻断 UI)
|
||||||
console.warn('[Mock] 未匹配路由:', method.toUpperCase(), url, params)
|
console.warn('[Mock] 未匹配路由:', method.toUpperCase(), url, params)
|
||||||
@@ -380,12 +392,13 @@ function safeJSON(str: string) {
|
|||||||
|
|
||||||
/** 给 axios instance 安装 mock adapter */
|
/** 给 axios instance 安装 mock adapter */
|
||||||
export function installMockAdapter(instance: AxiosInstance) {
|
export function installMockAdapter(instance: AxiosInstance) {
|
||||||
instance.defaults.adapter = async (config: AxiosRequestConfig) => {
|
const adapter = async (config: AxiosRequestConfig) => {
|
||||||
try {
|
try {
|
||||||
const response = await handleMock(config)
|
const response = await handleMock(config)
|
||||||
return response
|
return response
|
||||||
} catch (e: any) {
|
} catch (error: unknown) {
|
||||||
return fail(e.message || 'Mock 错误', 500, config)
|
return fail(error instanceof Error ? error.message : 'Mock 错误', 500, config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
instance.defaults.adapter = adapter as AxiosAdapter
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,37 +58,40 @@ export const mockSystemInfo: SystemInfo = {
|
|||||||
id: 0,
|
id: 0,
|
||||||
uuid: 'GPU-MOCK-A800-00',
|
uuid: 'GPU-MOCK-A800-00',
|
||||||
name: 'NVIDIA A800',
|
name: 'NVIDIA A800',
|
||||||
status: 'idle',
|
status: 'busy',
|
||||||
gpu_percent: 0,
|
gpu_percent: 74,
|
||||||
memory_used_gb: 0,
|
memory_used_gb: 41.8,
|
||||||
memory_total_gb: 80,
|
memory_total_gb: 80,
|
||||||
memory_percent: 0,
|
memory_percent: 52.3,
|
||||||
temperature: 32,
|
temperature: 63,
|
||||||
power_w: 38,
|
power_w: 286,
|
||||||
power_limit_w: 400,
|
power_limit_w: 400,
|
||||||
fan_speed: 0,
|
fan_speed: 51,
|
||||||
clock_mhz: 1410,
|
clock_mhz: 1410,
|
||||||
driver_version: '535.86.10',
|
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,
|
id: 1,
|
||||||
uuid: 'GPU-MOCK-A800-01',
|
uuid: 'GPU-MOCK-A800-01',
|
||||||
name: 'NVIDIA A800',
|
name: 'NVIDIA A800',
|
||||||
status: 'busy',
|
status: 'busy',
|
||||||
gpu_percent: 28,
|
gpu_percent: 71,
|
||||||
memory_used_gb: 22.5,
|
memory_used_gb: 42.1,
|
||||||
memory_total_gb: 80,
|
memory_total_gb: 80,
|
||||||
memory_percent: 28.1,
|
memory_percent: 52.6,
|
||||||
temperature: 52,
|
temperature: 62,
|
||||||
power_w: 165,
|
power_w: 279,
|
||||||
power_limit_w: 400,
|
power_limit_w: 400,
|
||||||
fan_speed: 32,
|
fan_speed: 49,
|
||||||
clock_mhz: 1410,
|
clock_mhz: 1410,
|
||||||
driver_version: '535.86.10',
|
driver_version: '535.86.10',
|
||||||
processes: [
|
processes: [
|
||||||
{ pid: 18421, name: 'python', task_name: '指令微调任务', user: 'trainer', memory_used_gb: 18.6 },
|
{ pid: 28742, name: 'python', task_name: 'medical-cpt-001 / rank 1', user: 'trainer', memory_used_gb: 39.9 },
|
||||||
{ pid: 18503, name: 'python', task_name: '训练指标采集', user: 'trainer', memory_used_gb: 3.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,
|
id: 1,
|
||||||
name: '金融问答-训练集',
|
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: 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: 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' },
|
{ 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,
|
...dataset,
|
||||||
files: dataset.files?.length
|
files: dataset.files?.length
|
||||||
? dataset.files
|
? dataset.files
|
||||||
@@ -339,12 +342,181 @@ export const mockDatasetPreviews: Record<string, string> = {
|
|||||||
|
|
||||||
// ============ 训练任务 ============
|
// ============ 训练任务 ============
|
||||||
export const mockFineTuneList: FineTuneTask[] = [
|
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: 103942,
|
||||||
{ 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' },
|
name: 'finance-sft-001',
|
||||||
{ 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' },
|
description: '金融领域 SFT 训练',
|
||||||
{ 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' },
|
status: 'completed',
|
||||||
{ 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' },
|
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[] = [
|
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: '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: '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' },
|
{ 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 = [
|
const fakeLogLines = [
|
||||||
"[2026-02-15 08:30:12] INFO: Loading model from /data/models/qwen2.5-7b",
|
"[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)",
|
"[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',
|
size: '2.3 MB',
|
||||||
content: fakeLogLines.join('\n'),
|
content: fakeLogLines.join('\n'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const mockTrainingLogContents: Record<string, LogContent> = {
|
||||||
|
'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'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PageCard from '@/components/PageCard.vue'
|
||||||
|
import { DATASET_TYPE_MAP, STORAGE_MAP, TRAIN_METHOD_MAP, TRAIN_TYPE_MAP } from '@/constants'
|
||||||
|
import type { DatasetItem, FineTuneTask } from '@/types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
task: FineTuneTask | null
|
||||||
|
dataset: DatasetItem | null
|
||||||
|
baseModelName: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageCard class="task-overview" title="任务信息" subtitle="模型、数据集与运行配置">
|
||||||
|
<div class="overview-layout" aria-label="训练任务信息">
|
||||||
|
<el-descriptions class="task-descriptions task-profile" :column="2" border>
|
||||||
|
<el-descriptions-item label="任务 ID">{{ task?.id ?? '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间">{{ formatDateTime(task?.create_time) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="基座模型">{{ baseModelName }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="输出模型">{{ task?.output_model_name || '暂未生成' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="训练方式">
|
||||||
|
{{ task?.train_type ? (TRAIN_TYPE_MAP[task.train_type] || task.train_type) : '未配置' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="训练方法">
|
||||||
|
{{ task?.train_method ? (TRAIN_METHOD_MAP[task.train_method] || task.train_method) : '未配置' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="训练数据集" class-name="dataset-profile">
|
||||||
|
{{ dataset?.name || (task?.train_dataset_id ? '正在加载' : '未配置') }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="数据类型">
|
||||||
|
{{ dataset ? (DATASET_TYPE_MAP[dataset.type] || dataset.type || '未配置') : '未配置' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="数据条数">
|
||||||
|
{{ dataset?.count?.toLocaleString('zh-CN') ?? '未配置' }}{{ dataset?.count != null ? ' 条' : '' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="数据大小">{{ dataset?.size || '未配置' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="训练开始时间" class-name="runtime-panel">
|
||||||
|
{{ formatDateTime(task?.create_time) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="训练时长">{{ task?.train_duration || '未配置' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="存储位置">
|
||||||
|
{{ STORAGE_MAP[dataset?.storage_type || ''] || dataset?.storage_type || '未配置' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="使用 GPU">
|
||||||
|
{{ task?.gpus?.length ? task.gpus.join('、') : '未配置' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</div>
|
||||||
|
</PageCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.task-overview {
|
||||||
|
margin-bottom: 0;
|
||||||
|
border: 1px solid #e4e7ed !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.overview-layout { width: 100%; }
|
||||||
|
.task-descriptions :deep(.el-descriptions__label) {
|
||||||
|
width: 132px;
|
||||||
|
color: #606266;
|
||||||
|
font-weight: 500;
|
||||||
|
background: #f7f8fa !important;
|
||||||
|
}
|
||||||
|
.task-descriptions :deep(.el-descriptions__content) {
|
||||||
|
color: #303133;
|
||||||
|
font-weight: 500;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.task-descriptions :deep(.el-descriptions__cell) { padding: 12px 16px !important; }
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.task-descriptions :deep(.el-descriptions__body),
|
||||||
|
.task-descriptions :deep(.el-descriptions__table),
|
||||||
|
.task-descriptions :deep(.el-descriptions__tbody),
|
||||||
|
.task-descriptions :deep(.el-descriptions__row),
|
||||||
|
.task-descriptions :deep(.el-descriptions__cell) { display: block; width: 100%; box-sizing: border-box; }
|
||||||
|
.task-descriptions :deep(.el-descriptions__label) { width: 100%; border-bottom: 0 !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
165
frontend/src/views/system/training-log/trainingLogModel.ts
Normal file
165
frontend/src/views/system/training-log/trainingLogModel.ts
Normal file
@@ -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<FineTuneTask, 'process_id' | 'name'>,
|
||||||
|
) {
|
||||||
|
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` },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user