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:
@@ -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>
|
||||
|
||||
@@ -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('删除成功')
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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('删除成功')
|
||||
}
|
||||
|
||||
|
||||
@@ -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('删除成功')
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
/** 合并权重 */
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user