feat(web): add task views, file manager routing and sidebar menu
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -6,18 +6,18 @@
|
||||
HPC 集群管理
|
||||
</div>
|
||||
<el-menu :router="true" :default-active="route.path">
|
||||
<el-menu-item index="/jobs">
|
||||
<el-icon><List /></el-icon>
|
||||
<span>任务列表</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/jobs/history">
|
||||
<el-icon><Timer /></el-icon>
|
||||
<span>任务历史</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/jobs/submit">
|
||||
<el-menu-item index="/tasks/create">
|
||||
<el-icon><CirclePlus /></el-icon>
|
||||
<span>提交任务</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/tasks">
|
||||
<el-icon><List /></el-icon>
|
||||
<span>任务管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/files">
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
<span>文件管理</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-main>
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import { List, Timer, CirclePlus } from '@element-plus/icons-vue'
|
||||
import { List, CirclePlus, FolderOpened } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const router = createRouter({
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/jobs',
|
||||
redirect: '/tasks',
|
||||
},
|
||||
{
|
||||
path: '/jobs',
|
||||
@@ -19,21 +19,33 @@ const router = createRouter({
|
||||
component: () => import('@/views/Jobs/History.vue'),
|
||||
meta: { title: '任务历史' },
|
||||
},
|
||||
{
|
||||
path: '/jobs/submit',
|
||||
name: 'JobsSubmit',
|
||||
component: () => import('@/views/Jobs/Submit.vue'),
|
||||
meta: { title: '提交任务' },
|
||||
},
|
||||
{
|
||||
path: '/jobs/:id',
|
||||
name: 'JobsDetail',
|
||||
component: () => import('@/views/Jobs/Detail.vue'),
|
||||
meta: { title: '任务详情' },
|
||||
},
|
||||
{
|
||||
path: '/tasks',
|
||||
name: 'TasksList',
|
||||
component: () => import('@/views/Tasks/List.vue'),
|
||||
meta: { title: '任务列表' },
|
||||
},
|
||||
{
|
||||
path: '/tasks/create',
|
||||
name: 'TasksCreate',
|
||||
component: () => import('@/views/Tasks/Submit.vue'),
|
||||
meta: { title: '提交任务' },
|
||||
},
|
||||
{
|
||||
path: '/files',
|
||||
name: 'FilesList',
|
||||
component: () => import('@/views/Files/List.vue'),
|
||||
meta: { title: '文件管理' },
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/jobs',
|
||||
redirect: '/tasks',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
127
web/src/views/Tasks/List.vue
Normal file
127
web/src/views/Tasks/List.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div class="list-container">
|
||||
<h2>任务列表</h2>
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="statusFilter" placeholder="状态筛选" clearable style="width: 200px">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="已提交" value="submitted" />
|
||||
<el-option label="准备中" value="preparing" />
|
||||
<el-option label="下载中" value="downloading" />
|
||||
<el-option label="就绪" value="ready" />
|
||||
<el-option label="排队中" value="queued" />
|
||||
<el-option label="运行中" value="running" />
|
||||
<el-option label="已完成" value="completed" />
|
||||
<el-option label="失败" value="failed" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<el-table :data="tasks" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="task_name" label="任务名称" min-width="160" />
|
||||
<el-table-column prop="app_name" label="应用" width="120" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getTaskStatusType(row.status)">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="partition" label="分区" width="100" />
|
||||
<el-table-column prop="cpus" label="CPU" width="80" />
|
||||
<el-table-column prop="slurm_job_id" label="Slurm Job ID" width="120" />
|
||||
<el-table-column label="创建时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ new Date(row.created_at).toLocaleString('zh-CN') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskResponse } from '@/types/tasks'
|
||||
import { listTasks } from '@/api/tasks'
|
||||
|
||||
const loading = ref(false)
|
||||
const tasks = ref<TaskResponse[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const statusFilter = ref('')
|
||||
|
||||
const getTaskStatusType = (status: string | undefined): 'success' | 'warning' | 'info' | 'danger' | undefined => {
|
||||
switch (status) {
|
||||
case 'submitted': return 'info'
|
||||
case 'preparing': return 'info'
|
||||
case 'downloading': return 'warning'
|
||||
case 'ready': return undefined
|
||||
case 'queued': return 'warning'
|
||||
case 'running': return 'success'
|
||||
case 'completed': return 'success'
|
||||
case 'failed': return 'danger'
|
||||
default: return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTasks = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await listTasks({
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
status: statusFilter.value || undefined
|
||||
})
|
||||
tasks.value = resp.data?.items || []
|
||||
total.value = resp.data?.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(statusFilter, () => {
|
||||
currentPage.value = 1
|
||||
fetchTasks()
|
||||
})
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchTasks()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
fetchTasks()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchTasks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.list-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
167
web/src/views/Tasks/Submit.vue
Normal file
167
web/src/views/Tasks/Submit.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div class="submit-container">
|
||||
<div class="page-header">
|
||||
<h2>提交任务</h2>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
<el-form label-width="120px">
|
||||
<el-form-item label="选择应用">
|
||||
<el-select v-model="selectedAppId" placeholder="请选择应用" clearable>
|
||||
<el-option v-for="app in appList" :key="app.id" :label="app.name" :value="app.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="任务名称">
|
||||
<el-input v-model="form.task_name" placeholder="可选" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="关联文件">
|
||||
<el-button @click="showFilePicker = true">选择文件</el-button>
|
||||
<div v-if="selectedFiles.length" style="margin-top: 8px">
|
||||
<el-tag
|
||||
v-for="f in selectedFiles"
|
||||
:key="f.id"
|
||||
closable
|
||||
@close="selectedFiles = selectedFiles.filter(x => x.id !== f.id)"
|
||||
style="margin: 2px"
|
||||
>
|
||||
{{ f.name }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<FilePicker v-model="showFilePicker" @select="selectedFiles = $event" />
|
||||
|
||||
<template v-if="selectedApp">
|
||||
<el-form-item
|
||||
v-for="param in (selectedApp.parameters || [])"
|
||||
:key="param.name"
|
||||
:label="param.label || param.name"
|
||||
>
|
||||
<el-input v-if="param.type === 'string'" v-model="values[param.name]" />
|
||||
|
||||
<el-input-number v-else-if="param.type === 'integer'"
|
||||
:model-value="values[param.name] ? Number(values[param.name]) : undefined"
|
||||
@update:model-value="(val: number | undefined) => {
|
||||
if (val != null) values[param.name] = String(val)
|
||||
else delete values[param.name]
|
||||
}"
|
||||
/>
|
||||
|
||||
<el-select v-else-if="param.type === 'enum'" v-model="values[param.name]">
|
||||
<el-option v-for="opt in param.options" :key="opt" :label="opt" :value="opt" />
|
||||
</el-select>
|
||||
|
||||
<el-switch v-else-if="param.type === 'boolean'"
|
||||
:model-value="values[param.name] === 'true'"
|
||||
@update:model-value="(val: string | number | boolean) => { values[param.name] = String(val) }"
|
||||
/>
|
||||
|
||||
<el-input v-else-if="param.type === 'file' || param.type === 'directory'" disabled placeholder="文件选择功能开发中" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-divider>调度参数</el-divider>
|
||||
|
||||
<el-form-item label="CPU 数量">
|
||||
<el-input-number v-model="form.cpus" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内存 (MB)">
|
||||
<el-input-number v-model="form.memory_per_node" placeholder="MB" />
|
||||
</el-form-item>
|
||||
<el-form-item label="节点数">
|
||||
<el-input v-model="form.nodes" placeholder="如: 2 或 2-4" />
|
||||
</el-form-item>
|
||||
<el-form-item label="任务数">
|
||||
<el-input-number v-model="form.tasks" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="每任务 CPU 数">
|
||||
<el-input-number v-model="form.cpus_per_task" :min="1" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">提交</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { Application } from '@/types/tasks'
|
||||
import { createTask, getApplications } from '@/api/tasks'
|
||||
import FilePicker from '@/views/Files/FilePicker.vue'
|
||||
import type { FileResponse } from '@/types/files'
|
||||
|
||||
const router = useRouter()
|
||||
const selectedAppId = ref<number | undefined>(undefined)
|
||||
const appList = ref<Application[]>([])
|
||||
const values = ref<Record<string, string>>({})
|
||||
const submitting = ref(false)
|
||||
const form = reactive({
|
||||
task_name: '',
|
||||
partition: 'normal',
|
||||
cpus: undefined as number | undefined,
|
||||
memory_per_node: undefined as number | undefined,
|
||||
nodes: '',
|
||||
tasks: undefined as number | undefined,
|
||||
cpus_per_task: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const selectedFiles = ref<FileResponse[]>([])
|
||||
const showFilePicker = ref(false)
|
||||
|
||||
const selectedApp = computed(() => appList.value.find(a => a.id === selectedAppId.value))
|
||||
|
||||
watch(selectedAppId, () => { values.value = {} })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const resp = await getApplications({ page_size: 100 })
|
||||
appList.value = resp.data?.applications || []
|
||||
} catch {
|
||||
// Error already handled by Axios interceptor
|
||||
}
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedAppId.value) { ElMessage.warning('请选择应用'); return }
|
||||
submitting.value = true
|
||||
try {
|
||||
const taskName = form.task_name.trim() || `task_${selectedAppId.value}_${Date.now()}`
|
||||
const resp = await createTask({ ...form, task_name: taskName, job_name: taskName, app_id: selectedAppId.value, values: values.value, file_ids: selectedFiles.value.map(f => f.id) })
|
||||
if (resp.success) {
|
||||
ElMessage.success('任务提交成功')
|
||||
router.push('/tasks')
|
||||
} else {
|
||||
ElMessage.error(resp.error || '提交失败')
|
||||
}
|
||||
} catch {
|
||||
// Error already handled by Axios interceptor
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
selectedAppId.value = undefined
|
||||
form.task_name = ''
|
||||
form.partition = 'normal'
|
||||
form.cpus = undefined
|
||||
form.memory_per_node = undefined
|
||||
form.nodes = ''
|
||||
form.tasks = undefined
|
||||
form.cpus_per_task = undefined
|
||||
values.value = {}
|
||||
selectedFiles.value = []
|
||||
showFilePicker.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.submit-container {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user