fix(tasks): fix task cancellation process tree leak, emit cancelled events, and refine status badges styling
This commit is contained in:
@@ -81,11 +81,12 @@ const (
|
|||||||
EventPasswordChanged = "password_changed"
|
EventPasswordChanged = "password_changed"
|
||||||
|
|
||||||
// 任务事件类型
|
// 任务事件类型
|
||||||
EventTaskSuccess = "task_success"
|
EventTaskSuccess = "task_success"
|
||||||
EventTaskFailed = "task_failed"
|
EventTaskFailed = "task_failed"
|
||||||
EventTaskTimeout = "task_timeout"
|
EventTaskTimeout = "task_timeout"
|
||||||
EventTaskRunning = "task_running"
|
EventTaskRunning = "task_running"
|
||||||
EventTaskQueued = "task_queued"
|
EventTaskQueued = "task_queued"
|
||||||
|
EventTaskCancelled = "task_cancelled"
|
||||||
|
|
||||||
// 其他事件类型
|
// 其他事件类型
|
||||||
EventSystemNotice = "system_notice"
|
EventSystemNotice = "system_notice"
|
||||||
|
|||||||
@@ -161,6 +161,9 @@ func ExecuteWithHooks(ctx context.Context, req Request, stdout, stderr io.Writer
|
|||||||
shell, args := utils.GetShellCommand(req.Command)
|
shell, args := utils.GetShellCommand(req.Command)
|
||||||
cmd := exec.CommandContext(execCtx, shell, args...)
|
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)
|
workDir := strings.TrimSpace(req.WorkDir)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,6 +100,7 @@ func (m *SystemWSManager) SubscribeEvents(bus *eventbus.EventBus) {
|
|||||||
constant.EventTaskTimeout,
|
constant.EventTaskTimeout,
|
||||||
constant.EventTaskRunning,
|
constant.EventTaskRunning,
|
||||||
constant.EventTaskQueued,
|
constant.EventTaskQueued,
|
||||||
|
constant.EventTaskCancelled,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, evt := range taskEvents {
|
for _, evt := range taskEvents {
|
||||||
|
|||||||
@@ -304,6 +304,8 @@ func (h *ServerSchedulerHandler) OnTaskCompleted(req *executor.ExecutionRequest,
|
|||||||
eventType = constant.EventTaskFailed
|
eventType = constant.EventTaskFailed
|
||||||
case constant.TaskStatusTimeout:
|
case constant.TaskStatusTimeout:
|
||||||
eventType = constant.EventTaskTimeout
|
eventType = constant.EventTaskTimeout
|
||||||
|
case constant.TaskStatusCancelled:
|
||||||
|
eventType = constant.EventTaskCancelled
|
||||||
}
|
}
|
||||||
if eventType != "" {
|
if eventType != "" {
|
||||||
eventbus.DefaultBus.Publish(eventbus.Event{
|
eventbus.DefaultBus.Publish(eventbus.Event{
|
||||||
|
|||||||
@@ -96,19 +96,19 @@ watch(() => props.log?.duration, (newVal) => {
|
|||||||
function getStatusBadgeClass(status: string) {
|
function getStatusBadgeClass(status: string) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case TASK_STATUS.SUCCESS:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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':
|
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:
|
default:
|
||||||
return 'bg-secondary text-secondary-foreground border-transparent'
|
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"
|
<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 ? '停止中' : '停止任务' }}
|
{{ isStopping ? '停止中' : '停止任务' }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,7 +143,7 @@ function getStatusBadgeClass(status: string) {
|
|||||||
<div class="flex items-center gap-1.5 shrink-0">
|
<div class="flex items-center gap-1.5 shrink-0">
|
||||||
<!-- 极简模式桌面端显示的停止按钮 -->
|
<!-- 极简模式桌面端显示的停止按钮 -->
|
||||||
<Button v-if="log.status === TASK_STATUS.RUNNING && variant === 'simple'" variant="destructive" size="sm"
|
<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 ? '停止中' : '停止任务' }}
|
{{ isStopping ? '停止中' : '停止任务' }}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -171,8 +171,8 @@ function getStatusBadgeClass(status: string) {
|
|||||||
|
|
||||||
<!-- 极简模式移动端停止按钮 -->
|
<!-- 极简模式移动端停止按钮 -->
|
||||||
<Button v-if="log.status === TASK_STATUS.RUNNING" variant="destructive" size="sm"
|
<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')">
|
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.5 w-3.5" />
|
<Ban class="h-3 w-3" />
|
||||||
{{ isStopping ? '停止中' : '停止' }}
|
{{ isStopping ? '停止中' : '停止' }}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -202,19 +202,21 @@ function getStatusBadgeClass(status: string) {
|
|||||||
<div class="flex justify-between items-center h-8">
|
<div class="flex justify-between items-center h-8">
|
||||||
<span class="text-sm font-normal text-muted-foreground">状态</span>
|
<span class="text-sm font-normal text-muted-foreground">状态</span>
|
||||||
<Badge variant="outline" :class="[
|
<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)
|
getStatusBadgeClass(log.status)
|
||||||
]">
|
]">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1">
|
||||||
<CheckCircle2 v-if="log.status === TASK_STATUS.SUCCESS" class="h-3.5 w-3.5 fill-green-500/20" />
|
<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.5 w-3.5 fill-red-500/20" />
|
<XCircle v-else-if="log.status === TASK_STATUS.FAILED" class="h-3 w-3 fill-rose-500/10" />
|
||||||
<ZapIcon v-else-if="log.status === TASK_STATUS.RUNNING"
|
<span v-else-if="log.status === TASK_STATUS.RUNNING" class="relative flex h-1.5 w-1.5 mr-0.5">
|
||||||
class="h-3.5 w-3.5 fill-current animate-pulse text-blue-500" />
|
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span>
|
||||||
<Clock v-else-if="log.status === TASK_STATUS.PENDING" class="h-3.5 w-3.5 fill-amber-500/20" />
|
<span class="relative inline-flex rounded-full h-1.5 w-1.5 bg-sky-500"></span>
|
||||||
<AlertCircle v-else-if="log.status === TASK_STATUS.TIMEOUT" class="h-3.5 w-3.5 fill-orange-500/20" />
|
</span>
|
||||||
<Ban v-else-if="log.status === TASK_STATUS.CANCELLED" class="h-3.5 w-3.5" />
|
<Clock v-else-if="log.status === TASK_STATUS.PENDING" class="h-3 w-3 fill-amber-500/10" />
|
||||||
<MinusCircle v-else-if="log.status === 'UNEXECUTED'" class="h-3.5 w-3.5 opacity-40" />
|
<AlertCircle v-else-if="log.status === TASK_STATUS.TIMEOUT" class="h-3 w-3 fill-orange-500/10" />
|
||||||
<span class="text-[10px] font-normal">
|
<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 }}
|
{{ TASK_STATUS_TEXT[log.status] || log.status }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export const TASK_EVENTS = {
|
|||||||
TIMEOUT: 'task_timeout',
|
TIMEOUT: 'task_timeout',
|
||||||
RUNNING: 'task_running',
|
RUNNING: 'task_running',
|
||||||
QUEUED: 'task_queued',
|
QUEUED: 'task_queued',
|
||||||
|
CANCELLED: 'task_cancelled',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
// 日志事件类型
|
// 日志事件类型
|
||||||
|
|||||||
@@ -54,10 +54,16 @@ useEventBus(Object.values(TASK_EVENTS), (payload, type) => {
|
|||||||
if (duration !== undefined) selectedLog.value.duration = duration
|
if (duration !== undefined) selectedLog.value.duration = duration
|
||||||
if (end_time !== undefined) selectedLog.value.end_time = end_time
|
if (end_time !== undefined) selectedLog.value.end_time = end_time
|
||||||
|
|
||||||
// 如果任务完成了,停止详情页的轮询定时器
|
// 如果任务完成了,停止详情页的轮询定时器并断开 SSE
|
||||||
if (status !== TASK_STATUS.RUNNING && durationTimer) {
|
if (status !== TASK_STATUS.RUNNING) {
|
||||||
clearInterval(durationTimer)
|
if (durationTimer) {
|
||||||
durationTimer = null
|
clearInterval(durationTimer)
|
||||||
|
durationTimer = null
|
||||||
|
}
|
||||||
|
if (logSource) {
|
||||||
|
logSource.close()
|
||||||
|
logSource = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (type === TASK_EVENTS.RUNNING) {
|
} else if (type === TASK_EVENTS.RUNNING) {
|
||||||
@@ -149,36 +155,29 @@ async function selectLog(log: TaskLog) {
|
|||||||
|
|
||||||
selectedLog.value = log
|
selectedLog.value = log
|
||||||
|
|
||||||
// 如果是运行中状态,启动定时器轮询最新日志信息(主要是更新耗时)
|
// 如果是运行中状态,启动定时器本地更新耗时,状态变更依靠 EventBus
|
||||||
if (log.status === TASK_STATUS.RUNNING) {
|
if (log.status === TASK_STATUS.RUNNING) {
|
||||||
const updateLog = async () => {
|
const updateLog = () => {
|
||||||
try {
|
if (selectedLog.value && selectedLog.value.id === log.id && selectedLog.value.status === TASK_STATUS.RUNNING) {
|
||||||
const res = await api.logs.get(log.id)
|
if (selectedLog.value.start_time) {
|
||||||
if (res && selectedLog.value && selectedLog.value.id === log.id) {
|
const startMs = new Date(selectedLog.value.start_time).getTime()
|
||||||
// 只更新需要变动的字段
|
if (!isNaN(startMs)) {
|
||||||
selectedLog.value.duration = res.duration
|
selectedLog.value.duration = Date.now() - startMs
|
||||||
// 同步更新列表中的数据
|
} else {
|
||||||
const listItem = logs.value.find(l => l.id === log.id)
|
selectedLog.value.duration += 1000
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} 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 = ''
|
wsContent.value = ''
|
||||||
|
|||||||
@@ -376,25 +376,23 @@ async function viewLogs(taskId: string) {
|
|||||||
cleanupLogSocket()
|
cleanupLogSocket()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动状态轮询
|
// 优化:本地定时更新耗时,不再发请求轮询状态,状态变更依赖 EventBus 推送
|
||||||
cleanupDurationTimer()
|
cleanupDurationTimer()
|
||||||
const updateLogStatus = async () => {
|
const updateLogStatus = () => {
|
||||||
try {
|
if (selectedLog.value && selectedLog.value.status === TASK_STATUS.RUNNING) {
|
||||||
const res = await api.logs.get(latestLog.id)
|
if (selectedLog.value.start_time && selectedLog.value.start_time !== '-') {
|
||||||
if (res && selectedLog.value && selectedLog.value.id === latestLog.id) {
|
const startMs = new Date(selectedLog.value.start_time).getTime()
|
||||||
selectedLog.value.duration = res.duration
|
if (!isNaN(startMs)) {
|
||||||
selectedLog.value.status = res.status
|
selectedLog.value.duration = Date.now() - startMs
|
||||||
selectedLog.value.end_time = res.end_time
|
} else {
|
||||||
|
selectedLog.value.duration += 1000
|
||||||
// 如果任务结束,停止轮询
|
|
||||||
if (res.status !== TASK_STATUS.RUNNING) {
|
|
||||||
cleanupDurationTimer()
|
|
||||||
loadTasks() // 同时刷新列表状态
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
selectedLog.value.duration += 1000
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
}
|
||||||
}
|
}
|
||||||
durationTimer = setInterval(updateLogStatus, 3000)
|
durationTimer = setInterval(updateLogStatus, 1000)
|
||||||
} else {
|
} else {
|
||||||
// 如果没有日志,构造一个基础的任务信息对象用于展示弹窗
|
// 如果没有日志,构造一个基础的任务信息对象用于展示弹窗
|
||||||
const task = tasks.value.find(t => t.id === taskId)
|
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)
|
const task = tasks.value.find(t => t.id === payload.task_id)
|
||||||
if (task) {
|
if (task) {
|
||||||
task.running_status = payload.status
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听路由参数变化
|
// 监听路由参数变化
|
||||||
|
|||||||
Reference in New Issue
Block a user