Files
oci-portal-dash/src/views/TaskDetailView.vue
T
Wang Defa 5464d2dfea
CI / test (push) Successful in 27s
Release / release (push) Successful in 26s
发布 0.1.0:通知渠道、告警规则与多项体验修复
2026-07-10 17:31:19 +08:00

325 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>