diff --git a/agent/app/api/v2/file.go b/agent/app/api/v2/file.go index aa734e250cd6..5928f8d72e84 100644 --- a/agent/app/api/v2/file.go +++ b/agent/app/api/v2/file.go @@ -638,6 +638,26 @@ func (b *BaseApi) MoveFile(c *gin.Context) { helper.Success(c) } +// @Tags File +// @Summary Stop file move task +// @Accept json +// @Param request body request.FileMoveStopReq true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /files/move/stop [post] +func (b *BaseApi) StopMoveFile(c *gin.Context) { + var req request.FileMoveStopReq + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := fileService.StopMvFile(req.TaskID); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} + // @Tags File // @Summary Download file // @Accept json diff --git a/agent/app/dto/request/file.go b/agent/app/dto/request/file.go index 257413c3a291..a0a9e6c8ce99 100644 --- a/agent/app/dto/request/file.go +++ b/agent/app/dto/request/file.go @@ -131,6 +131,11 @@ type FileMove struct { Name string `json:"name"` Cover bool `json:"cover"` CoverPaths []string `json:"coverPaths"` + TaskID string `json:"taskID"` +} + +type FileMoveStopReq struct { + TaskID string `json:"taskID" validate:"required"` } type FileDownload struct { diff --git a/agent/app/service/file.go b/agent/app/service/file.go index adb826ae1369..f3212fbfede3 100644 --- a/agent/app/service/file.go +++ b/agent/app/service/file.go @@ -52,6 +52,8 @@ type FileService struct { const fileHistorySnapshotMaxSize = 10 * 1024 * 1024 +var fileTransferLocks = newFileTransferLocks() + type IFileService interface { GetFileList(op request.FileOption) (response.FileInfo, error) SearchUploadWithPage(req request.SearchUploadWithPage) (int64, interface{}, error) @@ -72,6 +74,7 @@ type IFileService interface { ChangeName(req request.FileRename) error Wget(w request.FileWget) (string, error) MvFile(m request.FileMove) error + StopMvFile(taskID string) error ChangeOwner(req request.FileRoleUpdate) error ChangeMode(op request.FileCreate) error BatchChangeModeAndOwner(op request.FileRoleReq) error @@ -908,17 +911,62 @@ func (f *FileService) Wget(w request.FileWget) (string, error) { func (f *FileService) MvFile(m request.FileMove) error { fo := files.NewFileOp() + if err := validateFileMove(fo, m); err != nil { + return err + } + if m.TaskID == "" { + m.TaskID = common.GetUuid() + } + if !fileTransferLocks.Acquire(m.TaskID, getFileTransferPaths(m)) { + return buserr.New("TaskIsExecuting") + } + taskItem, err := task.NewTask(m.NewPath, task.TaskExec, task.TaskScopeTask, m.TaskID, 1) + if err != nil { + fileTransferLocks.Release(m.TaskID) + return err + } + go func() { + defer fileTransferLocks.Release(m.TaskID) + taskItem.AddSubTaskWithOps(m.NewPath, func(t *task.Task) error { + t.LogStart(m.NewPath) + err := f.moveFileWithContext(t.TaskCtx, m) + if err != nil && t.TaskCtx.Err() != nil { + return t.TaskCtx.Err() + } + return err + }, nil, 0, 0) + _ = taskItem.Execute() + }() + return nil +} + +func (f *FileService) StopMvFile(taskID string) error { + if cancel, ok := global.LoadTaskCancel(taskID); ok { + cancel() + return nil + } + return buserr.New("TaskNotFound") +} + +func validateFileMove(fo files.FileOp, m request.FileMove) error { if !fo.Stat(m.NewPath) { return buserr.New("ErrPathNotFound") } - for _, oldPath := range m.OldPaths { + for _, oldPath := range append(append([]string{}, m.OldPaths...), m.CoverPaths...) { if !fo.Stat(oldPath) { return buserr.WithName("ErrFileNotFound", oldPath) } - if oldPath == m.NewPath || strings.Contains(m.NewPath, filepath.Clean(oldPath)+"/") { + oldPath = filepath.Clean(oldPath) + newPath := filepath.Clean(m.NewPath) + if oldPath == newPath || strings.HasPrefix(newPath, oldPath+string(filepath.Separator)) { return buserr.New("ErrMovePathFailed") } } + return nil +} + +func (f *FileService) moveFileWithContext(ctx context.Context, m request.FileMove) error { + fo := files.NewFileOp() type moveSnapshot struct { path string content []byte @@ -934,13 +982,25 @@ func (f *FileService) MvFile(m request.FileMove) error { } if len(m.CoverPaths) > 0 { for _, src := range m.CoverPaths { - if err := fo.CopyAndReName(src, m.NewPath, "", true); err != nil { + if err := ctx.Err(); err != nil { + return err + } + if err := fo.CopyAndReNameWithContext(ctx, src, m.NewPath, "", true); err != nil { errs = append(errs, err) global.LOG.Errorf("cut copy file [%s] to [%s] failed, err: %s", src, m.NewPath, err.Error()) + continue + } + if err := ctx.Err(); err != nil { + return err + } + if err := fo.DeleteDir(src); err != nil { + removeErr := fmt.Errorf("remove merged source [%s] failed: %w", src, err) + errs = append(errs, removeErr) + global.LOG.Errorf("%s", removeErr.Error()) } } } - if err := fo.Cut(m.OldPaths, m.NewPath, m.Name, m.Cover); err != nil { + if err := fo.CutWithContext(ctx, m.OldPaths, m.NewPath, m.Name, m.Cover); err != nil { return err } for _, snapshot := range snapshots { @@ -951,18 +1011,18 @@ func (f *FileService) MvFile(m request.FileMove) error { } } } - return nil + return aggregateFileMoveErrors(errs) } if m.Type == "copy" { for _, src := range m.OldPaths { - if err := fo.CopyAndReName(src, m.NewPath, m.Name, m.Cover); err != nil { + if err := fo.CopyAndReNameWithContext(ctx, src, m.NewPath, m.Name, m.Cover); err != nil { errs = append(errs, err) global.LOG.Errorf("copy file [%s] to [%s] failed, err: %s", src, m.NewPath, err.Error()) } } if len(m.CoverPaths) > 0 { for _, src := range m.CoverPaths { - if err := fo.CopyAndReName(src, m.NewPath, "", true); err != nil { + if err := fo.CopyAndReNameWithContext(ctx, src, m.NewPath, "", true); err != nil { errs = append(errs, err) global.LOG.Errorf("copy file [%s] to [%s] failed, err: %s", src, m.NewPath, err.Error()) } @@ -970,14 +1030,7 @@ func (f *FileService) MvFile(m request.FileMove) error { } } - var errString string - for _, err := range errs { - errString += err.Error() + "\n" - } - if errString != "" { - return errors.New(errString) - } - return nil + return aggregateFileMoveErrors(errs) } func readEditableFileHistoryContent(filePath string) ([]byte, os.FileMode, bool) { diff --git a/agent/app/service/file_transfer.go b/agent/app/service/file_transfer.go new file mode 100644 index 000000000000..bc5d1d303797 --- /dev/null +++ b/agent/app/service/file_transfer.go @@ -0,0 +1,77 @@ +package service + +import ( + "errors" + "path/filepath" + "strings" + "sync" + + "github.com/1Panel-dev/1Panel/agent/app/dto/request" +) + +type fileTransferLockSet struct { + mu sync.Mutex + paths map[string][]string +} + +func newFileTransferLocks() *fileTransferLockSet { + return &fileTransferLockSet{paths: make(map[string][]string)} +} + +func (s *fileTransferLockSet) Acquire(taskID string, transferPaths []string) bool { + s.mu.Lock() + defer s.mu.Unlock() + + for _, activePaths := range s.paths { + for _, activePath := range activePaths { + for _, transferPath := range transferPaths { + if fileTransferPathsOverlap(activePath, transferPath) { + return false + } + } + } + } + s.paths[taskID] = transferPaths + return true +} + +func (s *fileTransferLockSet) Release(taskID string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.paths, taskID) +} + +func getFileTransferPaths(req request.FileMove) []string { + paths := make([]string, 0, 1+len(req.OldPaths)+len(req.CoverPaths)) + paths = append(paths, req.NewPath) + paths = append(paths, req.OldPaths...) + paths = append(paths, req.CoverPaths...) + + unique := make(map[string]struct{}, len(paths)) + result := make([]string, 0, len(paths)) + for _, item := range paths { + item = filepath.Clean(item) + if _, ok := unique[item]; ok { + continue + } + unique[item] = struct{}{} + result = append(result, item) + } + return result +} + +func fileTransferPathsOverlap(first, second string) bool { + return first == second || strings.HasPrefix(first, second+string(filepath.Separator)) || strings.HasPrefix(second, first+string(filepath.Separator)) +} + +func aggregateFileMoveErrors(errs []error) error { + if len(errs) == 0 { + return nil + } + var errString strings.Builder + for _, err := range errs { + errString.WriteString(err.Error()) + errString.WriteByte('\n') + } + return errors.New(errString.String()) +} diff --git a/agent/router/ro_file.go b/agent/router/ro_file.go index c8658127e367..fe78b5b3471e 100644 --- a/agent/router/ro_file.go +++ b/agent/router/ro_file.go @@ -44,6 +44,7 @@ func (f *FileRouter) InitRouter(Router *gin.RouterGroup) { fileRouter.POST("/wget", baseApi.WgetFile) fileRouter.POST("/wget/stop", baseApi.StopWget) fileRouter.POST("/move", baseApi.MoveFile) + fileRouter.POST("/move/stop", baseApi.StopMoveFile) fileRouter.GET("/download", baseApi.Download) fileRouter.POST("/share/search", baseApi.SearchFileShare) fileRouter.POST("/share/detail", baseApi.GetFileShareDetail) diff --git a/agent/utils/files/file_op.go b/agent/utils/files/file_op.go index e9dc49747aa1..a54392b531bd 100644 --- a/agent/utils/files/file_op.go +++ b/agent/utils/files/file_op.go @@ -594,6 +594,12 @@ func (f FileOp) DownloadFile(url, dst string) error { } func (f FileOp) Cut(oldPaths []string, dst, name string, cover bool) error { + ctx, cancel := context.WithTimeout(context.Background(), cmdRecursiveTimeout) + defer cancel() + return f.CutWithContext(ctx, oldPaths, dst, name, cover) +} + +func (f FileOp) CutWithContext(ctx context.Context, oldPaths []string, dst, name string, cover bool) error { if len(oldPaths) == 0 { return nil } @@ -617,7 +623,7 @@ func (f FileOp) Cut(oldPaths []string, dst, name string, cover bool) error { } args = append(args, oldPaths...) args = append(args, dstPath) - if err := cmd.NewCommandMgr(cmd.WithTimeout(cmdRecursiveTimeout)).Run("mv", args...); err != nil { + if err := cmd.NewCommandMgr(cmd.WithContext(ctx)).Run("mv", args...); err != nil { return err } return nil diff --git a/frontend/src/api/interface/file.ts b/frontend/src/api/interface/file.ts index 886580aee063..55507fc1c94e 100644 --- a/frontend/src/api/interface/file.ts +++ b/frontend/src/api/interface/file.ts @@ -233,6 +233,11 @@ export namespace File { name?: string; cover?: boolean; coverPaths?: string[]; + taskID?: string; + } + + export interface FileMoveStopReq { + taskID: string; } export interface FileDownload { diff --git a/frontend/src/api/modules/files.ts b/frontend/src/api/modules/files.ts index 5c9ec2d6b137..94bca64c531d 100644 --- a/frontend/src/api/modules/files.ts +++ b/frontend/src/api/modules/files.ts @@ -146,7 +146,16 @@ export const stopWgetFile = (key: string) => { }; export const moveFile = (params: File.FileMove) => { - return http.post('files/move', params, TimeoutEnum.T_5M); + return http.post('files/move', params); +}; + +export const stopMoveFile = (taskID: string, currentNode?: string) => { + return http.post( + 'files/move/stop', + { taskID } as File.FileMoveStopReq, + undefined, + currentNode ? { CurrentNode: currentNode } : undefined, + ); }; export const downloadFile = (params: File.FileDownload) => { diff --git a/frontend/src/views/host/file-management/compress/index.vue b/frontend/src/views/host/file-management/compress/index.vue index 9fd2f949a09d..6d7cd8835705 100644 --- a/frontend/src/views/host/file-management/compress/index.vue +++ b/frontend/src/views/host/file-management/compress/index.vue @@ -143,6 +143,7 @@ const currentTaskID = ref(''); const taskInfo = ref(null); let taskTimer: ReturnType | null = null; const compressTaskKey = 'file-management-compress-task'; +const compressTypePreferenceKey = 'file-management-compress-type'; const em = defineEmits<{ (e: 'close', value: boolean): void; @@ -159,7 +160,9 @@ const extension = computed(() => { return CompressExtension[form.value.type]; }); -const isTaskExecuting = computed(() => taskInfo.value?.status === 'Executing'); +const isTaskExecuting = computed(() => { + return !!currentTaskID.value && (!taskInfo.value || taskInfo.value.status === 'Executing'); +}); const showTaskStatus = computed(() => Boolean(currentTaskID.value || loading.value || canceling.value || stopping.value), ); @@ -201,17 +204,19 @@ const loadTaskInfo = async () => { }, currentNode.value, ); - taskInfo.value = res.data.items?.[0] || null; + const item = res.data.items?.[0]; + if (!item) { + return; + } + taskInfo.value = item; emitTaskChange(); - if (!taskInfo.value || taskInfo.value.status !== 'Executing') { + if (taskInfo.value.status !== 'Executing') { stopTaskPolling(); resetDrawerState(); em('close', false); } } catch (error) { - stopTaskPolling(); - resetDrawerState(); - em('close', false); + console.error(error); } }; @@ -239,7 +244,6 @@ const resetDrawerState = () => { const closeDrawer = () => { if (currentTaskID.value && isTaskExecuting.value) { - stopTaskPolling(); open.value = false; em('close', false); return; @@ -298,6 +302,17 @@ const getLinkPath = (path: string) => { form.value.dst = path; }; +const restoreCompressTypePreference = () => { + const preferredType = localStorage.getItem(compressTypePreferenceKey); + if (preferredType && Object.values(CompressType).includes(preferredType as CompressType)) { + form.value.type = preferredType; + } +}; + +const saveCompressTypePreference = () => { + localStorage.setItem(compressTypePreferenceKey, form.value.type); +}; + const submit = async (formEl: FormInstance | undefined) => { if (!formEl) return; await formEl.validate((valid) => { @@ -316,6 +331,7 @@ const submit = async (formEl: FormInstance | undefined) => { signal: abortController.value?.signal, }) .then(() => { + saveCompressTypePreference(); currentTaskID.value = taskID; loading.value = false; taskInfo.value = null; @@ -345,14 +361,17 @@ const submit = async (formEl: FormInstance | undefined) => { }); }; -const acceptParams = (props: CompressProps) => { +const acceptParams = async (props: CompressProps) => { if (currentTaskID.value) { - open.value = true; - if (!taskTimer) { - startTaskPolling(); + await loadTaskInfo(); + if (currentTaskID.value) { + open.value = true; + if (!taskTimer) { + startTaskPolling(); + } + emitTaskChange(); + return; } - emitTaskChange(); - return; } form.value.files = props.files; @@ -393,9 +412,7 @@ const restoreRunningTask = () => { return; } currentTaskID.value = task.taskID; - if (task.status === 'Executing') { - emitTaskChange(); - } else { + if (task.status !== 'Executing') { localStorage.removeItem(compressTaskKey); } } catch { @@ -404,14 +421,16 @@ const restoreRunningTask = () => { }; onMounted(() => { + restoreCompressTypePreference(); restoreRunningTask(); + if (currentTaskID.value) { + startTaskPolling(); + } }); watch(open, (val) => { - if (val && currentTaskID.value) { + if (val && currentTaskID.value && !taskTimer) { startTaskPolling(); - } else { - stopTaskPolling(); } }); diff --git a/frontend/src/views/host/file-management/decompress/index.vue b/frontend/src/views/host/file-management/decompress/index.vue index 594a2433ca23..19e51bd9a454 100644 --- a/frontend/src/views/host/file-management/decompress/index.vue +++ b/frontend/src/views/host/file-management/decompress/index.vue @@ -141,7 +141,9 @@ const em = defineEmits<{ ): void; }>(); -const isTaskExecuting = computed(() => taskInfo.value?.status === 'Executing'); +const isTaskExecuting = computed(() => { + return !!currentTaskID.value && (!taskInfo.value || taskInfo.value.status === 'Executing'); +}); const showTaskStatus = computed(() => Boolean(currentTaskID.value || loading.value || canceling.value || stopping.value), ); @@ -183,17 +185,19 @@ const loadTaskInfo = async () => { }, currentNode.value, ); - taskInfo.value = res.data.items?.[0] || null; + const item = res.data.items?.[0]; + if (!item) { + return; + } + taskInfo.value = item; emitTaskChange(); - if (!taskInfo.value || taskInfo.value.status !== 'Executing') { + if (taskInfo.value.status !== 'Executing') { stopTaskPolling(); resetDrawerState(); em('close', false); } - } catch { - stopTaskPolling(); - resetDrawerState(); - em('close', false); + } catch (error) { + console.error(error); } }; @@ -221,7 +225,6 @@ const resetDrawerState = () => { const closeDrawer = () => { if (currentTaskID.value && isTaskExecuting.value) { - stopTaskPolling(); open.value = false; em('close', false); return; @@ -323,14 +326,17 @@ const submit = async (formEl: FormInstance | undefined) => { }); }; -const acceptParams = (props: DecompressProps) => { +const acceptParams = async (props: DecompressProps) => { if (currentTaskID.value) { - open.value = true; - if (!taskTimer) { - startTaskPolling(); + await loadTaskInfo(); + if (currentTaskID.value) { + open.value = true; + if (!taskTimer) { + startTaskPolling(); + } + emitTaskChange(); + return; } - emitTaskChange(); - return; } form.value.type = props.type; @@ -365,9 +371,7 @@ const restoreRunningTask = () => { return; } currentTaskID.value = task.taskID; - if (task.status === 'Executing') { - emitTaskChange(); - } else { + if (task.status !== 'Executing') { localStorage.removeItem(decompressTaskKey); } } catch { @@ -377,13 +381,14 @@ const restoreRunningTask = () => { onMounted(() => { restoreRunningTask(); + if (currentTaskID.value) { + startTaskPolling(); + } }); watch(open, (val) => { - if (val && currentTaskID.value) { + if (val && currentTaskID.value && !taskTimer) { startTaskPolling(); - } else { - stopTaskPolling(); } }); diff --git a/frontend/src/views/host/file-management/move/index.vue b/frontend/src/views/host/file-management/move/index.vue index 0c996de8ca44..fb3022909569 100644 --- a/frontend/src/views/host/file-management/move/index.vue +++ b/frontend/src/views/host/file-management/move/index.vue @@ -1,81 +1,132 @@