diff --git a/internal/constant/constant.go b/internal/constant/constant.go index 695a4cb..763b964 100644 --- a/internal/constant/constant.go +++ b/internal/constant/constant.go @@ -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" diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 1158c68..514aed7 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -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) diff --git a/internal/executor/proc_unix.go b/internal/executor/proc_unix.go new file mode 100644 index 0000000..959c875 --- /dev/null +++ b/internal/executor/proc_unix.go @@ -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 + } +} diff --git a/internal/executor/proc_windows.go b/internal/executor/proc_windows.go new file mode 100644 index 0000000..ddfc1c2 --- /dev/null +++ b/internal/executor/proc_windows.go @@ -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 + } +} diff --git a/internal/services/system_ws_service.go b/internal/services/system_ws_service.go index 9589106..409df7d 100644 --- a/internal/services/system_ws_service.go +++ b/internal/services/system_ws_service.go @@ -100,6 +100,7 @@ func (m *SystemWSManager) SubscribeEvents(bus *eventbus.EventBus) { constant.EventTaskTimeout, constant.EventTaskRunning, constant.EventTaskQueued, + constant.EventTaskCancelled, } for _, evt := range taskEvents { diff --git a/internal/services/tasks/executor_service.go b/internal/services/tasks/executor_service.go index d039b4f..0e26141 100644 --- a/internal/services/tasks/executor_service.go +++ b/internal/services/tasks/executor_service.go @@ -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{ diff --git a/web/src/components/LogDetailCard.vue b/web/src/components/LogDetailCard.vue index 3d37e58..a65a387 100644 --- a/web/src/components/LogDetailCard.vue +++ b/web/src/components/LogDetailCard.vue @@ -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) { @@ -143,7 +143,7 @@ function getStatusBadgeClass(status: string) {
@@ -171,8 +171,8 @@ function getStatusBadgeClass(status: string) { @@ -202,19 +202,21 @@ function getStatusBadgeClass(status: string) {
状态 -
- - - - - - - - +
+ + + + + + + + + + + {{ TASK_STATUS_TEXT[log.status] || log.status }}
diff --git a/web/src/constants/index.ts b/web/src/constants/index.ts index ff4b408..ab65cd0 100644 --- a/web/src/constants/index.ts +++ b/web/src/constants/index.ts @@ -71,6 +71,7 @@ export const TASK_EVENTS = { TIMEOUT: 'task_timeout', RUNNING: 'task_running', QUEUED: 'task_queued', + CANCELLED: 'task_cancelled', } as const // 日志事件类型 diff --git a/web/src/views/history/History.vue b/web/src/views/history/History.vue index d932c0a..e1de4aa 100644 --- a/web/src/views/history/History.vue +++ b/web/src/views/history/History.vue @@ -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 = '' diff --git a/web/src/views/tasks/Tasks.vue b/web/src/views/tasks/Tasks.vue index 5a11489..1d26e3e 100644 --- a/web/src/views/tasks/Tasks.vue +++ b/web/src/views/tasks/Tasks.vue @@ -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() + } + } }) // 监听路由参数变化