feat: Support manual stopping of shell-type tasks (#10524)

Refs #10067
This commit is contained in:
ssongliu 2025-09-28 18:18:17 +08:00 committed by GitHub
parent 7518c5fe95
commit 8937acee13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 141 additions and 14 deletions

View file

@ -226,6 +226,28 @@ func (b *BaseApi) CleanRecord(c *gin.Context) {
helper.Success(c)
}
// @Tags Cronjob
// @Summary Handle stop job
// @Accept json
// @Param request body dto.OperateByID true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /cronjobs/stop [post]
func (b *BaseApi) StopCronJob(c *gin.Context) {
var req dto.OperateByID
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := cronjobService.HandleStop(req.ID); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Cronjob
// @Summary Delete cronjob
// @Accept json

View file

@ -36,6 +36,7 @@ type ICronjobService interface {
Delete(req dto.CronjobBatchDelete) error
StartJob(cronjob *model.Cronjob, isUpdate bool) (string, error)
CleanRecord(req dto.CronjobClean) error
HandleStop(id uint) error
Export(req dto.OperateByIDs) (string, error)
Import(req []dto.CronjobTrans) error
@ -617,6 +618,20 @@ func (u *CronjobService) StartJob(cronjob *model.Cronjob, isUpdate bool) (string
return strings.Join(ids, ","), nil
}
func (u *CronjobService) HandleStop(id uint) error {
record, _ := cronjobRepo.GetRecord(repo.WithByID(id))
if record.ID == 0 {
return buserr.New("ErrRecordNotFound")
}
if len(record.TaskID) == 0 {
return nil
}
if cancle, ok := global.TaskCtxMap[record.TaskID]; ok {
cancle()
}
return nil
}
func (u *CronjobService) Delete(req dto.CronjobBatchDelete) error {
for _, id := range req.IDs {
cronjob, _ := cronjobRepo.Get(repo.WithByID(id))

View file

@ -132,7 +132,8 @@ func (u *CronjobService) loadTask(cronjob *model.Cronjob, record *model.JobRecor
}
func (u *CronjobService) handleShell(cronjob model.Cronjob, taskItem *task.Task) {
cmdMgr := cmd.NewCommandMgr(cmd.WithTask(*taskItem))
cmdMgr := cmd.NewCommandMgr(cmd.WithTask(*taskItem), cmd.WithContext(taskItem.TaskCtx))
taskItem.AddSubTaskWithOps(i18n.GetWithName("HandleShell", cronjob.Name), func(t *task.Task) error {
if len(cronjob.ContainerName) != 0 {
scriptItem := cronjob.Script

View file

@ -25,6 +25,8 @@ type ActionFunc func(*Task) error
type RollbackFunc func(*Task)
type Task struct {
TaskCtx context.Context
Name string
TaskID string
Logger *log.Logger
@ -147,7 +149,9 @@ func NewTask(name, operate, taskScope, taskID string, resourceID uint) (*Task, e
Operate: operate,
}
taskRepo := repo.NewITaskRepo()
task := &Task{Name: name, logFile: file, Logger: logger, taskRepo: taskRepo, Task: taskModel, Writer: writer}
ctx, cancle := context.WithCancel(context.Background())
global.TaskCtxMap[taskID] = cancle
task := &Task{TaskCtx: ctx, Name: name, logFile: file, Logger: logger, taskRepo: taskRepo, Task: taskModel, Writer: writer}
return task, nil
}
@ -211,6 +215,7 @@ func (t *Task) AddSubTaskWithIgnoreErr(name string, action ActionFunc) {
}
func (s *SubTask) Execute() error {
defer delete(global.TaskCtxMap, s.RootTask.TaskID)
subTaskName := s.Name
if s.Name == "" {
subTaskName = i18n.GetMsgByKey("SubTask")

View file

@ -1,6 +1,8 @@
package global
import (
"context"
badger_db "github.com/1Panel-dev/1Panel/agent/init/cache/db"
"github.com/go-playground/validator/v10"
"github.com/nicksnyder/go-i18n/v2/i18n"
@ -34,4 +36,6 @@ var (
AlertBaseJobID cron.EntryID
AlertResourceJobID cron.EntryID
TaskCtxMap = make(map[string]context.CancelFunc)
)

View file

@ -14,6 +14,7 @@ ErrApiConfigKeyInvalid: 'API interface key error: {{ .detail }}'
ErrApiConfigIPInvalid: 'The IP used to call the API interface is not in the whitelist: {{ .detail }}'
ErrApiConfigDisable: 'This interface prohibits the use of API interface calls: {{ .detail }}'
ErrApiConfigKeyTimeInvalid: 'API interface timestamp error: {{ .detail }}'
ErrShutDown: "Command manually terminated!"
ErrMinQuickJump: "Please set at least one quick jump entry!"
ErrMaxQuickJump: "You can set up to four quick jump entries!"

View file

@ -14,6 +14,8 @@ ErrApiConfigKeyInvalid: 'Error en la clave de la interfaz API: {{ .detail }}'
ErrApiConfigIPInvalid: 'La IP usada para llamar a la API no está en la lista blanca: {{ .detail }}'
ErrApiConfigDisable: 'Esta interfaz prohíbe el uso de llamadas a la API: {{ .detail }}'
ErrApiConfigKeyTimeInvalid: 'Error en la marca de tiempo de la interfaz API: {{ .detail }}'
ErrShutDown: "¡Comando terminado manualmente!"
ErrMinQuickJump: "¡Por favor configure al menos una entrada de acceso rápido!"
ErrMaxQuickJump: "¡Puede configurar hasta cuatro entradas de acceso rápido!"

View file

@ -14,6 +14,7 @@ ErrApiConfigKeyInvalid: 'API 인터페이스 키 오류: {{ .detail }}'
ErrApiConfigIPInvalid: 'API 인터페이스를 호출하는 데 사용된 IP가 허용 목록에 없습니다: {{ .detail }}'
ErrApiConfigDisable: '이 인터페이스는 API 인터페이스 호출 사용을 금지합니다: {{ .detail }}'
ErrApiConfigKeyTimeInvalid: 'API 인터페이스 타임스탬프 오류: {{ .detail }}'
ErrShutDown: "명령이 수동으로 종료되었습니다!"
ErrMinQuickJump: "최소 하나의 빠른 점프 항목을 설정해 주세요!"
ErrMaxQuickJump: "최대 네 개의 빠른 점프 항목을 설정할 수 있습니다!"

View file

@ -17,6 +17,7 @@ ErrApiConfigKeyTimeInvalid: 'Ralat cap masa antara muka API: {{ .detail }}'
StartPushSSLToNode: "Mula menolak sijil ke nod"
PushSSLToNodeFailed: "Gagal menolak sijil ke nod: {{ .err }}"
PushSSLToNodeSuccess: "Berjaya menolak sijil ke nod"
ErrShutDown: "Arahan dihentikan secara manual!"
ErrMinQuickJump: "Sila tetapkan sekurang-kurangnya satu entri lompat pantas!"
ErrMaxQuickJump: "Anda boleh menetapkan sehingga empat entri lompat pantas!"

View file

@ -17,6 +17,7 @@ ErrApiConfigKeyTimeInvalid: 'Erro de registro de data e hora da interface da API
StartPushSSLToNode: "Iniciando o envio do certificado para o nó"
PushSSLToNodeFailed: "Falha ao enviar o certificado para o nó: {{ .err }}"
PushSSLToNodeSuccess: "Certificado enviado com sucesso para o nó"
ErrShutDown: "Comando terminado manualmente!"
ErrMinQuickJump: "Defina pelo menos uma entrada de salto rápido!"
ErrMaxQuickJump: "Você pode definir até quatro entradas de salto rápido!"

View file

@ -17,6 +17,7 @@ ErrApiConfigKeyTimeInvalid: 'Ошибка временной метки инте
StartPushSSLToNode: "Начало отправки сертификата на узел"
PushSSLToNodeFailed: "Не удалось отправить сертификат на узел: {{ .err }}"
PushSSLToNodeSuccess: "Сертификат успешно отправлен на узел"
ErrShutDown: "Команда завершена вручную!"
ErrMinQuickJump: "Пожалуйста, установите хотя бы одну запись быстрого перехода!"
ErrMaxQuickJump: "Можно установить до четырех записей быстрого перехода!"

View file

@ -17,6 +17,7 @@ ErrApiConfigKeyTimeInvalid: 'API arayüz zaman damgası hatası: {{ .detail }}'
StartPushSSLToNode: "Sertifika düğüme gönderilmeye başlandı"
PushSSLToNodeFailed: "Sertifika düğüme gönderilemedi: {{ .err }}"
PushSSLToNodeSuccess: "Sertifika düğüme başarıyla gönderildi"
ErrShutDown: "Komut manuel olarak sonlandırıldı!"
ErrMinQuickJump: "Lütfen en az bir hızlı atlama girişi ayarlayın!"
ErrMaxQuickJump: "En fazla dört hızlı atlama girişi ayarlayabilirsiniz!"

View file

@ -14,6 +14,7 @@ ErrApiConfigKeyInvalid: 'API 介面金鑰錯誤: {{ .detail }}'
ErrApiConfigIPInvalid: '呼叫 API 介面 IP 不在白名單: {{ .detail }}'
ErrApiConfigDisable: '此介面禁止使用 API 介面呼叫: {{ .detail }}'
ErrApiConfigKeyTimeInvalid: 'API 介面時間戳記錯誤: {{ .detail }}'
ErrShutDown: "命令被手動結束!"
ErrMinQuickJump: "請至少設定一個快速跳轉入口!"
ErrMaxQuickJump: "最多可設定四個快速跳轉入口!"

View file

@ -14,6 +14,7 @@ ErrApiConfigKeyInvalid: "API 接口密钥错误: {{ .detail }}"
ErrApiConfigIPInvalid: "调用 API 接口 IP 不在白名单: {{ .detail }}"
ErrApiConfigDisable: "此接口禁止使用 API 接口调用: {{ .detail }}"
ErrApiConfigKeyTimeInvalid: "API 接口时间戳错误: {{ .detail }}"
ErrShutDown: "命令被手动结束!"
ErrMinQuickJump: "请至少设置一个快速跳转入口!"
ErrMaxQuickJump: "最多可设置四个快速跳转入口!"

View file

@ -18,6 +18,7 @@ func (s *CronjobRouter) InitRouter(Router *gin.RouterGroup) {
cmdRouter.POST("/load/info", baseApi.LoadCronjobInfo)
cmdRouter.GET("/script/options", baseApi.LoadScriptOptions)
cmdRouter.POST("/del", baseApi.DeleteCronjob)
cmdRouter.POST("/stop", baseApi.StopCronJob)
cmdRouter.POST("/update", baseApi.UpdateCronjob)
cmdRouter.POST("/group/update", baseApi.UpdateCronjobGroup)
cmdRouter.POST("/status", baseApi.UpdateCronjobStatus)

View file

@ -18,6 +18,7 @@ import (
)
type CommandHelper struct {
context context.Context
workDir string
outputFile string
scriptPath string
@ -95,15 +96,23 @@ func (c *CommandHelper) RunWithStdoutBashCf(command string, arg ...interface{})
func (c *CommandHelper) run(name string, arg ...string) (string, error) {
var cmd *exec.Cmd
var ctx context.Context
var cancel context.CancelFunc
if c.timeout != 0 {
ctx, cancel = context.WithTimeout(context.Background(), c.timeout)
defer cancel()
cmd = exec.CommandContext(ctx, name, arg...)
if c.context == nil {
c.context, cancel = context.WithTimeout(context.Background(), c.timeout)
defer cancel()
} else {
c.context, cancel = context.WithTimeout(c.context, c.timeout)
defer cancel()
}
cmd = exec.CommandContext(c.context, name, arg...)
} else {
cmd = exec.Command(name, arg...)
if c.context == nil {
cmd = exec.Command(name, arg...)
} else {
cmd = exec.CommandContext(c.context, name, arg...)
}
}
customWriter := &CustomWriter{taskItem: c.taskItem}
@ -141,16 +150,24 @@ func (c *CommandHelper) run(name string, arg ...string) (string, error) {
customWriter.Flush()
}
if c.timeout != 0 {
if ctx != nil && errors.Is(ctx.Err(), context.DeadlineExceeded) {
if c.context != nil && errors.Is(c.context.Err(), context.DeadlineExceeded) {
return "", buserr.New("ErrCmdTimeout")
}
}
if err != nil {
if err.Error() == "signal: killed" {
return "", buserr.New("ErrShutDown")
}
return handleErr(stdout, stderr, c.IgnoreExist1, err)
}
return stdout.String(), nil
}
func WithContext(ctx context.Context) Option {
return func(s *CommandHelper) {
s.context = ctx
}
}
func WithOutputFile(outputFile string) Option {
return func(s *CommandHelper) {
s.outputFile = outputFile

View file

@ -50,6 +50,10 @@ export const searchRecords = (params: Cronjob.SearchRecord) => {
return http.post<ResPage<Cronjob.Record>>(`cronjobs/search/records`, params);
};
export const stopCronjob = (id: number) => {
return http.post(`cronjobs/stop`, { id: id });
};
export const cleanRecords = (id: number, cleanData: boolean, cleanRemoteData: boolean) => {
return http.post(`cronjobs/records/clean`, { cronjobID: id, cleanData: cleanData, cleanRemoteData });
};

View file

@ -1035,6 +1035,8 @@ const message = {
record: 'Records',
viewRecords: 'View records',
shell: 'Shell',
stop: 'Manual Stop',
stopHelper: 'This operation will force stop the current task execution. Continue?',
log: 'Backup logs',
logHelper: 'Backup system log',
ogHelper1: '1.1Panel System log ',

View file

@ -1033,6 +1033,8 @@ const message = {
record: 'Registros',
viewRecords: 'Ver registros',
shell: 'Shell',
stop: 'Detención Manual',
stopHelper: 'Esta operación forzará la detención de la ejecución de la tarea actual. ¿Continuar?',
log: 'Registros de respaldo',
logHelper: 'Registro del sistema de copias de seguridad',
ogHelper1: '1. Registro del sistema de 1Panel',

View file

@ -1006,6 +1006,8 @@ const message = {
record: '記録',
viewRecords: '記録',
shell: 'シェル',
stop: '手動終了',
stopHelper: 'この操作により現在のタスクの実行が強制停止されます続行しますか',
log: 'バックアップログ',
logHelper: 'バックアップシステムログ',
ogHelper1: '1.1パネルシステムログ',

View file

@ -996,6 +996,8 @@ const message = {
record: '레코드',
viewRecords: '레코드 보기',
shell: '셸',
stop: '수동 중지',
stopHelper: ' 작업은 현재 작업 실행을 강제로 중지합니다. 계속하시겠습니까?',
log: '백업 로그',
logHelper: '시스템 백업 로그',
ogHelper1: '1. 1Panel 시스템 로그',

View file

@ -1028,6 +1028,8 @@ const message = {
record: 'Rekod',
viewRecords: 'Rekod',
shell: 'Shell',
stop: 'Hentikan Manual',
stopHelper: 'Operasi ini akan memaksa menghentikan pelaksanaan tugas semasa. Teruskan?',
log: 'Log sandaran',
logHelper: 'Log sistem sandaran',
ogHelper1: '1. Log Sistem 1Panel ',

View file

@ -1025,6 +1025,8 @@ const message = {
record: 'Registros',
viewRecords: 'Visualizar registros',
shell: 'Shell',
stop: 'Parada Manual',
stopHelper: 'Esta operação forçará a parada da execução da tarefa atual. Continuar?',
log: 'Logs de backup',
logHelper: 'Backup do log do sistema',
ogHelper1: '1. Log do sistema 1Panel',

View file

@ -1022,6 +1022,8 @@ const message = {
record: 'Записи',
viewRecords: 'Записи',
shell: 'Shell',
stop: 'Ручная Остановка',
stopHelper: 'Эта операция принудительно остановит выполнение текущей задачи. Продолжить?',
log: 'Логи резервного копирования',
logHelper: 'Резервное копирование системного лога',
ogHelper1: '1. Системный лог 1Panel',

View file

@ -1047,6 +1047,8 @@ const message = {
record: 'Kayıtlar',
viewRecords: 'Kayıtları görüntüle',
shell: 'Shell',
stop: 'Manuel Durdur',
stopHelper: 'Bu işlem mevcut görev yürütmesini zorla durduracaktır. Devam etmek istiyor musunuz?',
log: 'Yedekleme logları',
logHelper: 'Sistem logunu yedekle',
ogHelper1: '1.1Panel Sistem logu ',

View file

@ -987,6 +987,8 @@ const message = {
record: '報告',
viewRecords: '查看報告',
shell: 'Shell 腳本',
stop: '手動結束',
stopHelper: '該操作將強制停止該次任務執行是否繼續',
log: '備份日誌',
logHelper: '備份系統日誌',
logHelper1: '1. 1Panel 系統日誌',

View file

@ -986,6 +986,8 @@ const message = {
record: '报告',
viewRecords: '查看报告',
shell: 'Shell 脚本',
stop: '手动结束',
stopHelper: '该操作将强制停止该次任务执行是否继续',
log: '备份日志',
logHelper: '备份系统日志',
logHelper1: '1. 1Panel 系统日志',

View file

@ -91,14 +91,13 @@
<el-col :span="7">
<el-card class="el-card">
<div class="infinite-list" style="overflow: auto">
<el-table
<ComplexTable
style="cursor: pointer"
:data="records"
border
:show-header="false"
@row-click="forDetail"
>
<el-table-column>
<el-table-column min-width="230px">
<template #default="{ row }">
<span v-if="row.id === currentRecord.id" class="select-sign"></span>
<Status class="mr-2 ml-1 float-left" :status="row.status" />
@ -109,7 +108,24 @@
</div>
</template>
</el-table-column>
</el-table>
<el-table-column min-width="30px">
<template #default="{ row }">
<el-tooltip :content="$t('cronjob.stop')">
<el-button
v-if="
dialogData.rowData.type === 'shell' &&
row.status === 'Waiting'
"
class="float-right"
link
type="primary"
@click="onStop(row.id)"
icon="SwitchButton"
/>
</el-tooltip>
</template>
</el-table-column>
</ComplexTable>
</div>
<div class="page-item">
<el-pagination
@ -231,7 +247,7 @@
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import { Cronjob } from '@/api/interface/cronjob';
import { searchRecords, handleOnce, updateStatus, cleanRecords } from '@/api/modules/cronjob';
import { searchRecords, handleOnce, updateStatus, cleanRecords, stopCronjob } from '@/api/modules/cronjob';
import { dateFormat } from '@/utils/util';
import LogFile from '@/components/log/file/index.vue';
import i18n from '@/lang';
@ -374,7 +390,8 @@ const search = async (changeToLatest: boolean) => {
}
};
const forDetail = async (row: Cronjob.Record) => {
const forDetail = (row: Cronjob.Record) => {
console.log('123');
currentRecord.value = row;
};
@ -400,6 +417,17 @@ const onClean = async () => {
}
};
const onStop = async (id: number) => {
ElMessageBox.confirm(i18n.global.t('cronjob.stopHelper'), i18n.global.t('cronjob.stop'), {
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
type: 'warning',
}).then(async () => {
await stopCronjob(id);
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
});
};
const cleanRecord = async () => {
delLoading.value = true;
await cleanRecords(dialogData.value.rowData.id, cleanData.value, false)