325 lines
12 KiB
Vue
325 lines
12 KiB
Vue
<script setup lang="ts">
|
||
import { NButton, NSpin, useDialog } from 'naive-ui'
|
||
import { computed, onUnmounted, ref } from 'vue'
|
||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||
|
||
import { deleteTask, getTask, listTaskLogs, runTask, updateTask } from '@/api/tasks'
|
||
import EmptyCard from '@/components/EmptyCard.vue'
|
||
import StatusBadge from '@/components/StatusBadge.vue'
|
||
import TaskFormModal from '@/components/task/TaskFormModal.vue'
|
||
import TaskTypeIcon from '@/components/task/TaskTypeIcon.vue'
|
||
import { parseSnatch, scopeCfgIds, STATUS_META, TYPE_LABEL } from '@/components/task/taskDisplay'
|
||
import { fmtRelative, fmtTime, regionCity } from '@/composables/useFormat'
|
||
import { useAsync } from '@/composables/useAsync'
|
||
import { useScopeStore } from '@/stores/scope'
|
||
import { useToast } from '@/composables/useToast'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const message = useToast()
|
||
const dialog = useDialog()
|
||
const scope = useScopeStore()
|
||
|
||
const taskId = computed(() => Number(route.params.taskId))
|
||
|
||
const task = useAsync(() => getTask(taskId.value))
|
||
const logs = useAsync(() => listTaskLogs(taskId.value))
|
||
|
||
// 每 5 秒静默刷新任务与事件流
|
||
const refreshTimer = setInterval(() => {
|
||
void task.run({ silent: true })
|
||
void logs.run({ silent: true })
|
||
}, 5000)
|
||
onUnmounted(() => clearInterval(refreshTimer))
|
||
|
||
const snatch = computed(() => (task.data.value ? parseSnatch(task.data.value) : null))
|
||
const status = computed(() => (task.data.value ? STATUS_META[task.data.value.status] : null))
|
||
const cfgAlias = computed(() => {
|
||
const s = snatch.value
|
||
if (!s) return ''
|
||
return (scope.configs.data ?? []).find((c) => c.id === s.cfgId)?.alias ?? `租户 #${s.cfgId}`
|
||
})
|
||
|
||
const pct = computed(() => {
|
||
const s = snatch.value
|
||
if (!s?.total) return 0
|
||
return Math.min(100, Math.round((s.done / s.total) * 100))
|
||
})
|
||
|
||
/** 右栏任务参数键值对(按类型组装) */
|
||
const params = computed<[string, string][]>(() => {
|
||
const t = task.data.value
|
||
if (!t) return []
|
||
const s = snatch.value
|
||
if (s) {
|
||
return [
|
||
['所属租户', cfgAlias.value],
|
||
['目标 Shape', s.shape],
|
||
['规格', s.ocpus ? `${s.ocpus} OCPU · ${s.memoryInGBs} GB` : '—'],
|
||
['引导卷', s.bootGb ? `${s.bootGb} GB` : '默认'],
|
||
['实例名', s.displayName ?? '—'],
|
||
['目标数量', `${s.total} 台`],
|
||
['剩余数量', `${s.remaining} 台`],
|
||
['调度周期', t.cronExpr],
|
||
['区域', s.region ? regionCity(s.region) : '默认'],
|
||
]
|
||
}
|
||
const ids = scopeCfgIds(t)
|
||
const scope = t.type === 'ai_probe'
|
||
? '号池渠道对应租户'
|
||
: ids.length ? `${ids.length} 个租户` : '全部租户'
|
||
return [
|
||
['类型', TYPE_LABEL[t.type]],
|
||
['范围', scope],
|
||
['调度周期', t.cronExpr],
|
||
['已运行', `${t.runCount} 次`],
|
||
]
|
||
})
|
||
|
||
const showEdit = ref(false)
|
||
|
||
async function act(fn: () => Promise<unknown>, ok: string) {
|
||
try {
|
||
await fn()
|
||
message.success(ok)
|
||
void task.run({ silent: true })
|
||
void logs.run({ silent: true })
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '操作失败')
|
||
}
|
||
}
|
||
|
||
/** active ↔ paused 切换;failed(熔断停止)同走恢复交互重新启用 */
|
||
function toggle() {
|
||
const t = task.data.value
|
||
if (!t) return
|
||
const resume = t.status !== 'active'
|
||
void act(
|
||
() => updateTask(t.id, { status: resume ? 'active' : 'paused' }),
|
||
resume ? (t.status === 'failed' ? '已重新启用' : '已恢复调度') : '已暂停',
|
||
)
|
||
}
|
||
|
||
function confirmRemove() {
|
||
const t = task.data.value
|
||
if (!t) return
|
||
dialog.warning({
|
||
title: '删除任务',
|
||
content: `删除任务「${t.name}」及其全部日志?`,
|
||
positiveText: '删除',
|
||
negativeText: '取消',
|
||
onPositiveClick: async () => {
|
||
await deleteTask(t.id)
|
||
message.success('已删除任务及其日志')
|
||
void router.push({ name: 'tasks' })
|
||
},
|
||
})
|
||
}
|
||
|
||
const features = computed(() => {
|
||
const base = [
|
||
['持久化存储', '任务参数与进度写入数据库,不依赖内存状态'],
|
||
['重启自动恢复', '服务重启后自动加载未完成任务并继续调度'],
|
||
]
|
||
if (task.data.value?.type === 'snatch')
|
||
base.push(['抢满自动停止', '达到目标数量后任务自动标记完成,不再调度'])
|
||
else base.push(['日志滚动保留', '每个任务保留最近 100 条执行日志'])
|
||
return base
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
|
||
<RouterLink
|
||
:to="{ name: 'tasks' }"
|
||
class="flex w-fit items-center gap-1.5 text-[13px] text-ink-3 transition-colors hover:text-ink"
|
||
>
|
||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M15 6l-6 6 6 6" />
|
||
</svg>
|
||
返回任务列表
|
||
</RouterLink>
|
||
|
||
<div
|
||
v-if="task.loading.value && !task.data.value"
|
||
class="panel flex items-center justify-center py-20"
|
||
>
|
||
<NSpin size="small" />
|
||
</div>
|
||
<div v-else-if="task.error.value" class="panel px-5 py-10 text-center text-[13px] text-ink-3">
|
||
{{ task.error.value }}
|
||
</div>
|
||
<div
|
||
v-else-if="task.data.value"
|
||
class="grid grid-cols-[minmax(0,1fr)_360px] items-start gap-4 max-lg:grid-cols-1"
|
||
>
|
||
<div class="flex min-w-0 flex-col gap-4">
|
||
<!-- 头部:名称 + 操作 + 进度 -->
|
||
<div class="panel px-6 py-5">
|
||
<div class="flex flex-wrap items-start gap-3.5">
|
||
<div class="flex h-11 w-11 flex-none items-center justify-center rounded-[10px] bg-wash">
|
||
<TaskTypeIcon :type="task.data.value.type" class="h-[22px] w-[22px] text-accent" />
|
||
</div>
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex flex-wrap items-center gap-2.5">
|
||
<span class="page-title !text-xl">{{ task.data.value.name }}</span>
|
||
<StatusBadge v-if="status" :kind="status.kind" :label="status.label" />
|
||
</div>
|
||
<div class="mono mt-1 text-xs text-ink-3">
|
||
{{ TYPE_LABEL[task.data.value.type] }} · {{ task.data.value.cronExpr }} · 创建于
|
||
{{ fmtTime(task.data.value.createdAt) }}
|
||
</div>
|
||
</div>
|
||
<div class="flex flex-none items-center gap-2">
|
||
<NButton
|
||
size="small"
|
||
@click="act(() => runTask(task.data.value!.id), '已触发执行')"
|
||
>
|
||
立即执行
|
||
</NButton>
|
||
<NButton
|
||
v-if="task.data.value.status !== 'succeeded'"
|
||
size="small"
|
||
@click="toggle"
|
||
>
|
||
{{ task.data.value.status === 'active' ? '暂停' : task.data.value.status === 'failed' ? '启用' : '恢复' }}
|
||
</NButton>
|
||
<NButton v-if="task.data.value.type !== 'ai_probe'" size="small" @click="showEdit = true">编辑</NButton>
|
||
<NButton v-if="task.data.value.type !== 'ai_probe'" size="small" type="error" quaternary @click="confirmRemove">删除</NButton>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="snatch" class="mt-5">
|
||
<div class="mb-2 flex items-center justify-between">
|
||
<span class="text-[13px] text-ink-3">创建进度</span>
|
||
<span class="mono text-[15px]">
|
||
<b
|
||
class="text-lg"
|
||
:style="{
|
||
color:
|
||
task.data.value.status === 'succeeded'
|
||
? 'var(--color-ok)'
|
||
: 'var(--color-accent)',
|
||
}"
|
||
>{{ snatch.done }}</b>
|
||
/ {{ snatch.total }} 台
|
||
</span>
|
||
</div>
|
||
<div class="h-2 overflow-hidden rounded-full bg-line-soft">
|
||
<div
|
||
class="h-full rounded-full transition-all"
|
||
:style="{
|
||
width: pct + '%',
|
||
background:
|
||
task.data.value.status === 'succeeded'
|
||
? 'var(--color-ok)'
|
||
: 'var(--color-accent)',
|
||
}"
|
||
/>
|
||
</div>
|
||
<div class="mt-2 text-xs text-ink-3">
|
||
已尝试 {{ task.data.value.runCount }} 次 · 上次运行
|
||
{{ fmtRelative(task.data.value.lastRunAt) }}
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-if="task.data.value.lastError"
|
||
class="mt-4 rounded-md border border-err/30 bg-err/10 px-3 py-2 text-[13px] break-all text-err"
|
||
>
|
||
{{ task.data.value.lastError }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 事件流 -->
|
||
<div class="panel">
|
||
<div class="flex items-center justify-between border-b border-line-soft px-5 py-3.5">
|
||
<div class="text-sm font-semibold">事件流</div>
|
||
<div class="text-xs text-ink-3">最新事件置顶 · 保留近 100 条</div>
|
||
</div>
|
||
<div class="max-h-[560px] overflow-y-auto px-5.5 pt-4 pb-3">
|
||
<div
|
||
v-for="(log, i) in logs.data.value ?? []"
|
||
:key="log.id"
|
||
class="relative flex gap-3 pb-5 last:pb-1"
|
||
>
|
||
<div
|
||
v-if="i < (logs.data.value?.length ?? 0) - 1"
|
||
class="absolute top-4 bottom-0 left-[5px] w-px bg-line-soft"
|
||
/>
|
||
<div
|
||
class="mt-1 h-[11px] w-[11px] flex-none rounded-full border-[2.5px]"
|
||
:style="{
|
||
borderColor: log.success ? 'var(--color-ok)' : 'var(--color-err)',
|
||
background: `color-mix(in srgb, ${log.success ? 'var(--color-ok)' : 'var(--color-err)'} 22%, var(--color-card))`,
|
||
}"
|
||
/>
|
||
<div class="min-w-0">
|
||
<div class="flex items-center gap-2">
|
||
<span class="mono text-[11.5px] text-ink-3">{{ fmtTime(log.createdAt) }}</span>
|
||
<span
|
||
class="rounded px-1.5 text-[10.5px] font-semibold tracking-wide"
|
||
:style="{
|
||
color: log.success ? 'var(--color-ok)' : 'var(--color-err)',
|
||
background: `color-mix(in srgb, ${log.success ? 'var(--color-ok)' : 'var(--color-err)'} 10%, transparent)`,
|
||
}"
|
||
>
|
||
{{ log.success ? 'SUCCESS' : 'FAILED' }}
|
||
</span>
|
||
<span class="mono text-[11.5px] text-ink-3">{{ log.durationMs }}ms</span>
|
||
</div>
|
||
<div class="mono mt-0.5 text-[13px] break-all">{{ log.message }}</div>
|
||
</div>
|
||
</div>
|
||
<EmptyCard
|
||
v-if="logs.data.value && !logs.data.value.length"
|
||
title="暂无事件"
|
||
note="任务执行后事件流会出现在这里"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右栏 -->
|
||
<div class="flex flex-col gap-4">
|
||
<div class="panel">
|
||
<div class="border-b border-line-soft px-5 py-3.5 text-sm font-semibold">任务参数</div>
|
||
<div class="px-5 pt-1 pb-2.5">
|
||
<div
|
||
v-for="[k, v] in params"
|
||
:key="k"
|
||
class="flex items-baseline justify-between gap-3 border-b border-line-soft py-2.5 last:border-b-0"
|
||
>
|
||
<span class="flex-none text-[13px] text-ink-3">{{ k }}</span>
|
||
<span class="mono min-w-0 truncate text-[13px]" :title="v">{{ v }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="panel">
|
||
<div class="border-b border-line-soft px-5 py-3.5 text-sm font-semibold">运维特性</div>
|
||
<div class="px-5 py-2">
|
||
<div v-for="[t, d] in features" :key="t" class="flex gap-2.5 py-2.5">
|
||
<svg
|
||
class="mt-0.5 h-[15px] w-[15px] flex-none text-ok"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2.2"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
>
|
||
<path d="M4 12.5l5 5L20 6.5" />
|
||
</svg>
|
||
<div>
|
||
<div class="text-[13px] font-semibold">{{ t }}</div>
|
||
<div class="mt-0.5 text-xs text-ink-3">{{ d }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<TaskFormModal v-model:show="showEdit" :task="task.data.value" @created="task.run()" />
|
||
</div>
|
||
</template>
|