From e0e04e5a25de2842cca5c48f287973ae69e4800a Mon Sep 17 00:00:00 2001 From: ssongliu Date: Thu, 12 Mar 2026 17:26:07 +0800 Subject: [PATCH] feat: support file operations inside containers --- agent/app/api/v2/container.go | 159 +++++ agent/app/dto/container.go | 28 + agent/app/service/container.go | 448 +++++++++++++++ agent/cmd/server/docs/x-log.json | 30 + agent/router/ro_container.go | 6 + core/cmd/server/docs/docs.go | 543 ++++++++++++++++++ core/cmd/server/docs/swagger.json | 543 ++++++++++++++++++ core/cmd/server/docs/x-log.json | 30 + core/middleware/operation.go | 36 +- frontend/src/api/interface/container.ts | 20 + frontend/src/api/modules/container.ts | 24 + .../container/file-browser/index.vue | 342 +++++++++++ .../src/views/container/container/index.vue | 16 + 13 files changed, 2219 insertions(+), 6 deletions(-) create mode 100644 frontend/src/views/container/container/file-browser/index.vue diff --git a/agent/app/api/v2/container.go b/agent/app/api/v2/container.go index c031b2952ce8..97b9632867ce 100644 --- a/agent/app/api/v2/container.go +++ b/agent/app/api/v2/container.go @@ -1,6 +1,9 @@ package v2 import ( + "net/http" + "net/url" + "path" "strconv" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" @@ -53,6 +56,162 @@ func (b *BaseApi) LoadContainerUsers(c *gin.Context) { helper.SuccessWithData(c, containerService.LoadUsers(req)) } +// @Tags Container +// @Summary List container files +// @Accept json +// @Param request body dto.ContainerFileReq true "request" +// @Success 200 {array} dto.ContainerFileInfo +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /containers/files/search [post] +func (b *BaseApi) ListContainerFiles(c *gin.Context) { + var req dto.ContainerFileReq + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + files, err := containerService.ListContainerFiles(req) + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, files) +} + +// @Tags Container +// @Summary Upload container file +// @Accept multipart/form-data +// @Param containerID formData string true "containerID" +// @Param path formData string true "path" +// @Param file formData file true "file" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /containers/files/upload [post] +// @x-panel-log {"bodyKeys":["containerID","path"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"容器 [containerID] 上传文件到 [path]","formatEN":"Upload file to [path] in container [containerID]"} +func (b *BaseApi) UploadContainerFile(c *gin.Context) { + form, err := c.MultipartForm() + if err != nil { + helper.BadRequest(c, err) + return + } + containerIDs := form.Value["containerID"] + paths := form.Value["path"] + uploadFiles := form.File["file"] + if len(containerIDs) == 0 || len(paths) == 0 || len(uploadFiles) == 0 { + helper.BadRequest(c, errors.New("invalid container file upload params")) + return + } + req := dto.ContainerFileReq{ + ContainerID: containerIDs[0], + Path: paths[0], + } + for _, uploadFile := range uploadFiles { + file, err := uploadFile.Open() + if err != nil { + helper.InternalServer(c, err) + return + } + err = containerService.UploadContainerFile(req, path.Base(uploadFile.Filename), uploadFile.Size, file) + _ = file.Close() + if err != nil { + helper.InternalServer(c, err) + return + } + } + helper.Success(c) +} + +// @Tags Container +// @Summary Get container file content +// @Accept json +// @Param request body dto.ContainerFileReq true "request" +// @Success 200 {object} dto.ContainerFileContent +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /containers/files/content [post] +func (b *BaseApi) GetContainerFileContent(c *gin.Context) { + var req dto.ContainerFileReq + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + content, err := containerService.GetContainerFileContent(req) + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, content) +} + +// @Tags Container +// @Summary Get container file size +// @Accept json +// @Param request body dto.ContainerFileReq true "request" +// @Success 200 {int} size +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /containers/files/size [post] +func (b *BaseApi) GetContainerFileSize(c *gin.Context) { + var req dto.ContainerFileReq + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + size, err := containerService.GetContainerFileSize(req) + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, size) +} + +// @Tags Container +// @Summary Delete container file +// @Accept json +// @Param request body dto.ContainerFileBatchDeleteReq true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /containers/files/del [post] +// @x-panel-log {"bodyKeys":["containerID","paths"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除容器 [containerID] 文件 [paths]","formatEN":"Delete files [paths] in container [containerID]"} +func (b *BaseApi) DeleteContainerFile(c *gin.Context) { + var req dto.ContainerFileBatchDeleteReq + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := containerService.DeleteContainerFile(req); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} + +// @Tags Container +// @Summary Download container file +// @Accept json +// @Param request body dto.ContainerFileReq true "request" +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /containers/files/download [post] +// @x-panel-log {"bodyKeys":["containerID","path"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"下载容器 [containerID] 文件 [path]","formatEN":"Download file [path] from container [containerID]"} +func (b *BaseApi) DownloadContainerFile(c *gin.Context) { + var req dto.ContainerFileReq + if err := c.ShouldBindJSON(&req); err != nil { + helper.BadRequest(c, err) + return + } + if req.ContainerID == "" || req.Path == "" { + helper.BadRequest(c, errors.New("invalid container file download params")) + return + } + reader, fileName, contentType, err := containerService.DownloadContainerFile(req) + if err != nil { + helper.InternalServer(c, err) + return + } + defer reader.Close() + c.Header("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(fileName)) + c.DataFromReader(http.StatusOK, -1, contentType, reader, nil) +} + // @Tags Container // @Summary List containers // @Accept json diff --git a/agent/app/dto/container.go b/agent/app/dto/container.go index f0e507563bb1..b19be1c9638c 100644 --- a/agent/app/dto/container.go +++ b/agent/app/dto/container.go @@ -48,6 +48,34 @@ type ContainerOptions struct { State string `json:"state"` } +type ContainerFileReq struct { + ContainerID string `json:"containerID" validate:"required"` + Path string `json:"path" validate:"required"` +} + +type ContainerFileBatchDeleteReq struct { + ContainerID string `json:"containerID" validate:"required"` + Paths []string `json:"paths" validate:"required,min=1,dive,required"` +} + +type ContainerFileInfo struct { + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"isDir"` + IsLink bool `json:"isLink"` + LinkTo string `json:"linkTo"` + Size int64 `json:"size"` + Mode string `json:"mode"` + ModTime string `json:"modTime"` +} + +type ContainerFileContent struct { + Content string `json:"content"` + Size int64 `json:"size"` + Truncated bool `json:"truncated"` + IsBinary bool `json:"isBinary"` +} + type ContainerStatus struct { Created int `json:"created"` Running int `json:"running"` diff --git a/agent/app/service/container.go b/agent/app/service/container.go index afb8f7afe1f7..34c4a289de42 100644 --- a/agent/app/service/container.go +++ b/agent/app/service/container.go @@ -1,7 +1,9 @@ package service import ( + "archive/tar" "bufio" + "bytes" "context" "encoding/base64" "encoding/json" @@ -11,6 +13,7 @@ import ( "net/url" "os" "os/exec" + "path" "path/filepath" "sort" "strconv" @@ -40,6 +43,7 @@ import ( "github.com/docker/docker/api/types/registry" "github.com/docker/docker/api/types/volume" "github.com/docker/docker/client" + "github.com/docker/docker/pkg/stdcopy" "github.com/docker/go-connections/nat" "github.com/gin-gonic/gin" v1 "github.com/opencontainers/image-spec/specs-go/v1" @@ -89,6 +93,12 @@ type IContainerService interface { Prune(req dto.ContainerPrune) error LoadUsers(req dto.OperationWithName) []string + ListContainerFiles(req dto.ContainerFileReq) ([]dto.ContainerFileInfo, error) + UploadContainerFile(req dto.ContainerFileReq, fileName string, fileSize int64, file io.Reader) error + GetContainerFileContent(req dto.ContainerFileReq) (*dto.ContainerFileContent, error) + GetContainerFileSize(req dto.ContainerFileReq) (int64, error) + DeleteContainerFile(req dto.ContainerFileBatchDeleteReq) error + DownloadContainerFile(req dto.ContainerFileReq) (io.ReadCloser, string, string, error) StreamLogs(ctx *gin.Context, params dto.StreamLog) } @@ -1166,6 +1176,444 @@ func (u *ContainerService) LoadUsers(req dto.OperationWithName) []string { return users } +func (u *ContainerService) ListContainerFiles(req dto.ContainerFileReq) ([]dto.ContainerFileInfo, error) { + if len(req.Path) == 0 { + req.Path = "/" + } + cli, err := docker.NewDockerClient() + if err != nil { + return nil, err + } + defer cli.Close() + + ctx := context.Background() + stat, err := cli.ContainerStatPath(ctx, req.ContainerID, req.Path) + if err != nil { + return nil, err + } + isDir := stat.Mode.IsDir() + isLink := stat.Mode&os.ModeSymlink != 0 + if isLink && !isDir { + linkDir, linkErr := isContainerDir(cli, req.ContainerID, req.Path) + if linkErr == nil { + isDir = linkDir + } + } + if !isDir { + return []dto.ContainerFileInfo{toContainerFileInfo(req.Path, stat, isDir)}, nil + } + + output, err := runContainerCommand(cli, req.ContainerID, []string{"ls", "-1A", "--", req.Path}) + if err != nil { + return nil, err + } + lines := strings.Split(strings.TrimSpace(output), "\n") + files := make([]dto.ContainerFileInfo, 0, len(lines)) + for _, line := range lines { + name := strings.TrimSpace(line) + if len(name) == 0 || name == "." || name == ".." { + continue + } + childPath := req.Path + if childPath == "/" { + childPath = "/" + name + } else { + childPath = strings.TrimSuffix(childPath, "/") + "/" + name + } + childStat, statErr := cli.ContainerStatPath(ctx, req.ContainerID, childPath) + if statErr != nil { + continue + } + childIsDir := childStat.Mode.IsDir() + if childStat.Mode&os.ModeSymlink != 0 && !childIsDir { + linkDir, linkErr := isContainerDir(cli, req.ContainerID, childPath) + if linkErr == nil { + childIsDir = linkDir + } + } + files = append(files, toContainerFileInfo(childPath, childStat, childIsDir)) + } + sort.Slice(files, func(i, j int) bool { + if files[i].IsDir != files[j].IsDir { + return files[i].IsDir + } + return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) + }) + return files, nil +} + +func (u *ContainerService) DeleteContainerFile(req dto.ContainerFileBatchDeleteReq) error { + for _, item := range req.Paths { + if strings.TrimSpace(item) == "/" { + return buserr.New("ErrPathNotDelete") + } + } + cli, err := docker.NewDockerClient() + if err != nil { + return err + } + defer cli.Close() + + command := []string{"rm", "-rf", "--"} + command = append(command, req.Paths...) + _, err = runContainerCommand(cli, req.ContainerID, command) + return err +} + +func (u *ContainerService) UploadContainerFile(req dto.ContainerFileReq, fileName string, fileSize int64, file io.Reader) error { + if len(req.Path) == 0 { + req.Path = "/" + } + safeName := path.Base(fileName) + if safeName == "." || safeName == "/" || len(safeName) == 0 { + return buserr.New("ErrInvalidChar") + } + + cli, err := docker.NewDockerClient() + if err != nil { + return err + } + defer cli.Close() + + ctx := context.Background() + stat, err := cli.ContainerStatPath(ctx, req.ContainerID, req.Path) + if err != nil { + if _, mkErr := runContainerCommand(cli, req.ContainerID, []string{"mkdir", "-p", "--", req.Path}); mkErr != nil { + return mkErr + } + stat, err = cli.ContainerStatPath(ctx, req.ContainerID, req.Path) + if err != nil { + return err + } + } + if !stat.Mode.IsDir() { + return fmt.Errorf("path %s is not directory", req.Path) + } + + pipeReader, pipeWriter := io.Pipe() + writeErr := make(chan error, 1) + go func() { + tw := tar.NewWriter(pipeWriter) + header := &tar.Header{ + Name: safeName, + Mode: 0644, + Size: fileSize, + ModTime: time.Now(), + } + if err := tw.WriteHeader(header); err != nil { + _ = tw.Close() + _ = pipeWriter.CloseWithError(err) + writeErr <- err + return + } + if _, err := io.Copy(tw, file); err != nil { + _ = tw.Close() + _ = pipeWriter.CloseWithError(err) + writeErr <- err + return + } + if err := tw.Close(); err != nil { + _ = pipeWriter.CloseWithError(err) + writeErr <- err + return + } + _ = pipeWriter.Close() + writeErr <- nil + }() + + err = cli.CopyToContainer(ctx, req.ContainerID, req.Path, pipeReader, container.CopyToContainerOptions{ + CopyUIDGID: true, + }) + if err != nil { + _ = pipeReader.CloseWithError(err) + _ = pipeWriter.CloseWithError(err) + <-writeErr + return err + } + if err := <-writeErr; err != nil { + return err + } + return nil +} + +func (u *ContainerService) GetContainerFileContent(req dto.ContainerFileReq) (*dto.ContainerFileContent, error) { + if len(req.Path) == 0 { + return nil, buserr.New("ErrInvalidChar") + } + cli, err := docker.NewDockerClient() + if err != nil { + return nil, err + } + defer cli.Close() + + stat, err := cli.ContainerStatPath(context.Background(), req.ContainerID, req.Path) + if err != nil { + return nil, err + } + if stat.Mode.IsDir() { + return nil, fmt.Errorf("path %s is directory", req.Path) + } + + content := &dto.ContainerFileContent{Size: stat.Size} + headBytes, err := runContainerCommandRaw(cli, req.ContainerID, []string{"head", "-c", "4096", "--", req.Path}) + if err != nil { + return nil, err + } + if bytes.IndexByte(headBytes, 0) >= 0 { + content.IsBinary = true + return content, nil + } + + const inlinePreviewMax = 512 * 1024 + if stat.Size <= inlinePreviewMax { + raw, err := runContainerCommandRaw(cli, req.ContainerID, []string{"cat", "--", req.Path}) + if err != nil { + return nil, err + } + content.Content = string(raw) + return content, nil + } + + raw, err := runContainerCommandRaw(cli, req.ContainerID, []string{"tail", "-n", "300", "--", req.Path}) + if err != nil { + return nil, err + } + content.Content = string(raw) + content.Truncated = true + return content, nil +} + +func (u *ContainerService) GetContainerFileSize(req dto.ContainerFileReq) (int64, error) { + if len(req.Path) == 0 { + return 0, buserr.New("ErrInvalidChar") + } + cli, err := docker.NewDockerClient() + if err != nil { + return 0, err + } + defer cli.Close() + + stat, err := cli.ContainerStatPath(context.Background(), req.ContainerID, req.Path) + if err != nil { + return 0, err + } + if !stat.Mode.IsDir() { + return stat.Size, nil + } + output, err := runContainerCommand(cli, req.ContainerID, []string{"du", "-sb", "--", req.Path}) + if err != nil { + return 0, err + } + parts := strings.Fields(output) + if len(parts) == 0 { + return 0, fmt.Errorf("invalid du output") + } + size, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return 0, err + } + return size, nil +} + +func (u *ContainerService) DownloadContainerFile(req dto.ContainerFileReq) (io.ReadCloser, string, string, error) { + if len(req.Path) == 0 { + req.Path = "/" + } + cli, err := docker.NewDockerClient() + if err != nil { + return nil, "", "", err + } + + ctx := context.Background() + stat, err := cli.ContainerStatPath(ctx, req.ContainerID, req.Path) + if err != nil { + _ = cli.Close() + return nil, "", "", err + } + + fileName := stat.Name + if len(fileName) == 0 { + fileName = "container-file" + } + if stat.Mode.IsDir() { + if _, err := runContainerCommand(cli, req.ContainerID, []string{"sh", "-c", "command -v tar >/dev/null 2>&1"}); err != nil { + _ = cli.Close() + return nil, "", "", fmt.Errorf("tar command not found in container") + } + + targetPath := path.Clean(req.Path) + parentPath := path.Dir(targetPath) + targetName := path.Base(targetPath) + if parentPath == "." || parentPath == "" { + parentPath = "/" + } + tarStream, err := runContainerCommandStream(cli, req.ContainerID, []string{ + "tar", "-czf", "-", "-C", parentPath, "--", targetName, + }) + if err != nil { + _ = cli.Close() + return nil, "", "", err + } + if !strings.HasSuffix(fileName, ".tar.gz") { + fileName += ".tar.gz" + } + return &closeHookReader{ + ReadCloser: tarStream, + onClose: cli.Close, + }, fileName, "application/gzip", nil + } + + fileStream, err := runContainerCommandStream(cli, req.ContainerID, []string{"cat", "--", req.Path}) + if err != nil { + _ = cli.Close() + return nil, "", "", err + } + return &closeHookReader{ + ReadCloser: fileStream, + onClose: cli.Close, + }, fileName, "application/octet-stream", nil +} + +func runContainerCommand(cli *client.Client, containerID string, command []string) (string, error) { + raw, err := runContainerCommandRaw(cli, containerID, command) + if err != nil { + return "", err + } + return strings.TrimSpace(string(raw)), nil +} + +type closeHookReader struct { + io.ReadCloser + onClose func() error +} + +func (r *closeHookReader) Close() error { + var closeErr error + if r.ReadCloser != nil { + closeErr = r.ReadCloser.Close() + } + if r.onClose != nil { + if err := r.onClose(); err != nil && closeErr == nil { + closeErr = err + } + } + return closeErr +} + +func runContainerCommandRaw(cli *client.Client, containerID string, command []string) ([]byte, error) { + ctx := context.Background() + resp, err := cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{ + Cmd: command, + AttachStdout: true, + AttachStderr: true, + }) + if err != nil { + return nil, err + } + hijack, err := cli.ContainerExecAttach(ctx, resp.ID, container.ExecAttachOptions{}) + if err != nil { + return nil, err + } + defer hijack.Close() + + raw, err := io.ReadAll(hijack.Reader) + if err != nil { + return nil, err + } + var stdout bytes.Buffer + var stderr bytes.Buffer + if _, err := stdcopy.StdCopy(&stdout, &stderr, bytes.NewReader(raw)); err != nil { + return nil, err + } + output := strings.TrimSpace(stdout.String()) + errorOutput := strings.TrimSpace(stderr.String()) + info, err := cli.ContainerExecInspect(ctx, resp.ID) + if err != nil { + return nil, err + } + if info.ExitCode != 0 { + if len(errorOutput) != 0 { + return nil, fmt.Errorf("%s", errorOutput) + } + if len(output) == 0 { + return nil, fmt.Errorf("container command failed with exit code %d", info.ExitCode) + } + return nil, fmt.Errorf("%s", output) + } + return stdout.Bytes(), nil +} + +func runContainerCommandStream(cli *client.Client, containerID string, command []string) (io.ReadCloser, error) { + ctx := context.Background() + resp, err := cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{ + Cmd: command, + AttachStdout: true, + AttachStderr: true, + }) + if err != nil { + return nil, err + } + hijack, err := cli.ContainerExecAttach(ctx, resp.ID, container.ExecAttachOptions{}) + if err != nil { + return nil, err + } + + pipeReader, pipeWriter := io.Pipe() + go func() { + defer hijack.Close() + var stderr bytes.Buffer + _, copyErr := stdcopy.StdCopy(pipeWriter, &stderr, hijack.Reader) + if copyErr != nil { + _ = pipeWriter.CloseWithError(copyErr) + return + } + info, inspectErr := cli.ContainerExecInspect(ctx, resp.ID) + if inspectErr != nil { + _ = pipeWriter.CloseWithError(inspectErr) + return + } + if info.ExitCode != 0 { + msg := strings.TrimSpace(stderr.String()) + if len(msg) == 0 { + msg = fmt.Sprintf("container command failed with exit code %d", info.ExitCode) + } + _ = pipeWriter.CloseWithError(fmt.Errorf("%s", msg)) + return + } + _ = pipeWriter.Close() + }() + return pipeReader, nil +} + +func toContainerFileInfo(filePath string, stat container.PathStat, isDir bool) dto.ContainerFileInfo { + name := stat.Name + if len(name) == 0 { + items := strings.Split(strings.TrimSuffix(filePath, "/"), "/") + name = items[len(items)-1] + } + isLink := stat.Mode&os.ModeSymlink != 0 + return dto.ContainerFileInfo{ + Name: name, + Path: filePath, + IsDir: isDir, + IsLink: isLink, + LinkTo: stat.LinkTarget, + Size: stat.Size, + Mode: stat.Mode.String(), + ModTime: stat.Mtime.Format(constant.DateTimeLayout), + } +} + +func isContainerDir(cli *client.Client, containerID, targetPath string) (bool, error) { + _, err := runContainerCommand(cli, containerID, []string{ + "sh", "-c", "[ -d \"$1\" ]", "sh", targetPath, + }) + if err != nil { + return false, err + } + return true, nil +} + func stringsToMap(list []string) map[string]string { var labelMap = make(map[string]string) for _, label := range list { diff --git a/agent/cmd/server/docs/x-log.json b/agent/cmd/server/docs/x-log.json index 9b862e303df1..f13e74089fbc 100644 --- a/agent/cmd/server/docs/x-log.json +++ b/agent/cmd/server/docs/x-log.json @@ -368,6 +368,36 @@ "formatZH": "docker 服务 [operation]", "formatEN": "[operation] docker service" }, + "/containers/files/del": { + "bodyKeys": [ + "containerID", + "paths" + ], + "paramKeys": [], + "beforeFunctions": [], + "formatZH": "删除容器 [containerID] 文件 [paths]", + "formatEN": "Delete files [paths] in container [containerID]" + }, + "/containers/files/download": { + "bodyKeys": [ + "containerID", + "path" + ], + "paramKeys": [], + "beforeFunctions": [], + "formatZH": "下载容器 [containerID] 文件 [path]", + "formatEN": "Download file [path] from container [containerID]" + }, + "/containers/files/upload": { + "bodyKeys": [ + "containerID", + "path" + ], + "paramKeys": [], + "beforeFunctions": [], + "formatZH": "容器 [containerID] 上传文件到 [path]", + "formatEN": "Upload file to [path] in container [containerID]" + }, "/containers/image/build": { "bodyKeys": [ "name" diff --git a/agent/router/ro_container.go b/agent/router/ro_container.go index b997b551ba7d..dbbc82f5ad62 100644 --- a/agent/router/ro_container.go +++ b/agent/router/ro_container.go @@ -35,6 +35,12 @@ func (s *ContainerRouter) InitRouter(Router *gin.RouterGroup) { baRouter.POST("/prune", baseApi.ContainerPrune) baRouter.POST("/users", baseApi.LoadContainerUsers) + baRouter.POST("/files/search", baseApi.ListContainerFiles) + baRouter.POST("/files/upload", baseApi.UploadContainerFile) + baRouter.POST("/files/content", baseApi.GetContainerFileContent) + baRouter.POST("/files/size", baseApi.GetContainerFileSize) + baRouter.POST("/files/del", baseApi.DeleteContainerFile) + baRouter.POST("/files/download", baseApi.DownloadContainerFile) baRouter.GET("/repo", baseApi.ListRepo) baRouter.POST("/repo/status", baseApi.CheckRepoStatus) diff --git a/core/cmd/server/docs/docs.go b/core/cmd/server/docs/docs.go index 626880567e61..09ac51ce84e1 100644 --- a/core/cmd/server/docs/docs.go +++ b/core/cmd/server/docs/docs.go @@ -523,6 +523,79 @@ const docTemplate = `{ ] } }, + "/ai/agents/channel/qqbot/get": { + "post": { + "consumes": [ + "application/json" + ], + "parameters": [ + { + "description": "request", + "in": "body", + "name": "request", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AgentQQBotConfigReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.AgentQQBotConfig" + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "Timestamp": [] + } + ], + "summary": "Get Agent QQ Bot channel config", + "tags": [ + "AI" + ] + } + }, + "/ai/agents/channel/qqbot/update": { + "post": { + "consumes": [ + "application/json" + ], + "parameters": [ + { + "description": "request", + "in": "body", + "name": "request", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AgentQQBotConfigUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "Timestamp": [] + } + ], + "summary": "Update Agent QQ Bot channel config", + "tags": [ + "AI" + ] + } + }, "/ai/agents/channel/telegram/get": { "post": { "consumes": [ @@ -739,6 +812,79 @@ const docTemplate = `{ ] } }, + "/ai/agents/plugin/check": { + "post": { + "consumes": [ + "application/json" + ], + "parameters": [ + { + "description": "request", + "in": "body", + "name": "request", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AgentPluginCheckReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.AgentPluginStatus" + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "Timestamp": [] + } + ], + "summary": "Check Agent plugin installation status", + "tags": [ + "AI" + ] + } + }, + "/ai/agents/plugin/install": { + "post": { + "consumes": [ + "application/json" + ], + "parameters": [ + { + "description": "request", + "in": "body", + "name": "request", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AgentPluginInstallReq" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "Timestamp": [] + } + ], + "summary": "Install Agent plugin", + "tags": [ + "AI" + ] + } + }, "/ai/agents/providers": { "get": { "responses": { @@ -4095,6 +4241,225 @@ const docTemplate = `{ ] } }, + "/containers/files/content": { + "post": { + "consumes": [ + "application/json" + ], + "parameters": [ + { + "description": "request", + "in": "body", + "name": "request", + "required": true, + "schema": { + "$ref": "#/definitions/dto.ContainerFileReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.ContainerFileContent" + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "Timestamp": [] + } + ], + "summary": "Get container file content", + "tags": [ + "Container" + ] + } + }, + "/containers/files/del": { + "post": { + "consumes": [ + "application/json" + ], + "parameters": [ + { + "description": "request", + "in": "body", + "name": "request", + "required": true, + "schema": { + "$ref": "#/definitions/dto.ContainerFileBatchDeleteReq" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "Timestamp": [] + } + ], + "summary": "Delete container file", + "tags": [ + "Container" + ] + } + }, + "/containers/files/download": { + "post": { + "consumes": [ + "application/json" + ], + "responses": {}, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "Timestamp": [] + } + ], + "summary": "Download container file", + "tags": [ + "Container" + ] + } + }, + "/containers/files/search": { + "post": { + "consumes": [ + "application/json" + ], + "parameters": [ + { + "description": "request", + "in": "body", + "name": "request", + "required": true, + "schema": { + "$ref": "#/definitions/dto.ContainerFileReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "items": { + "$ref": "#/definitions/dto.ContainerFileInfo" + }, + "type": "array" + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "Timestamp": [] + } + ], + "summary": "List container files", + "tags": [ + "Container" + ] + } + }, + "/containers/files/size": { + "post": { + "consumes": [ + "application/json" + ], + "parameters": [ + { + "description": "request", + "in": "body", + "name": "request", + "required": true, + "schema": { + "$ref": "#/definitions/dto.ContainerFileReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "int" + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "Timestamp": [] + } + ], + "summary": "Get container file size", + "tags": [ + "Container" + ] + } + }, + "/containers/files/upload": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "parameters": [ + { + "description": "containerID", + "in": "formData", + "name": "containerID", + "required": true, + "type": "string" + }, + { + "description": "path", + "in": "formData", + "name": "path", + "required": true, + "type": "string" + }, + { + "description": "file", + "in": "formData", + "name": "file", + "required": true, + "type": "file" + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "Timestamp": [] + } + ], + "summary": "Upload container file", + "tags": [ + "Container" + ] + } + }, "/containers/image": { "get": { "produces": [ @@ -23746,6 +24111,104 @@ const docTemplate = `{ ], "type": "object" }, + "dto.AgentPluginCheckReq": { + "properties": { + "agentId": { + "type": "integer" + }, + "type": { + "enum": [ + "qqbot" + ], + "type": "string" + } + }, + "required": [ + "agentId", + "type" + ], + "type": "object" + }, + "dto.AgentPluginInstallReq": { + "properties": { + "agentId": { + "type": "integer" + }, + "taskID": { + "type": "string" + }, + "type": { + "enum": [ + "qqbot" + ], + "type": "string" + } + }, + "required": [ + "agentId", + "taskID", + "type" + ], + "type": "object" + }, + "dto.AgentPluginStatus": { + "properties": { + "installed": { + "type": "boolean" + } + }, + "type": "object" + }, + "dto.AgentQQBotConfig": { + "properties": { + "appId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "installed": { + "type": "boolean" + } + }, + "type": "object" + }, + "dto.AgentQQBotConfigReq": { + "properties": { + "agentId": { + "type": "integer" + } + }, + "required": [ + "agentId" + ], + "type": "object" + }, + "dto.AgentQQBotConfigUpdateReq": { + "properties": { + "agentId": { + "type": "integer" + }, + "appId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "agentId", + "appId", + "clientSecret" + ], + "type": "object" + }, "dto.AgentTelegramConfig": { "properties": { "botToken": { @@ -25035,6 +25498,86 @@ const docTemplate = `{ ], "type": "object" }, + "dto.ContainerFileBatchDeleteReq": { + "properties": { + "containerID": { + "type": "string" + }, + "paths": { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "containerID", + "paths" + ], + "type": "object" + }, + "dto.ContainerFileContent": { + "properties": { + "content": { + "type": "string" + }, + "isBinary": { + "type": "boolean" + }, + "size": { + "type": "integer" + }, + "truncated": { + "type": "boolean" + } + }, + "type": "object" + }, + "dto.ContainerFileInfo": { + "properties": { + "isDir": { + "type": "boolean" + }, + "isLink": { + "type": "boolean" + }, + "linkTo": { + "type": "string" + }, + "modTime": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "size": { + "type": "integer" + } + }, + "type": "object" + }, + "dto.ContainerFileReq": { + "properties": { + "containerID": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "containerID", + "path" + ], + "type": "object" + }, "dto.ContainerItemStats": { "properties": { "buildCacheReclaimable": { diff --git a/core/cmd/server/docs/swagger.json b/core/cmd/server/docs/swagger.json index 25df6df050dc..03a3f54d801c 100644 --- a/core/cmd/server/docs/swagger.json +++ b/core/cmd/server/docs/swagger.json @@ -519,6 +519,79 @@ ] } }, + "/ai/agents/channel/qqbot/get": { + "post": { + "consumes": [ + "application/json" + ], + "parameters": [ + { + "description": "request", + "in": "body", + "name": "request", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AgentQQBotConfigReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.AgentQQBotConfig" + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "Timestamp": [] + } + ], + "summary": "Get Agent QQ Bot channel config", + "tags": [ + "AI" + ] + } + }, + "/ai/agents/channel/qqbot/update": { + "post": { + "consumes": [ + "application/json" + ], + "parameters": [ + { + "description": "request", + "in": "body", + "name": "request", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AgentQQBotConfigUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "Timestamp": [] + } + ], + "summary": "Update Agent QQ Bot channel config", + "tags": [ + "AI" + ] + } + }, "/ai/agents/channel/telegram/get": { "post": { "consumes": [ @@ -735,6 +808,79 @@ ] } }, + "/ai/agents/plugin/check": { + "post": { + "consumes": [ + "application/json" + ], + "parameters": [ + { + "description": "request", + "in": "body", + "name": "request", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AgentPluginCheckReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.AgentPluginStatus" + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "Timestamp": [] + } + ], + "summary": "Check Agent plugin installation status", + "tags": [ + "AI" + ] + } + }, + "/ai/agents/plugin/install": { + "post": { + "consumes": [ + "application/json" + ], + "parameters": [ + { + "description": "request", + "in": "body", + "name": "request", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AgentPluginInstallReq" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "Timestamp": [] + } + ], + "summary": "Install Agent plugin", + "tags": [ + "AI" + ] + } + }, "/ai/agents/providers": { "get": { "responses": { @@ -4091,6 +4237,225 @@ ] } }, + "/containers/files/content": { + "post": { + "consumes": [ + "application/json" + ], + "parameters": [ + { + "description": "request", + "in": "body", + "name": "request", + "required": true, + "schema": { + "$ref": "#/definitions/dto.ContainerFileReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.ContainerFileContent" + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "Timestamp": [] + } + ], + "summary": "Get container file content", + "tags": [ + "Container" + ] + } + }, + "/containers/files/del": { + "post": { + "consumes": [ + "application/json" + ], + "parameters": [ + { + "description": "request", + "in": "body", + "name": "request", + "required": true, + "schema": { + "$ref": "#/definitions/dto.ContainerFileBatchDeleteReq" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "Timestamp": [] + } + ], + "summary": "Delete container file", + "tags": [ + "Container" + ] + } + }, + "/containers/files/download": { + "post": { + "consumes": [ + "application/json" + ], + "responses": {}, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "Timestamp": [] + } + ], + "summary": "Download container file", + "tags": [ + "Container" + ] + } + }, + "/containers/files/search": { + "post": { + "consumes": [ + "application/json" + ], + "parameters": [ + { + "description": "request", + "in": "body", + "name": "request", + "required": true, + "schema": { + "$ref": "#/definitions/dto.ContainerFileReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "items": { + "$ref": "#/definitions/dto.ContainerFileInfo" + }, + "type": "array" + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "Timestamp": [] + } + ], + "summary": "List container files", + "tags": [ + "Container" + ] + } + }, + "/containers/files/size": { + "post": { + "consumes": [ + "application/json" + ], + "parameters": [ + { + "description": "request", + "in": "body", + "name": "request", + "required": true, + "schema": { + "$ref": "#/definitions/dto.ContainerFileReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "int" + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "Timestamp": [] + } + ], + "summary": "Get container file size", + "tags": [ + "Container" + ] + } + }, + "/containers/files/upload": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "parameters": [ + { + "description": "containerID", + "in": "formData", + "name": "containerID", + "required": true, + "type": "string" + }, + { + "description": "path", + "in": "formData", + "name": "path", + "required": true, + "type": "string" + }, + { + "description": "file", + "in": "formData", + "name": "file", + "required": true, + "type": "file" + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "Timestamp": [] + } + ], + "summary": "Upload container file", + "tags": [ + "Container" + ] + } + }, "/containers/image": { "get": { "produces": [ @@ -23742,6 +24107,104 @@ ], "type": "object" }, + "dto.AgentPluginCheckReq": { + "properties": { + "agentId": { + "type": "integer" + }, + "type": { + "enum": [ + "qqbot" + ], + "type": "string" + } + }, + "required": [ + "agentId", + "type" + ], + "type": "object" + }, + "dto.AgentPluginInstallReq": { + "properties": { + "agentId": { + "type": "integer" + }, + "taskID": { + "type": "string" + }, + "type": { + "enum": [ + "qqbot" + ], + "type": "string" + } + }, + "required": [ + "agentId", + "taskID", + "type" + ], + "type": "object" + }, + "dto.AgentPluginStatus": { + "properties": { + "installed": { + "type": "boolean" + } + }, + "type": "object" + }, + "dto.AgentQQBotConfig": { + "properties": { + "appId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "installed": { + "type": "boolean" + } + }, + "type": "object" + }, + "dto.AgentQQBotConfigReq": { + "properties": { + "agentId": { + "type": "integer" + } + }, + "required": [ + "agentId" + ], + "type": "object" + }, + "dto.AgentQQBotConfigUpdateReq": { + "properties": { + "agentId": { + "type": "integer" + }, + "appId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "agentId", + "appId", + "clientSecret" + ], + "type": "object" + }, "dto.AgentTelegramConfig": { "properties": { "botToken": { @@ -25031,6 +25494,86 @@ ], "type": "object" }, + "dto.ContainerFileBatchDeleteReq": { + "properties": { + "containerID": { + "type": "string" + }, + "paths": { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "containerID", + "paths" + ], + "type": "object" + }, + "dto.ContainerFileContent": { + "properties": { + "content": { + "type": "string" + }, + "isBinary": { + "type": "boolean" + }, + "size": { + "type": "integer" + }, + "truncated": { + "type": "boolean" + } + }, + "type": "object" + }, + "dto.ContainerFileInfo": { + "properties": { + "isDir": { + "type": "boolean" + }, + "isLink": { + "type": "boolean" + }, + "linkTo": { + "type": "string" + }, + "modTime": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "size": { + "type": "integer" + } + }, + "type": "object" + }, + "dto.ContainerFileReq": { + "properties": { + "containerID": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "containerID", + "path" + ], + "type": "object" + }, "dto.ContainerItemStats": { "properties": { "buildCacheReclaimable": { diff --git a/core/cmd/server/docs/x-log.json b/core/cmd/server/docs/x-log.json index 9b862e303df1..f13e74089fbc 100644 --- a/core/cmd/server/docs/x-log.json +++ b/core/cmd/server/docs/x-log.json @@ -368,6 +368,36 @@ "formatZH": "docker 服务 [operation]", "formatEN": "[operation] docker service" }, + "/containers/files/del": { + "bodyKeys": [ + "containerID", + "paths" + ], + "paramKeys": [], + "beforeFunctions": [], + "formatZH": "删除容器 [containerID] 文件 [paths]", + "formatEN": "Delete files [paths] in container [containerID]" + }, + "/containers/files/download": { + "bodyKeys": [ + "containerID", + "path" + ], + "paramKeys": [], + "beforeFunctions": [], + "formatZH": "下载容器 [containerID] 文件 [path]", + "formatEN": "Download file [path] from container [containerID]" + }, + "/containers/files/upload": { + "bodyKeys": [ + "containerID", + "path" + ], + "paramKeys": [], + "beforeFunctions": [], + "formatZH": "容器 [containerID] 上传文件到 [path]", + "formatEN": "Upload file to [path] in container [containerID]" + }, "/containers/image/build": { "bodyKeys": [ "name" diff --git a/core/middleware/operation.go b/core/middleware/operation.go index 4c5fb0c8d4b1..9caefef290c1 100644 --- a/core/middleware/operation.go +++ b/core/middleware/operation.go @@ -100,6 +100,7 @@ func OperationLog() gin.HandlerFunc { writer := responseBodyWriter{ ResponseWriter: c.Writer, body: &bytes.Buffer{}, + captureBody: shouldCaptureResponseBody(c.Request.URL.Path), } c.Writer = &writer now := time.Now() @@ -143,12 +144,24 @@ func OperationLog() gin.HandlerFunc { datas, _ = io.ReadAll(reader) } var res response - _ = json.Unmarshal(datas, &res) - if res.Code == 200 { - record.Status = constant.StatusSuccess + contentType := strings.ToLower(c.Writer.Header().Get("Content-Type")) + isJSONResponse := strings.Contains(contentType, "application/json") + if isJSONResponse { + _ = json.Unmarshal(datas, &res) + if res.Code == 200 { + record.Status = constant.StatusSuccess + } else { + record.Status = constant.StatusFailed + record.Message = res.Message + } } else { - record.Status = constant.StatusFailed - record.Message = res.Message + statusCode := c.Writer.Status() + if statusCode >= 200 && statusCode < 400 { + record.Status = constant.StatusSuccess + } else { + record.Status = constant.StatusFailed + record.Message = http.StatusText(statusCode) + } } latency := time.Since(now) @@ -211,6 +224,7 @@ type responseBodyWriter struct { gin.ResponseWriter body *bytes.Buffer resolvedHeader string + captureBody bool } func (r *responseBodyWriter) sanitizeResolvedHeader() { @@ -232,10 +246,20 @@ func (r *responseBodyWriter) WriteHeaderNow() { func (r *responseBodyWriter) Write(b []byte) (int, error) { r.sanitizeResolvedHeader() - r.body.Write(b) + if r.captureBody { + r.body.Write(b) + } return r.ResponseWriter.Write(b) } +func shouldCaptureResponseBody(reqPath string) bool { + reqPath = strings.ToLower(reqPath) + if strings.Contains(reqPath, "download") { + return false + } + return true +} + func loadLogInfo(path string) string { path = replaceStr(path, "/api/v2", "/core", "/xpack") if !strings.Contains(path, "/") { diff --git a/frontend/src/api/interface/container.ts b/frontend/src/api/interface/container.ts index b2ae2edba675..bc7f05c901f3 100644 --- a/frontend/src/api/interface/container.ts +++ b/frontend/src/api/interface/container.ts @@ -56,6 +56,26 @@ export namespace Container { name: string; state: string; } + export interface ContainerFileReq { + containerID: string; + path: string; + } + export interface ContainerFileInfo { + name: string; + path: string; + isDir: boolean; + isLink: boolean; + linkTo: string; + size: number; + mode: string; + modTime: string; + } + export interface ContainerFileContent { + content: string; + size: number; + truncated: boolean; + isBinary: boolean; + } export interface ResourceLimit { cpu: number; memory: number; diff --git a/frontend/src/api/modules/container.ts b/frontend/src/api/modules/container.ts index 8a2c21b50fa6..46a39c681c14 100644 --- a/frontend/src/api/modules/container.ts +++ b/frontend/src/api/modules/container.ts @@ -15,6 +15,30 @@ export const listContainerByImage = (image: string) => { export const loadContainerUsers = (name: string) => { return http.post>(`/containers/users`, { name: name }); }; +export const listContainerFiles = (params: Container.ContainerFileReq) => { + return http.post>(`/containers/files/search`, params, TimeoutEnum.T_40S); +}; +export const uploadContainerFile = (params: FormData) => { + return http.upload(`/containers/files/upload`, params, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: TimeoutEnum.T_5M, + }); +}; +export const getContainerFileContent = (params: Container.ContainerFileReq) => { + return http.post(`/containers/files/content`, params, TimeoutEnum.T_40S); +}; +export const getContainerFileSize = (params: Container.ContainerFileReq) => { + return http.post(`/containers/files/size`, params, TimeoutEnum.T_40S); +}; +export const deleteContainerFile = (params: { containerID: string; paths: string[] }) => { + return http.post(`/containers/files/del`, params, TimeoutEnum.T_40S); +}; +export const downloadContainerFile = (params: { containerID: string; path: string }) => { + return http.download(`/containers/files/download`, params, { + responseType: 'blob', + timeout: TimeoutEnum.T_40S, + }); +}; export const loadContainerStatus = () => { return http.get(`/containers/status`); }; diff --git a/frontend/src/views/container/container/file-browser/index.vue b/frontend/src/views/container/container/file-browser/index.vue new file mode 100644 index 000000000000..1f59aae71fbc --- /dev/null +++ b/frontend/src/views/container/container/file-browser/index.vue @@ -0,0 +1,342 @@ + + + + + diff --git a/frontend/src/views/container/container/index.vue b/frontend/src/views/container/container/index.vue index 9ac411950ac4..b50751985340 100644 --- a/frontend/src/views/container/container/index.vue +++ b/frontend/src/views/container/container/index.vue @@ -375,6 +375,7 @@ + @@ -388,6 +389,7 @@ import UpgradeDialog from '@/views/container/container/upgrade/index.vue'; import CommitDialog from '@/views/container/container/commit/index.vue'; import MonitorDialog from '@/views/container/container/monitor/index.vue'; import TerminalDialog from '@/views/container/container/terminal/index.vue'; +import ContainerFileDrawer from '@/views/container/container/file-browser/index.vue'; import ContainerInspectDialog from '@/views/container/container/inspect/index.vue'; import PortJumpDialog from '@/components/port-jump/index.vue'; import TaskLog from '@/components/log/task/index.vue'; @@ -647,6 +649,11 @@ const onTerminal = (row: any) => { const title = i18n.global.t('menu.container') + ' ' + row.name; dialogTerminalRef.value!.acceptParams({ containerID: row.containerID, title: title }); }; +const dialogFileBrowserRef = ref(); +const onOpenFileBrowser = (row: any) => { + const title = i18n.global.t('menu.container') + ' ' + row.name; + dialogFileBrowserRef.value!.acceptParams({ containerID: row.containerID, title: title }); +}; const onInspect = async (row: any) => { const res = await inspect({ id: row.containerID, type: 'container', detail: '' }); @@ -754,6 +761,15 @@ const buttons = [ dialogContainerLogRef.value!.acceptParams({ containerID: row.containerID, container: row.name }); }, }, + { + label: i18n.global.t('home.dir'), + disabled: (row: Container.ContainerInfo) => { + return row.state !== 'running'; + }, + click: (row: Container.ContainerInfo) => { + onOpenFileBrowser(row); + }, + }, { label: i18n.global.t('commons.button.edit'), click: (row: Container.ContainerInfo) => {