fix(tasks): fix task cancellation process tree leak, emit cancelled events, and refine status badges styling

This commit is contained in:
duorameng
2026-06-29 22:34:08 +08:00
parent e5fc716b8f
commit b52af6c4fd
10 changed files with 132 additions and 73 deletions
+6 -5
View File
@@ -81,11 +81,12 @@ const (
EventPasswordChanged = "password_changed"
// 任务事件类型
EventTaskSuccess = "task_success"
EventTaskFailed = "task_failed"
EventTaskTimeout = "task_timeout"
EventTaskRunning = "task_running"
EventTaskQueued = "task_queued"
EventTaskSuccess = "task_success"
EventTaskFailed = "task_failed"
EventTaskTimeout = "task_timeout"
EventTaskRunning = "task_running"
EventTaskQueued = "task_queued"
EventTaskCancelled = "task_cancelled"
// 其他事件类型
EventSystemNotice = "system_notice"
+3
View File
@@ -161,6 +161,9 @@ func ExecuteWithHooks(ctx context.Context, req Request, stdout, stderr io.Writer
shell, args := utils.GetShellCommand(req.Command)
cmd := exec.CommandContext(execCtx, shell, args...)
usePty := runtime.GOOS != "windows" && stdout != nil && (stdout == stderr || stdout == io.Discard)
SetProcessGroupAndCancel(cmd, usePty)
// 设置工作目录
// 设置工作目录
workDir := strings.TrimSpace(req.WorkDir)
+21
View File
@@ -0,0 +1,21 @@
//go:build !windows
package executor
import (
"os/exec"
"syscall"
)
func SetProcessGroupAndCancel(cmd *exec.Cmd, usePty bool) {
if !usePty {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
cmd.Cancel = func() error {
if cmd.Process != nil {
// Kill the entire process group by sending SIGKILL to negative PID
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
}
+18
View File
@@ -0,0 +1,18 @@
//go:build windows
package executor
import (
"fmt"
"os/exec"
)
func SetProcessGroupAndCancel(cmd *exec.Cmd, usePty bool) {
cmd.Cancel = func() error {
if cmd.Process != nil {
killCmd := exec.Command("taskkill", "/F", "/T", "/PID", fmt.Sprintf("%d", cmd.Process.Pid))
return killCmd.Run()
}
return nil
}
}
+1
View File
@@ -100,6 +100,7 @@ func (m *SystemWSManager) SubscribeEvents(bus *eventbus.EventBus) {
constant.EventTaskTimeout,
constant.EventTaskRunning,
constant.EventTaskQueued,
constant.EventTaskCancelled,
}
for _, evt := range taskEvents {
@@ -304,6 +304,8 @@ func (h *ServerSchedulerHandler) OnTaskCompleted(req *executor.ExecutionRequest,
eventType = constant.EventTaskFailed
case constant.TaskStatusTimeout:
eventType = constant.EventTaskTimeout
case constant.TaskStatusCancelled:
eventType = constant.EventTaskCancelled
}
if eventType != "" {
eventbus.DefaultBus.Publish(eventbus.Event{
+24 -22
View File
@@ -96,19 +96,19 @@ watch(() => props.log?.duration, (newVal) => {
function getStatusBadgeClass(status: string) {
switch (status) {
case TASK_STATUS.SUCCESS:
return 'bg-green-500/10 text-green-600 border-green-500/20 dark:bg-green-500/20 dark:text-green-400 dark:border-green-500/30 shadow-[0_0_8px_-2px_rgba(34,197,94,0.15)]'
return 'bg-emerald-500/5 text-emerald-500 border-emerald-500/15 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20'
case TASK_STATUS.FAILED:
return 'bg-red-500/10 text-red-600 border-red-500/20 dark:bg-red-500/20 dark:text-red-400 dark:border-red-500/30'
return 'bg-rose-500/5 text-rose-500 border-rose-500/15 dark:bg-rose-500/10 dark:text-rose-400 dark:border-rose-500/20'
case TASK_STATUS.RUNNING:
return 'bg-blue-500/10 text-blue-600 border-blue-500/20 dark:bg-blue-500/20 dark:text-blue-400 dark:border-blue-500/30'
return 'bg-sky-500/5 text-sky-500 border-sky-500/15 dark:bg-sky-500/10 dark:text-sky-400 dark:border-sky-500/20'
case TASK_STATUS.PENDING:
return 'bg-amber-500/10 text-amber-600 border-amber-500/20 dark:bg-amber-500/20 dark:text-amber-400 dark:border-amber-500/30'
return 'bg-amber-500/5 text-amber-500 border-amber-500/15 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20'
case TASK_STATUS.TIMEOUT:
return 'bg-orange-500/10 text-orange-600 border-orange-500/20 dark:bg-orange-500/20 dark:text-orange-400 dark:border-orange-500/30'
return 'bg-orange-500/5 text-orange-500 border-orange-500/15 dark:bg-orange-500/10 dark:text-orange-400 dark:border-orange-500/20'
case TASK_STATUS.CANCELLED:
return 'bg-muted/50 text-muted-foreground border-muted-foreground/10'
return 'bg-zinc-500/5 text-zinc-400 border-zinc-500/15 dark:bg-zinc-500/10 dark:text-zinc-400 dark:border-zinc-500/20'
case 'UNEXECUTED':
return 'bg-muted/30 text-muted-foreground/60 border-dashed border-muted-foreground/20 shadow-none'
return 'bg-zinc-500/5 text-zinc-400 border-zinc-500/15 border-dashed dark:bg-zinc-500/10 dark:text-zinc-400 dark:border-zinc-500/20'
default:
return 'bg-secondary text-secondary-foreground border-transparent'
}
@@ -135,7 +135,7 @@ function getStatusBadgeClass(status: string) {
<!-- 详情模式下的停止按钮 (始终在第一行进入) -->
<Button v-if="log.status === TASK_STATUS.RUNNING && variant === 'full'" variant="destructive" size="sm"
class="h-6 px-2 text-[10px] shrink-0" :disabled="isStopping" @click="$emit('stop')">
class="h-5 px-1.5 text-[9px] shrink-0 rounded-sm" :disabled="isStopping" @click="$emit('stop')">
{{ isStopping ? '停止中' : '停止任务' }}
</Button>
</div>
@@ -143,7 +143,7 @@ function getStatusBadgeClass(status: string) {
<div class="flex items-center gap-1.5 shrink-0">
<!-- 极简模式桌面端显示的停止按钮 -->
<Button v-if="log.status === TASK_STATUS.RUNNING && variant === 'simple'" variant="destructive" size="sm"
class="h-7 px-2 text-[10px] hidden sm:flex" :disabled="isStopping" @click="$emit('stop')">
class="h-5 px-1.5 text-[9px] rounded-sm hidden sm:flex" :disabled="isStopping" @click="$emit('stop')">
{{ isStopping ? '停止中' : '停止任务' }}
</Button>
@@ -171,8 +171,8 @@ function getStatusBadgeClass(status: string) {
<!-- 极简模式移动端停止按钮 -->
<Button v-if="log.status === TASK_STATUS.RUNNING" variant="destructive" size="sm"
class="h-8 px-3 text-xs gap-1.5 flex-1 max-w-[120px]" :disabled="isStopping" @click="$emit('stop')">
<Ban class="h-3.5 w-3.5" />
class="h-6 px-2 text-[10px] gap-1 flex-1 max-w-[100px] rounded-sm" :disabled="isStopping" @click="$emit('stop')">
<Ban class="h-3 w-3" />
{{ isStopping ? '停止中' : '停止' }}
</Button>
@@ -202,19 +202,21 @@ function getStatusBadgeClass(status: string) {
<div class="flex justify-between items-center h-8">
<span class="text-sm font-normal text-muted-foreground">状态</span>
<Badge variant="outline" :class="[
'capitalize px-3 py-1 font-normal rounded-full border shadow-sm transition-all duration-300 ring-4 ring-transparent hover:ring-primary/5',
'capitalize px-2.5 py-0.5 font-normal rounded-full border transition-all duration-300',
getStatusBadgeClass(log.status)
]">
<div class="flex items-center gap-1.5">
<CheckCircle2 v-if="log.status === TASK_STATUS.SUCCESS" class="h-3.5 w-3.5 fill-green-500/20" />
<XCircle v-else-if="log.status === TASK_STATUS.FAILED" class="h-3.5 w-3.5 fill-red-500/20" />
<ZapIcon v-else-if="log.status === TASK_STATUS.RUNNING"
class="h-3.5 w-3.5 fill-current animate-pulse text-blue-500" />
<Clock v-else-if="log.status === TASK_STATUS.PENDING" class="h-3.5 w-3.5 fill-amber-500/20" />
<AlertCircle v-else-if="log.status === TASK_STATUS.TIMEOUT" class="h-3.5 w-3.5 fill-orange-500/20" />
<Ban v-else-if="log.status === TASK_STATUS.CANCELLED" class="h-3.5 w-3.5" />
<MinusCircle v-else-if="log.status === 'UNEXECUTED'" class="h-3.5 w-3.5 opacity-40" />
<span class="text-[10px] font-normal">
<div class="flex items-center gap-1">
<CheckCircle2 v-if="log.status === TASK_STATUS.SUCCESS" class="h-3 w-3 fill-emerald-500/10" />
<XCircle v-else-if="log.status === TASK_STATUS.FAILED" class="h-3 w-3 fill-rose-500/10" />
<span v-else-if="log.status === TASK_STATUS.RUNNING" class="relative flex h-1.5 w-1.5 mr-0.5">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-1.5 w-1.5 bg-sky-500"></span>
</span>
<Clock v-else-if="log.status === TASK_STATUS.PENDING" class="h-3 w-3 fill-amber-500/10" />
<AlertCircle v-else-if="log.status === TASK_STATUS.TIMEOUT" class="h-3 w-3 fill-orange-500/10" />
<Ban v-else-if="log.status === TASK_STATUS.CANCELLED" class="h-3 w-3" />
<MinusCircle v-else-if="log.status === 'UNEXECUTED'" class="h-3 w-3 opacity-40" />
<span class="text-[10px] font-normal tracking-wide">
{{ TASK_STATUS_TEXT[log.status] || log.status }}
</span>
</div>
+1
View File
@@ -71,6 +71,7 @@ export const TASK_EVENTS = {
TIMEOUT: 'task_timeout',
RUNNING: 'task_running',
QUEUED: 'task_queued',
CANCELLED: 'task_cancelled',
} as const
// 日志事件类型
+29 -30
View File
@@ -54,10 +54,16 @@ useEventBus(Object.values(TASK_EVENTS), (payload, type) => {
if (duration !== undefined) selectedLog.value.duration = duration
if (end_time !== undefined) selectedLog.value.end_time = end_time
// 如果任务完成了,停止详情页的轮询定时器
if (status !== TASK_STATUS.RUNNING && durationTimer) {
clearInterval(durationTimer)
durationTimer = null
// 如果任务完成了,停止详情页的轮询定时器并断开 SSE
if (status !== TASK_STATUS.RUNNING) {
if (durationTimer) {
clearInterval(durationTimer)
durationTimer = null
}
if (logSource) {
logSource.close()
logSource = null
}
}
}
} else if (type === TASK_EVENTS.RUNNING) {
@@ -149,36 +155,29 @@ async function selectLog(log: TaskLog) {
selectedLog.value = log
// 如果是运行中状态,启动定时器轮询最新日志信息(主要是更新耗时)
// 如果是运行中状态,启动定时器本地更新耗时,状态变更依靠 EventBus
if (log.status === TASK_STATUS.RUNNING) {
const updateLog = async () => {
try {
const res = await api.logs.get(log.id)
if (res && selectedLog.value && selectedLog.value.id === log.id) {
// 只更新需要变动的字段
selectedLog.value.duration = res.duration
// 同步更新列表中的数据
const listItem = logs.value.find(l => l.id === log.id)
if (listItem) {
listItem.duration = res.duration
}
// 如果状态变了,更新状态并停止轮询
if (res.status !== TASK_STATUS.RUNNING) {
selectedLog.value.status = res.status
selectedLog.value.end_time = res.end_time
if (listItem) {
listItem.status = res.status
listItem.end_time = res.end_time
}
if (durationTimer) {
clearInterval(durationTimer)
durationTimer = null
}
const updateLog = () => {
if (selectedLog.value && selectedLog.value.id === log.id && selectedLog.value.status === TASK_STATUS.RUNNING) {
if (selectedLog.value.start_time) {
const startMs = new Date(selectedLog.value.start_time).getTime()
if (!isNaN(startMs)) {
selectedLog.value.duration = Date.now() - startMs
} else {
selectedLog.value.duration += 1000
}
} else {
selectedLog.value.duration += 1000
}
} catch { /* ignore */ }
// 同步更新列表中的数据
const listItem = logs.value.find(l => l.id === log.id)
if (listItem) {
listItem.duration = selectedLog.value.duration
}
}
}
durationTimer = setInterval(updateLog, 3000)
durationTimer = setInterval(updateLog, 1000)
}
wsContent.value = ''
+27 -16
View File
@@ -376,25 +376,23 @@ async function viewLogs(taskId: string) {
cleanupLogSocket()
}
// 启动状态轮询
// 优化:本地定时更新耗时,不再发请求轮询状态,状态变更依赖 EventBus 推送
cleanupDurationTimer()
const updateLogStatus = async () => {
try {
const res = await api.logs.get(latestLog.id)
if (res && selectedLog.value && selectedLog.value.id === latestLog.id) {
selectedLog.value.duration = res.duration
selectedLog.value.status = res.status
selectedLog.value.end_time = res.end_time
// 如果任务结束,停止轮询
if (res.status !== TASK_STATUS.RUNNING) {
cleanupDurationTimer()
loadTasks() // 同时刷新列表状态
const updateLogStatus = () => {
if (selectedLog.value && selectedLog.value.status === TASK_STATUS.RUNNING) {
if (selectedLog.value.start_time && selectedLog.value.start_time !== '-') {
const startMs = new Date(selectedLog.value.start_time).getTime()
if (!isNaN(startMs)) {
selectedLog.value.duration = Date.now() - startMs
} else {
selectedLog.value.duration += 1000
}
} else {
selectedLog.value.duration += 1000
}
} catch { /* ignore */ }
}
}
durationTimer = setInterval(updateLogStatus, 3000)
durationTimer = setInterval(updateLogStatus, 1000)
} else {
// 如果没有日志,构造一个基础的任务信息对象用于展示弹窗
const task = tasks.value.find(t => t.id === taskId)
@@ -507,11 +505,24 @@ onMounted(async () => {
})
// 订阅任务状态实时更新
useEventBus(['task_running', 'task_queued', 'task_success', 'task_failed', 'task_timeout'], (payload) => {
useEventBus(['task_running', 'task_queued', 'task_success', 'task_failed', 'task_timeout', 'task_cancelled'], (payload) => {
const task = tasks.value.find(t => t.id === payload.task_id)
if (task) {
task.running_status = payload.status
}
// 同步更新打开的日志弹窗状态
if (selectedLog.value && selectedLog.value.id === payload.log_id) {
selectedLog.value.status = payload.status
if (payload.duration !== undefined) selectedLog.value.duration = payload.duration
if (payload.end_time !== undefined) selectedLog.value.end_time = payload.end_time
if (payload.status !== TASK_STATUS.RUNNING) {
cleanupDurationTimer()
cleanupLogSocket()
loadTasks()
}
}
})
// 监听路由参数变化