This commit is contained in:
wangjiming
2026-07-31 16:10:34 +08:00
parent 945b4ace86
commit 242407b676
34 changed files with 3847 additions and 717 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

@@ -26,7 +26,7 @@ export interface DashboardStats {
service_status: ServiceStatusStat[]
training_tasks: TrainingTaskStat[]
operation_distribution: { name: string; value: number }[]
login_duration_rank: { user: string; role: string; duration: string }[]
login_duration_rank: { user: string; role: string; duration: number }[]
recent_login_users: { user: string; role: string; last_login: string }[]
}

View File

@@ -19,6 +19,10 @@ export const getHealth = () => get<HealthMetrics>('/health')
export const login = (username: string, password: string) =>
post<LoginResponse>('/login', { username, password })
/** 退出登录,上报会话结束以统计在线时长 */
export const logout = (sessionId: string) =>
post<null>('/logout', { session_id: sessionId })
/** 用户列表 */
export const getUsers = () => get<SystemUser[]>('/users')

View File

@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { login as loginApi } from '@/api/modules/system'
import { login as loginApi, logout as logoutApi } from '@/api/modules/system'
import { SESSION_TIMEOUT } from '@/constants'
import type { PermissionCode, SystemUser } from '@/types'
@@ -61,6 +61,7 @@ export const useAuthStore = defineStore('auth', () => {
return '观察员'
})
const loginTime = ref<number>(parseInt(localStorage.getItem('loginTime') || '0', 10) || 0)
const sessionId = ref<string>(localStorage.getItem('sessionId') || '')
const isLoggedIn = computed(() => {
if (!loginTime.value) return false
@@ -76,6 +77,8 @@ export const useAuthStore = defineStore('auth', () => {
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(response.user))
localStorage.setItem('loginTime', String(loginTime.value))
localStorage.setItem('authToken', response.token)
sessionId.value = response.session_id
localStorage.setItem('sessionId', response.session_id)
}
/** 检查当前账号是否拥有指定模块权限。 */
@@ -93,13 +96,22 @@ export const useAuthStore = defineStore('auth', () => {
}
/** 退出 */
function logout() {
async function logout() {
if (sessionId.value) {
try {
await logoutApi(sessionId.value)
} catch {
// 上报失败不影响本地退出
}
}
currentUser.value = null
loginTime.value = 0
sessionId.value = ''
localStorage.removeItem('username')
localStorage.removeItem(USER_STORAGE_KEY)
localStorage.removeItem('loginTime')
localStorage.removeItem('authToken')
localStorage.removeItem('sessionId')
}
return {
@@ -108,6 +120,7 @@ export const useAuthStore = defineStore('auth', () => {
displayName,
roleLabel,
loginTime,
sessionId,
isLoggedIn,
hasPermission,
login,

View File

@@ -418,6 +418,7 @@ export interface SystemUser {
export interface LoginResponse {
token: string
user: SystemUser
session_id: string
}
export interface CreateUserPayload {

View File

@@ -0,0 +1,99 @@
type TagType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
const STATUS_LABELS: Record<string, string> = {
pending: '等待中',
syncing: '同步中',
queued: '排队中',
running: '运行中',
completed: '已完成',
failed: '失败',
stopped: '已停止',
cancelled: '已取消',
starting: '启动中',
loading: '加载中',
loaded: '已加载',
ready: '已就绪',
done: '已完成',
error: '异常',
success: '成功',
not_started: '未启动',
valid: '有效',
modified: '已修改',
invalid: '无效',
original: '原始',
manual: '手动新增',
active: '启用',
disabled: '停用',
online: '在线',
offline: '离线',
draining: '维护中',
maintenance: '维护模式',
busy: '忙碌',
reserved: '已预留',
idle: '空闲',
warning: '告警',
available: '可用',
missing: '缺失',
synced: '已同步',
drifted: '已漂移',
repair_pending: '待修复',
}
const STATUS_TYPES: Record<string, TagType> = {
completed: 'success',
success: 'success',
running: 'success',
ready: 'success',
loaded: 'success',
active: 'success',
online: 'success',
busy: 'success',
available: 'success',
synced: 'success',
valid: 'success',
pending: 'info',
idle: 'info',
offline: 'info',
disabled: 'info',
original: 'info',
stopped: 'info',
cancelled: 'info',
syncing: 'warning',
queued: 'warning',
starting: 'warning',
loading: 'warning',
reserved: 'warning',
warning: 'warning',
modified: 'warning',
draining: 'warning',
maintenance: 'warning',
repair_pending: 'warning',
failed: 'danger',
error: 'danger',
invalid: 'danger',
missing: 'danger',
drifted: 'danger',
}
export function statusLabel(status?: string | number | null) {
const key = String(status ?? '').trim()
if (!key) return '未知'
return STATUS_LABELS[key] || key
}
export function statusTagType(status?: string | number | null): TagType {
const key = String(status ?? '').trim()
return STATUS_TYPES[key] || 'info'
}
export function mergeStatusLabel(row: { merged?: boolean; merging?: boolean }) {
if (row.merging) return '合并中'
if (row.merged) return '已合并'
return '未合并'
}
export function mergeStatusType(row: { merged?: boolean; merging?: boolean }): TagType {
if (row.merging) return 'warning'
if (row.merged) return 'success'
return 'info'
}

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,23 +12,45 @@ import {
getComputeQueue,
getNodeReplicas,
testComputeNode,
updateComputeNode,
type ComputeGpu,
type ComputeNode,
type ComputeQueueItem,
type ResourceReplica,
} from '@/api/modules/compute'
import { statusLabel, statusTagType } from '@/utils/status'
const route = useRoute()
const router = useRouter()
const activeTab = ref(String(route.query.tab || 'nodes'))
const loading = ref(false)
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
const selectedNode = computed(() => nodes.value.find((item) => item.id === selectedNodeId.value))
@@ -34,6 +58,10 @@ const enabledNodes = computed(() => nodes.value.filter((item) => item.enabled).l
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))
function asComputeNode(row: unknown): ComputeNode {
return row as ComputeNode
}
watch(
() => route.query.tab,
(tab) => {
@@ -41,8 +69,9 @@ watch(
},
)
async function load() {
loading.value = true
async function load(options: { showLoading?: boolean; showButtonLoading?: boolean } = {}) {
if (options.showLoading) loading.value = true
if (options.showButtonLoading) buttonRefreshing.value = true
try {
const [nodeList, gpuList, queueList] = await Promise.all([
getComputeNodes(),
@@ -52,11 +81,14 @@ 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 (options.showLoading) loading.value = false
if (options.showButtonLoading) buttonRefreshing.value = false
}
}
@@ -72,37 +104,120 @@ 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) {
async function handleNodeAction(action: 'enable' | 'disable' | 'drain' | 'test', node: ComputeNode) {
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)
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 nodeStatusType(status: string) {
if (status === 'online') return 'success'
if (status === 'draining' || status === 'maintenance') return 'warning'
return 'info'
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 taskStatusType(status: string) {
if (status === 'running') return 'success'
if (status === 'syncing' || status === 'queued') return 'warning'
if (status === 'failed') return 'danger'
return 'info'
function openCreateNodeDialog() {
resetNodeForm()
nodeDialogMode.value = 'create'
nodeDialogVisible.value = true
}
function gpuStatusType(status: string) {
if (status === 'busy') return 'success'
if (status === 'reserved') return 'warning'
return 'info'
function openEditNodeDialog(node: ComputeNode) {
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 formatTime(value?: string) {
return value ? new Date(value).toLocaleString('zh-CN') : '-'
}
onMounted(() => {
load()
timer = setInterval(load, 5000)
load({ showLoading: true })
timer = setInterval(() => load(), 5000)
})
onUnmounted(() => {
@@ -119,27 +234,16 @@ 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>
<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 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">
@@ -149,7 +253,7 @@ onUnmounted(() => {
<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>
<el-tag :type="statusTagType(row.scheduler_status)">{{ statusLabel(row.scheduler_status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="启用" width="90">
@@ -174,12 +278,13 @@ 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="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>
<el-button size="small" @click="openEditNodeDialog(asComputeNode(row))">编辑</el-button>
<el-button size="small" @click="handleNodeAction('test', asComputeNode(row))">测试</el-button>
<el-button v-if="row.enabled" size="small" @click="handleNodeAction('disable', asComputeNode(row))"></el-button>
<el-button v-else size="small" type="primary" @click="handleNodeAction('enable', asComputeNode(row))">启用</el-button>
<el-button size="small" type="warning" plain @click="handleNodeAction('drain', asComputeNode(row))">维护</el-button>
</template>
</el-table-column>
</el-table>
@@ -195,13 +300,11 @@ onUnmounted(() => {
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="gpuStatusType(row.status)">{{ row.status }}</el-tag>
<el-tag :type="statusTagType(row.status)">{{ statusLabel(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>
<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 }">
@@ -228,19 +331,19 @@ onUnmounted(() => {
<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>
<el-tag :type="statusTagType(row.status)">{{ statusLabel(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>
<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-column label="创建时间" width="190">
<template #default="{ row }">{{ formatTime(row.create_time) }}</template>
</el-table-column>
</el-table>
</el-tab-pane>
@@ -254,13 +357,72 @@ onUnmounted(() => {
<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-column prop="local_path" label="本地路径" min-width="300" show-overflow-tooltip />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="同步状态" width="120">
<template #default="{ row }">
<el-tag :type="statusTagType(row.sync_status)" size="small">{{ statusLabel(row.sync_status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" width="190">
<template #default="{ row }">{{ formatTime(row.create_time) }}</template>
</el-table-column>
</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>
@@ -294,7 +456,8 @@ onUnmounted(() => {
}
}
.header-actions {
.header-actions,
.replica-toolbar {
display: flex;
align-items: center;
gap: 12px;
@@ -306,6 +469,11 @@ onUnmounted(() => {
font-size: 12px;
}
.last-updated {
min-width: 92px;
text-align: right;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -351,9 +519,6 @@ onUnmounted(() => {
}
.replica-toolbar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 12px;
.el-select {
@@ -361,6 +526,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 +548,9 @@ onUnmounted(() => {
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.node-form-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -246,7 +246,7 @@ const loginDurationChartOption = computed<EChartsOption>(() => ({
},
series: [
{
name: '距上次登录',
name: '登录时长',
type: 'bar',
data: loginDurationStats.value.map((user) => user.duration),
barMaxWidth: 18,

View File

@@ -35,6 +35,7 @@ async function loadData() {
async function handleDelete(row: any) {
await deleteDataset(row.id)
ElMessage.success('删除成功')
await loadData()
}
function handlePreview(row: any) {