feat: 添加平台管理、计算模块适配器及前端页面更新

- 新增 platform API 端点和存储
- 新增 llama_factory 适配器
- 新增前端 compute、guide、system 等视图页面
- 新增 echarts 插件和 mock 数据
- 更新 Docker 配置、后端配置及文档
- 更新前端路由、API、侧边栏等组件

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wuyongtao
2026-07-21 09:23:43 +08:00
parent 2c1e08a271
commit a67ca2c19c
43 changed files with 3632 additions and 814 deletions

View File

@@ -0,0 +1,376 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
disableComputeNode,
drainComputeNode,
enableComputeNode,
getComputeGpus,
getComputeNodes,
getComputeQueue,
getNodeReplicas,
testComputeNode,
type ComputeGpu,
type ComputeNode,
type ComputeQueueItem,
type ResourceReplica,
} from '@/api/modules/compute'
const route = useRoute()
const router = useRouter()
const activeTab = ref(String(route.query.tab || 'nodes'))
const loading = ref(false)
const nodes = ref<ComputeNode[]>([])
const gpus = ref<ComputeGpu[]>([])
const queue = ref<ComputeQueueItem[]>([])
const replicas = ref<ResourceReplica[]>([])
const selectedNodeId = ref('')
const lastUpdated = ref('')
let timer: ReturnType<typeof setInterval> | null = null
const selectedNode = computed(() => nodes.value.find((item) => item.id === selectedNodeId.value))
const enabledNodes = computed(() => nodes.value.filter((item) => item.enabled).length)
const busyGpus = computed(() => gpus.value.filter((item) => item.status === 'busy' || item.status === 'reserved').length)
const totalRunningJobs = computed(() => nodes.value.reduce((sum, item) => sum + item.current_running_jobs, 0))
watch(
() => route.query.tab,
(tab) => {
activeTab.value = String(tab || 'nodes')
},
)
async function load() {
loading.value = true
try {
const [nodeList, gpuList, queueList] = await Promise.all([
getComputeNodes(),
getComputeGpus(),
getComputeQueue(),
])
nodes.value = nodeList
gpus.value = gpuList
queue.value = queueList
if (!selectedNodeId.value && nodeList.length) selectedNodeId.value = nodeList[0].id
await loadReplicas()
lastUpdated.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
} finally {
loading.value = false
}
}
async function loadReplicas() {
if (!selectedNodeId.value) {
replicas.value = []
return
}
replicas.value = await getNodeReplicas(selectedNodeId.value)
}
async function changeTab(name: string | number) {
await router.replace({ path: '/compute', query: { tab: String(name) } })
}
async function handleNodeAction(action: 'enable' | 'disable' | 'drain' | 'test', node: any) {
const nodeId = String(node.id)
if (action === 'enable') await enableComputeNode(nodeId)
if (action === 'disable') await disableComputeNode(nodeId)
if (action === 'drain') await drainComputeNode(nodeId)
if (action === 'test') await testComputeNode(nodeId)
await load()
}
function nodeStatusType(status: string) {
if (status === 'online') return 'success'
if (status === 'draining' || status === 'maintenance') return 'warning'
return 'info'
}
function taskStatusType(status: string) {
if (status === 'running') return 'success'
if (status === 'syncing' || status === 'queued') return 'warning'
if (status === 'failed') return 'danger'
return 'info'
}
function gpuStatusType(status: string) {
if (status === 'busy') return 'success'
if (status === 'reserved') return 'warning'
return 'info'
}
onMounted(() => {
load()
timer = setInterval(load, 5000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
<template>
<section class="compute-page" v-loading="loading">
<header class="compute-header">
<div>
<h1>算力节点</h1>
<p>维护训练算力节点GPU 状态任务队列和模型/数据集本地副本</p>
</div>
<div class="header-actions">
<span v-if="lastUpdated" class="last-updated">更新 {{ lastUpdated }}</span>
<el-button @click="load">刷新</el-button>
</div>
</header>
<div class="summary-grid">
<div class="summary-tile">
<span>在线节点</span>
<strong>{{ enabledNodes }} / {{ nodes.length }}</strong>
</div>
<div class="summary-tile">
<span>GPU 占用</span>
<strong>{{ busyGpus }} / {{ gpus.length }}</strong>
</div>
<div class="summary-tile">
<span>运行任务</span>
<strong>{{ totalRunningJobs }}</strong>
</div>
<div class="summary-tile">
<span>队列任务</span>
<strong>{{ queue.length }}</strong>
</div>
</div>
<el-tabs v-model="activeTab" class="compute-tabs" @tab-change="changeTab">
<el-tab-pane label="节点" name="nodes">
<el-table :data="nodes" height="100%">
<el-table-column prop="code" label="节点编码" min-width="150" />
<el-table-column prop="name" label="节点名称" min-width="150" />
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="nodeStatusType(row.scheduler_status)">{{ row.scheduler_status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="启用" width="90">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="scheduler_weight" label="权重" width="90" />
<el-table-column label="标签" min-width="220">
<template #default="{ row }">
<el-space wrap>
<el-tag v-for="tag in row.tags" :key="tag" effect="plain">{{ tag }}</el-tag>
</el-space>
</template>
</el-table-column>
<el-table-column label="任务/GPU" width="120">
<template #default="{ row }">{{ row.current_running_jobs }} / {{ row.gpu_count }}</template>
</el-table-column>
<el-table-column label="服务地址" min-width="260">
<template #default="{ row }">
<div class="mono">{{ row.api_base_url }}</div>
<div class="muted mono">{{ row.file_gateway_url }}</div>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<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>
<el-button size="small" type="warning" plain @click="handleNodeAction('drain', row)">维护</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="GPU" name="gpus">
<el-table :data="gpus" height="100%">
<el-table-column label="GPU" min-width="220">
<template #default="{ row }">
<strong>{{ row.node_code }} / GPU {{ row.id }}</strong>
<div class="muted">{{ row.name }}</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="gpuStatusType(row.status)">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="利用率" min-width="180">
<template #default="{ row }">
<el-progress :percentage="row.gpu_percent" :stroke-width="8" />
</template>
</el-table-column>
<el-table-column label="显存" min-width="190">
<template #default="{ row }">
<el-progress :percentage="Math.round(row.memory_percent)" :stroke-width="8" />
<span class="muted">{{ row.memory_used_gb }} / {{ row.memory_total_gb }} GB</span>
</template>
</el-table-column>
<el-table-column label="温度/功耗" width="150">
<template #default="{ row }">{{ row.temperature }} C / {{ row.power_w }} W</template>
</el-table-column>
<el-table-column label="进程" min-width="220">
<template #default="{ row }">
<span v-if="!row.processes?.length" class="muted">空闲</span>
<div v-for="process in row.processes" :key="process.pid">
{{ process.task_name }} <span class="muted">PID {{ process.pid }}</span>
</div>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="队列" name="queue">
<el-table :data="queue" height="100%">
<el-table-column prop="name" label="任务名称" min-width="200" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="taskStatusType(row.status)">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="进度" min-width="220">
<template #default="{ row }">
<el-progress :percentage="row.progress" :stroke-width="8" />
</template>
</el-table-column>
<el-table-column prop="compute_node_id" label="节点" width="140" />
<el-table-column label="GPU" width="120">
<template #default="{ row }">{{ row.gpus?.join(', ') || '-' }}</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="190" />
</el-table>
</el-tab-pane>
<el-tab-pane label="资源副本" name="replicas">
<div class="replica-toolbar">
<el-select v-model="selectedNodeId" filterable @change="loadReplicas">
<el-option v-for="node in nodes" :key="node.id" :label="`${node.code} - ${node.name}`" :value="node.id" />
</el-select>
<span v-if="selectedNode" class="muted">当前节点 {{ selectedNode.api_base_url }}</span>
</div>
<el-table :data="replicas" height="100%">
<el-table-column prop="resource_type" label="资源类型" width="110" />
<el-table-column prop="resource_id" label="资源 ID" min-width="180" />
<el-table-column prop="local_path" label="本地路径" min-width="300" />
<el-table-column prop="status" label="状态" width="110" />
<el-table-column prop="sync_status" label="同步状态" width="120" />
<el-table-column prop="create_time" label="创建时间" width="190" />
</el-table>
</el-tab-pane>
</el-tabs>
</section>
</template>
<style scoped lang="scss">
.compute-page {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
height: 100%;
padding: 24px;
background: #fff;
}
.compute-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
h1 {
margin: 0;
font-size: 24px;
font-weight: 650;
color: #111827;
}
p {
margin: 8px 0 0;
color: #64748b;
}
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.last-updated,
.muted {
color: #64748b;
font-size: 12px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.summary-tile {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 14px 16px;
background: #f8fafc;
span {
display: block;
color: #64748b;
font-size: 12px;
}
strong {
display: block;
margin-top: 8px;
color: #111827;
font-size: 24px;
}
}
.compute-tabs {
flex: 1;
min-height: 0;
:deep(.el-tabs__content) {
height: calc(100% - 56px);
}
:deep(.el-tab-pane) {
height: 100%;
}
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.replica-toolbar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 12px;
.el-select {
width: 280px;
}
}
@media (max-width: 960px) {
.compute-header,
.header-actions,
.replica-toolbar {
flex-direction: column;
align-items: stretch;
}
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>