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 { 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, /<aside\b/, '任务概况仍保留左右侧栏结构')
|
||||
assert.doesNotMatch(overviewSource, /<i class="fa\b/, '任务概况仍包含过多装饰性图标')
|
||||
|
||||
const toggleButtons = findElements(
|
||||
templateAst,
|
||||
(node) => node.tag === 'button'
|
||||
(node) => node.tag === 'el-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]
|
||||
assert.equal(boundExpression(toggleButton, 'aria-expanded'), 'paramsExpanded', '折叠按钮未绑定 aria-expanded')
|
||||
@@ -96,33 +144,80 @@ const controlledRegions = findElements(
|
||||
)
|
||||
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('<!-- 训练曲线 -->')
|
||||
assert.notEqual(firstChartIndex, -1, '未找到训练曲线边界,无法限定首屏检查范围')
|
||||
assert.equal(
|
||||
template.slice(0, firstChartIndex).includes('<el-descriptions'),
|
||||
false,
|
||||
'任务概览、数据集和训练参数仍使用带表格感的 el-descriptions',
|
||||
assert.ok(template.includes('training-progress-panel'), '训练概览缺少独立的主进度区域')
|
||||
assert.ok(template.includes('training-metric-grid'), '训练概览缺少次级训练指标摘要')
|
||||
assert.ok(template.includes('gpu-device-list'), '训练概览缺少紧凑的 GPU 设备列表')
|
||||
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?.batch_size ?? '未配置'"), '训练参数缺失值文案不正确')
|
||||
|
||||
const media1100 = extractCssBlock(style, '@media (max-width: 1100px)')
|
||||
const overviewAt1100 = extractCssBlock(media1100, '.overview-layout')
|
||||
assert.match(overviewAt1100, /grid-template-columns:\s*minmax\(0,\s*1fr\)/, '1100px 断点未将双栏改为单栏')
|
||||
const overviewLayoutBlock = extractCssBlock(overviewStyle, '.overview-layout')
|
||||
assert.match(overviewLayoutBlock, /width:\s*100%/, '任务概况没有使用全宽单列布局')
|
||||
assert.doesNotMatch(overviewLayoutBlock, /grid-template-columns/, '任务档案仍保留左右分栏规则')
|
||||
|
||||
const media700 = extractCssBlock(style, '@media (max-width: 700px)')
|
||||
for (const selector of ['.dataset-metrics', '.runtime-list', '.parameter-grid']) {
|
||||
assert.ok(media700.includes(selector), `700px 断点缺少单列规则:${selector}`)
|
||||
}
|
||||
assert.match(media700, /grid-template-columns:\s*minmax\(0,\s*1fr\)/, '700px 断点未设置单列网格')
|
||||
|
||||
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`,
|
||||
)
|
||||
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('训练日志详情布局回归检查通过')
|
||||
|
||||
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 数据回归检查通过')
|
||||
Reference in New Issue
Block a user