feat: 新增 compute_gateway、compute_poller、agent 模块,重构前端 dist

- 新增 backend/app/modules/compute_gateway(client/sync)计算网关模块
- 新增 backend/app/workers/compute_poller 计算轮询 worker
- 新增 compute/agent/process_manager 进程管理器
- 新增 scripts/ 脚本目录
- 更新 Docker 部署配置(app/compute/nginx)
- 更新后端平台 API、数据库 SQL、core 配置
- 更新前端多个视图组件及 API 模块
- 重构 frontend/dist 构建产物(新 hash)
- 更新多项文档

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wuyongtao
2026-07-22 17:32:59 +08:00
parent 1e438164c1
commit 836343b29e
180 changed files with 3352 additions and 263 deletions

View File

@@ -16,7 +16,16 @@ export interface ComputeNode {
data_root: string
model_root: string
log_root: string
api_version?: string
capabilities?: string[]
description?: string
last_health_check_at?: string
health_detail?: Record<string, unknown>
}
export type ComputeNodePayload = Partial<ComputeNode> & {
code?: string
api_base_url?: string
}
export interface ComputeGpu {
@@ -66,11 +75,14 @@ export interface ResourceReplica {
export const getComputeNodes = () => get<ComputeNode[]>('/compute/nodes')
export const createComputeNode = (data: ComputeNodePayload) =>
post<ComputeNode>('/compute/nodes', data)
export const updateComputeNode = (id: string, data: Partial<ComputeNode>) =>
put<ComputeNode>(`/compute/nodes/${id}`, data)
export const testComputeNode = (id: string) =>
post<{ node_id: string; success: boolean; latency_ms: number }>(`/compute/nodes/${id}/test-connection`)
post<{ node_id: string; success: boolean; latency_ms: number; gpu_count: number; error?: string }>(`/compute/nodes/${id}/test-connection`)
export const enableComputeNode = (id: string) => post<ComputeNode>(`/compute/nodes/${id}/enable`)

View File

@@ -1,5 +1,5 @@
import { get, post, put, del } from '../request'
import type { FineTuneStartPayload, FineTuneTask, TrainingProgress } from '@/types'
import type { FineTuneStartPayload, FineTuneTask, TrainingProgress, LogContent } from '@/types'
/** 训练任务列表 */
export const getFineTuneList = () => get<FineTuneTask[]>('/fine-tune')
@@ -32,6 +32,11 @@ export const deleteFineTune = (id: string | number) => del(`/fine-tune/${id}`)
export const getFineTuneProgress = (id: string | number) =>
get<TrainingProgress>(`/fine-tune/progress/${id}`)
export const getFineTuneLogs = (
id: string | number,
params: { tail_lines?: number; offset?: number; limit?: number } = {},
) => get<LogContent & { job_id?: string; source?: string }>(`/fine-tune/${id}/logs`, params)
/** 启动 TensorBoard */
export const startTensorboard = () => post('/fine-tune/tensorboard/start')

View File

@@ -12,7 +12,7 @@ export const getModelByName = (name: string) => get<ModelItem>(`/model-manage/na
/** 本地模型路径列表 */
export const getLocalModels = () =>
get<{ models: { path: string; name: string }[] }>('/model-manage/local-models')
get<{ models: { path: string; name: string; source?: string }[] }>('/model-manage/local-models')
/** 已训练模型列表 */
export const getTrainedModels = () =>

View File

@@ -136,6 +136,9 @@ export interface FineTuneTask {
export_format?: string
progress?: number
process_id?: number
compute_job_id?: string
failure_reason?: string
log_file?: string
train_duration?: string
create_time?: string
}

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
createComputeNode,
disableComputeNode,
drainComputeNode,
enableComputeNode,
@@ -10,6 +12,7 @@ import {
getComputeQueue,
getNodeReplicas,
testComputeNode,
updateComputeNode,
type ComputeGpu,
type ComputeNode,
type ComputeQueueItem,
@@ -20,14 +23,35 @@ const route = useRoute()
const router = useRouter()
const activeTab = ref(String(route.query.tab || 'nodes'))
const loading = ref(false)
const loading = ref(true)
const buttonRefreshing = ref(false)
const nodes = ref<ComputeNode[]>([])
const gpus = ref<ComputeGpu[]>([])
const queue = ref<ComputeQueueItem[]>([])
const replicas = ref<ResourceReplica[]>([])
const selectedNodeId = ref('')
const lastUpdated = ref('')
const nodeDialogVisible = ref(false)
const nodeDialogMode = ref<'create' | 'edit'>('create')
const savingNode = ref(false)
const nodeForm = reactive({
id: '',
code: '',
name: '',
api_base_url: '',
file_gateway_url: '',
enabled: true,
scheduler_status: 'offline',
scheduler_weight: 100,
tags_text: '',
max_parallel_jobs: 1,
data_root: '/data/yg-ft',
model_root: '/data/yg-ft/models',
log_root: '/opt/yg-ft/logs/training',
description: '',
})
let timer: ReturnType<typeof setInterval> | null = null
let refreshInFlight = false
const selectedNode = computed(() => nodes.value.find((item) => item.id === selectedNodeId.value))
const enabledNodes = computed(() => nodes.value.filter((item) => item.enabled).length)
@@ -41,8 +65,13 @@ watch(
},
)
async function load() {
loading.value = true
async function load(options: { showLoading?: boolean; showButtonLoading?: boolean } = {}) {
if (refreshInFlight) return
refreshInFlight = true
const showLoading = options.showLoading ?? false
const showButtonLoading = options.showButtonLoading ?? false
if (showLoading) loading.value = true
if (showButtonLoading) buttonRefreshing.value = true
try {
const [nodeList, gpuList, queueList] = await Promise.all([
getComputeNodes(),
@@ -52,11 +81,15 @@ async function load() {
nodes.value = nodeList
gpus.value = gpuList
queue.value = queueList
if (!selectedNodeId.value && nodeList.length) selectedNodeId.value = nodeList[0].id
if ((!selectedNodeId.value || !nodeList.some((item) => item.id === selectedNodeId.value)) && nodeList.length) {
selectedNodeId.value = nodeList[0].id
}
await loadReplicas()
lastUpdated.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
} finally {
loading.value = false
if (showLoading) loading.value = false
if (showButtonLoading) buttonRefreshing.value = false
refreshInFlight = false
}
}
@@ -77,10 +110,108 @@ async function handleNodeAction(action: 'enable' | 'disable' | 'drain' | 'test',
if (action === 'enable') await enableComputeNode(nodeId)
if (action === 'disable') await disableComputeNode(nodeId)
if (action === 'drain') await drainComputeNode(nodeId)
if (action === 'test') await testComputeNode(nodeId)
if (action === 'test') {
const result = await testComputeNode(nodeId)
if (result.success) {
ElMessage.success(`连接成功,发现 ${result.gpu_count} 张 GPU延迟 ${result.latency_ms}ms`)
} else {
ElMessage.error(result.error || '连接失败')
}
}
await load()
}
function resetNodeForm() {
Object.assign(nodeForm, {
id: '',
code: '',
name: '',
api_base_url: '',
file_gateway_url: '',
enabled: true,
scheduler_status: 'offline',
scheduler_weight: 100,
tags_text: '',
max_parallel_jobs: 1,
data_root: '/data/yg-ft',
model_root: '/data/yg-ft/models',
log_root: '/opt/yg-ft/logs/training',
description: '',
})
}
function openCreateNodeDialog() {
resetNodeForm()
nodeDialogMode.value = 'create'
nodeDialogVisible.value = true
}
function openEditNodeDialog(node: any) {
Object.assign(nodeForm, {
id: node.id,
code: node.code,
name: node.name,
api_base_url: node.api_base_url,
file_gateway_url: node.file_gateway_url,
enabled: node.enabled,
scheduler_status: node.scheduler_status,
scheduler_weight: node.scheduler_weight,
tags_text: node.tags?.join(', ') || '',
max_parallel_jobs: node.max_parallel_jobs,
data_root: node.data_root,
model_root: node.model_root,
log_root: node.log_root,
description: node.description || '',
})
nodeDialogMode.value = 'edit'
nodeDialogVisible.value = true
}
function buildNodePayload() {
const tags = nodeForm.tags_text
.replace(//g, ',')
.split(',')
.map((item) => item.trim())
.filter(Boolean)
return {
code: nodeForm.code.trim(),
name: nodeForm.name.trim() || nodeForm.code.trim(),
api_base_url: nodeForm.api_base_url.trim().replace(/\/$/, ''),
file_gateway_url: (nodeForm.file_gateway_url || nodeForm.api_base_url).trim().replace(/\/$/, ''),
enabled: nodeForm.enabled,
scheduler_status: nodeForm.scheduler_status,
scheduler_weight: Number(nodeForm.scheduler_weight) || 0,
tags,
max_parallel_jobs: Number(nodeForm.max_parallel_jobs) || 1,
data_root: nodeForm.data_root.trim() || '/data/yg-ft',
model_root: nodeForm.model_root.trim() || '/data/yg-ft/models',
log_root: nodeForm.log_root.trim() || '/opt/yg-ft/logs/training',
description: nodeForm.description.trim(),
}
}
async function saveNode() {
const payload = buildNodePayload()
if (!payload.code || !payload.api_base_url) {
ElMessage.warning('请填写节点编码和 Compute API 地址')
return
}
savingNode.value = true
try {
if (nodeDialogMode.value === 'create') {
await createComputeNode(payload)
ElMessage.success('节点已创建')
} else {
await updateComputeNode(nodeForm.id, payload)
ElMessage.success('节点配置已更新')
}
nodeDialogVisible.value = false
await load()
} finally {
savingNode.value = false
}
}
function nodeStatusType(status: string) {
if (status === 'online') return 'success'
if (status === 'draining' || status === 'maintenance') return 'warning'
@@ -101,8 +232,8 @@ function gpuStatusType(status: string) {
}
onMounted(() => {
load()
timer = setInterval(load, 5000)
load({ showLoading: true })
timer = setInterval(() => load(), 5000)
})
onUnmounted(() => {
@@ -119,7 +250,8 @@ onUnmounted(() => {
</div>
<div class="header-actions">
<span v-if="lastUpdated" class="last-updated">更新 {{ lastUpdated }}</span>
<el-button @click="load"></el-button>
<el-button type="primary" @click="openCreateNodeDialog">增节点</el-button>
<el-button :loading="buttonRefreshing" @click="load({ showButtonLoading: true })">刷新</el-button>
</div>
</header>
@@ -174,8 +306,9 @@ onUnmounted(() => {
<div class="muted mono">{{ row.file_gateway_url }}</div>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<el-table-column label="操作" width="330" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openEditNodeDialog(row)">编辑</el-button>
<el-button size="small" @click="handleNodeAction('test', row)">测试</el-button>
<el-button v-if="row.enabled" size="small" @click="handleNodeAction('disable', row)">停用</el-button>
<el-button v-else size="small" type="primary" @click="handleNodeAction('enable', row)">启用</el-button>
@@ -261,6 +394,65 @@ onUnmounted(() => {
</el-table>
</el-tab-pane>
</el-tabs>
<el-dialog
v-model="nodeDialogVisible"
:title="nodeDialogMode === 'create' ? '新增算力节点' : '编辑算力节点'"
width="720px"
>
<el-form label-width="130px">
<div class="node-form-grid">
<el-form-item label="节点编码" required>
<el-input v-model="nodeForm.code" :disabled="nodeDialogMode === 'edit'" placeholder="gpu-node-01" />
</el-form-item>
<el-form-item label="节点名称">
<el-input v-model="nodeForm.name" placeholder="A800 训练节点 01" />
</el-form-item>
<el-form-item label="Compute API" required>
<el-input v-model="nodeForm.api_base_url" placeholder="http://10.0.0.11:19100" />
</el-form-item>
<el-form-item label="File Gateway">
<el-input v-model="nodeForm.file_gateway_url" placeholder="http://10.0.0.11:19101" />
</el-form-item>
<el-form-item label="调度权重">
<el-input-number v-model="nodeForm.scheduler_weight" :min="0" :max="1000" controls-position="right" />
</el-form-item>
<el-form-item label="最大并行任务">
<el-input-number v-model="nodeForm.max_parallel_jobs" :min="1" :max="32" controls-position="right" />
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="nodeForm.enabled" active-text="启用" inactive-text="停用" />
</el-form-item>
<el-form-item label="调度状态">
<el-select v-model="nodeForm.scheduler_status">
<el-option label="离线" value="offline" />
<el-option label="在线" value="online" />
<el-option label="维护中" value="draining" />
<el-option label="维护模式" value="maintenance" />
</el-select>
</el-form-item>
</div>
<el-form-item label="标签">
<el-input v-model="nodeForm.tags_text" placeholder="a800, lora, beijing" />
</el-form-item>
<el-form-item label="数据根目录">
<el-input v-model="nodeForm.data_root" />
</el-form-item>
<el-form-item label="模型根目录">
<el-input v-model="nodeForm.model_root" />
</el-form-item>
<el-form-item label="训练日志目录">
<el-input v-model="nodeForm.log_root" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="nodeForm.description" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="nodeDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="savingNode" @click="saveNode">保存</el-button>
</template>
</el-dialog>
</section>
</template>
@@ -306,6 +498,12 @@ onUnmounted(() => {
font-size: 12px;
}
.last-updated {
display: inline-block;
min-width: 92px;
text-align: right;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -361,6 +559,17 @@ onUnmounted(() => {
}
}
.node-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 12px;
:deep(.el-input-number),
:deep(.el-select) {
width: 100%;
}
}
@media (max-width: 960px) {
.compute-header,
.header-actions,
@@ -372,5 +581,9 @@ onUnmounted(() => {
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.node-form-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -34,6 +34,8 @@ async function loadData() {
async function handleDelete(row: any) {
await deleteDataset(row.id)
dataList.value = dataList.value.filter((item) => item.id !== row.id)
await loadData()
ElMessage.success('删除成功')
}

View File

@@ -36,8 +36,9 @@ async function loadEvalList() {
async function handleDeleteEval(row: any) {
await ElMessageBox.confirm('确定要删除这个评测任务吗?', '确认删除', { type: 'warning' })
await deleteEval(row.id)
evalList.value = evalList.value.filter((item) => item.id !== row.id)
ElMessage.success('删除成功')
loadEvalList()
await loadEvalList()
}
function handleCreateClick() {

View File

@@ -80,6 +80,9 @@ async function refreshProgress() {
async function handleDelete(row: any) {
await deleteFineTune(row.id)
dataList.value = dataList.value.filter((item) => item.id !== row.id)
delete progressCache.value[row.id]
await loadData()
ElMessage.success('删除成功')
}

View File

@@ -106,6 +106,8 @@ async function handleDelete(row: any) {
}
}
await deleteCompare(row.id)
dataList.value = dataList.value.filter((item) => item.id !== row.id)
await loadData(true)
ElMessage.success('删除成功')
}

View File

@@ -21,7 +21,7 @@ const submitting = ref(false)
const isEdit = computed(() => !!route.params.id)
const editId = computed(() => route.params.id as string | undefined)
const localModels = ref<{ path: string; name: string }[]>([])
const localModels = ref<{ path: string; name: string; source?: string }[]>([])
const form = reactive<ModelForm>({
name: '',
@@ -180,19 +180,31 @@ onMounted(() => {
<!-- 本地模型 -->
<template v-if="form.model_source === 'local'">
<el-form-item label="本地模型路径" prop="path">
<el-select
v-model="form.path"
placeholder="请选择本地模型路径"
filterable
style="width: 100%"
>
<el-option
v-for="m in localModels"
:key="m.path"
:label="`${m.name} (${m.path})`"
:value="m.path"
/>
</el-select>
<div class="model-path-row">
<el-select
v-model="form.path"
placeholder="请选择或输入 /data/yg-ft/models 下的模型路径"
filterable
allow-create
default-first-option
clearable
style="width: 100%"
>
<el-option
v-for="m in localModels"
:key="m.path"
:label="`${m.name} (${m.path})${m.source ? ` - ${m.source}` : ''}`"
:value="m.path"
/>
</el-select>
<el-button @click="loadLocalModels">刷新</el-button>
<el-tooltip
content="模型目录需要位于算力容器可访问路径,例如默认挂载目录 docker/compute/data/yg-ft/models 对应容器内 /data/yg-ft/models。浏览器不能把电脑任意本地目录直接作为训练路径。"
placement="top"
>
<i class="fa fa-info-circle model-path-help" aria-hidden="true" />
</el-tooltip>
</div>
</el-form-item>
</template>
@@ -229,3 +241,18 @@ onMounted(() => {
</el-form>
</PageCard>
</template>
<style scoped lang="scss">
.model-path-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px;
width: 100%;
}
.model-path-help {
color: #64748b;
cursor: help;
}
</style>

View File

@@ -56,6 +56,8 @@ function loadData() {
/** 删除配置模型 */
async function handleDeleteConfig(row: any) {
await deleteModel(row.id)
configList.value = configList.value.filter((item) => item.id !== row.id)
await loadConfig()
ElMessage.success('删除成功')
}
@@ -69,8 +71,9 @@ async function handleDeleteWeight(row: any) {
{ type: 'warning' },
)
await deleteTrainedModel(row.name, 'lora')
trainedList.value = trainedList.value.filter((item) => item.name !== row.name && item.id !== row.id)
await loadTrained()
ElMessage.success('权重已删除')
loadTrained()
}
/** 合并权重 */

View File

@@ -8,7 +8,7 @@ import TrainingTaskOverview from './training-log/TrainingTaskOverview.vue'
import { usePolling } from '@/composables/usePolling'
import '@/plugins/echarts-training-log'
import { useModelsStore } from '@/stores/models'
import { getFineTune } from '@/api/modules/fineTune'
import { getFineTune, getFineTuneLogs } from '@/api/modules/fineTune'
import { getTrainingLogFiles, getTrainingLogContent } from '@/api/modules/log'
import { getDataset } from '@/api/modules/dataset'
import { getSystemInfo } from '@/api/modules/system'
@@ -223,6 +223,16 @@ function applyLogContent(content: string) {
}
async function loadLog(currentTask: FineTuneTask) {
try {
const runtime = await getFineTuneLogs(currentTask.id, { tail_lines: 800 })
if (runtime?.content || currentTask.failure_reason) {
applyLogContent(runtime?.content || currentTask.failure_reason || '')
return
}
} catch {
// Fall back to platform-side archived/generated logs for compatibility.
}
try {
const files = (await getTrainingLogFiles()) || []
const matched = resolveTrainingLogFile(files, currentTask)
@@ -263,7 +273,7 @@ const isTerminalTask = () => ['completed', 'failed', 'stopped', 'cancelled'].inc
const { start: startPolling, stop: stopPolling } = usePolling(async () => {
await refreshAll()
if (isTerminalTask()) stopPolling()
}, 5000, { immediate: false })
}, 3000, { immediate: false })
onMounted(async () => {
void modelsStore.load()