feat: support file operations inside containers (#12160)

Refs #523
This commit is contained in:
ssongliu
2026-03-12 09:33:33 +00:00
committed by GitHub
parent 341a55ae63
commit 176fd0f8ad
13 changed files with 2219 additions and 6 deletions
+159
View File
@@ -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
+28
View File
@@ -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"`
+448
View File
@@ -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 {
+30
View File
@@ -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"
+6
View File
@@ -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)
+543
View File
@@ -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": {
+543
View File
@@ -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": {
+30
View File
@@ -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"
+30 -6
View File
@@ -98,6 +98,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()
@@ -141,12 +142,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)
@@ -209,6 +222,7 @@ type responseBodyWriter struct {
gin.ResponseWriter
body *bytes.Buffer
resolvedHeader string
captureBody bool
}
func (r *responseBodyWriter) sanitizeResolvedHeader() {
@@ -230,10 +244,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, "/") {
+20
View File
@@ -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;
+24
View File
@@ -15,6 +15,30 @@ export const listContainerByImage = (image: string) => {
export const loadContainerUsers = (name: string) => {
return http.post<Array<string>>(`/containers/users`, { name: name });
};
export const listContainerFiles = (params: Container.ContainerFileReq) => {
return http.post<Array<Container.ContainerFileInfo>>(`/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<Container.ContainerFileContent>(`/containers/files/content`, params, TimeoutEnum.T_40S);
};
export const getContainerFileSize = (params: Container.ContainerFileReq) => {
return http.post<number>(`/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<BlobPart>(`/containers/files/download`, params, {
responseType: 'blob',
timeout: TimeoutEnum.T_40S,
});
};
export const loadContainerStatus = () => {
return http.get<Container.ContainerStatus>(`/containers/status`);
};
@@ -0,0 +1,342 @@
<template>
<DrawerPro
v-model="visible"
:header="$t('home.dir')"
:resource="title"
size="large"
:fullScreen="true"
@close="visible = false"
>
<template #content>
<el-form label-position="top">
<el-form-item>
<div class="path-breadcrumb">
<el-breadcrumb separator="/">
<el-breadcrumb-item>
<el-link type="primary" @click="navigateToPath('/')">
<el-icon><HomeFilled /></el-icon>
</el-link>
</el-breadcrumb-item>
<el-breadcrumb-item v-for="item in pathSegments" :key="item.path">
<el-link type="primary" @click="navigateToPath(item.path)">
{{ item.name }}
</el-link>
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<el-upload ref="uploadRef" :auto-upload="false" :show-file-list="false" :on-change="onUploadChange">
<el-button class="mt-2" :loading="uploading" type="primary" plain>
{{ $t('commons.button.upload') }}
</el-button>
</el-upload>
<el-button class="mt-2 ml-3" plain :disabled="selectedRows.length === 0" @click="onBatchDelete">
{{ $t('commons.button.delete') }}
</el-button>
<el-button class="mt-2" plain @click="loadContainerFiles">
{{ $t('commons.button.refresh') }}
</el-button>
</el-form-item>
<ComplexTable
:data="containerFiles"
size="small"
max-height="calc(100vh - 240px)"
v-model:selects="selectedRows"
>
<el-table-column type="selection" width="48" />
<el-table-column :label="$t('commons.table.name')" min-width="260" show-overflow-tooltip>
<template #default="{ row }">
<div class="name-cell">
<el-icon>
<FolderOpened v-if="row.isDir" />
<Document v-else />
</el-icon>
<el-link v-if="row.isDir" type="primary" @click="enterDir(row.path)">
{{ row.name }}
</el-link>
<el-link v-else type="primary" @click="onOpenFile(row.path)">
{{ row.name }}
</el-link>
<span v-if="row.isLink && row.linkTo" class="symlink-target">-> {{ row.linkTo }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="$t('file.size')" width="130">
<template #default="{ row }">
<span v-if="!row.isDir">{{ formatSize(row.size) }}</span>
<el-button
v-else-if="!row.sizeLoaded"
link
type="primary"
:loading="row.sizeLoading"
@click="onCalcDirSize(row)"
>
{{ $t('file.calculate') }}
</el-button>
<span v-else>{{ formatSize(row.size) }}</span>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.operate')" width="220">
<template #default="{ row }">
<el-button type="primary" link @click="onDownloadFile(row)">
{{ $t('commons.button.download') }}
</el-button>
<el-button type="primary" link @click="onDeleteFile(row)">
{{ $t('commons.button.delete') }}
</el-button>
</template>
</el-table-column>
</ComplexTable>
</el-form>
</template>
</DrawerPro>
<el-dialog v-model="previewVisible" :title="previewTitle" width="70%" destroy-on-close>
<el-alert
v-if="previewTruncated"
:title="$t('file.previewTruncated')"
type="warning"
:closable="false"
show-icon
class="mb-2"
/>
<pre class="preview-content">{{ previewContent }}</pre>
</el-dialog>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import {
deleteContainerFile,
downloadContainerFile,
getContainerFileContent,
getContainerFileSize,
listContainerFiles,
uploadContainerFile,
} from '@/api/modules/container';
import { MsgError, MsgSuccess, MsgWarning } from '@/utils/message';
import i18n from '@/lang';
import { ElMessageBox, UploadFile, UploadInstance } from 'element-plus';
import { Document, FolderOpened, HomeFilled } from '@element-plus/icons-vue';
import { computeSize2 } from '@/utils/util';
const visible = ref(false);
const title = ref('');
const containerID = ref('');
const filePath = ref('/');
const containerFiles = ref<any[]>([]);
const uploadRef = ref<UploadInstance>();
const uploading = ref(false);
const previewVisible = ref(false);
const previewTitle = ref('');
const previewContent = ref('');
const previewTruncated = ref(false);
const selectedRows = ref<any[]>([]);
const pathSegments = computed(() => {
const parts = filePath.value.split('/').filter((item) => item);
return parts.map((name, index) => ({
name,
path: '/' + parts.slice(0, index + 1).join('/'),
}));
});
interface DrawerProps {
containerID: string;
title: string;
}
const acceptParams = async (params: DrawerProps): Promise<void> => {
visible.value = true;
containerID.value = params.containerID;
title.value = params.title;
filePath.value = '/';
await loadContainerFiles();
};
const loadContainerFiles = async () => {
if (!containerID.value || !filePath.value) {
return;
}
await listContainerFiles({
containerID: containerID.value,
path: filePath.value,
})
.then((res) => {
containerFiles.value = (res.data || []).map((item) => ({
...item,
sizeLoaded: !item.isDir,
sizeLoading: false,
}));
selectedRows.value = [];
})
.catch(() => {
containerFiles.value = [];
});
};
const enterDir = async (path: string) => {
filePath.value = path;
await loadContainerFiles();
};
const navigateToPath = async (path: string) => {
filePath.value = path || '/';
await loadContainerFiles();
};
const onUploadChange = async (uploadFile: UploadFile) => {
if (!uploadFile.raw) {
return;
}
const formData = new FormData();
formData.append('containerID', containerID.value);
formData.append('path', filePath.value);
formData.append('file', uploadFile.raw);
uploading.value = true;
await uploadContainerFile(formData)
.then(async () => {
MsgSuccess(i18n.global.t('file.uploadSuccess'));
await loadContainerFiles();
})
.catch((err) => {
MsgError(err?.message || i18n.global.t('commons.msg.operationFailed'));
})
.finally(() => {
uploading.value = false;
uploadRef.value?.clearFiles();
});
};
const onOpenFile = async (path: string) => {
await getContainerFileContent({
containerID: containerID.value,
path,
})
.then((res) => {
if (res.data?.isBinary) {
MsgWarning(i18n.global.t('file.fileCanNotRead'));
return;
}
previewTitle.value = path;
previewContent.value = res.data?.content || '';
previewTruncated.value = !!res.data?.truncated;
previewVisible.value = true;
})
.catch((err) => {
MsgError(err?.message || i18n.global.t('commons.msg.operationFailed'));
});
};
const onCalcDirSize = async (row: any) => {
row.sizeLoading = true;
await getContainerFileSize({
containerID: containerID.value,
path: row.path,
})
.then((res) => {
row.size = res.data || 0;
row.sizeLoaded = true;
})
.catch((err) => {
MsgError(err?.message || i18n.global.t('commons.msg.operationFailed'));
})
.finally(() => {
row.sizeLoading = false;
});
};
const formatSize = (size: number) => {
return computeSize2(size || 0);
};
const onDownloadFile = async (row: any) => {
const blob = await downloadContainerFile({
containerID: containerID.value,
path: row.path,
});
const downloadUrl = window.URL.createObjectURL(new Blob([blob]));
const a = document.createElement('a');
a.style.display = 'none';
a.href = downloadUrl;
const fileName = row.path.split('/').pop() || 'container-file';
a.download = row.isDir ? `${fileName}.tar.gz` : fileName;
a.dispatchEvent(new MouseEvent('click'));
};
const onDeleteFile = async (row: any) => {
try {
await ElMessageBox.confirm(i18n.global.t('file.deleteHelper2'), i18n.global.t('commons.button.delete'), {
type: 'warning',
});
} catch {
return;
}
await deleteContainerFile({
containerID: containerID.value,
paths: [row.path],
})
.then(async () => {
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
await loadContainerFiles();
})
.catch((err) => {
MsgError(err?.message || i18n.global.t('commons.msg.operationFailed'));
});
};
const onBatchDelete = async () => {
if (selectedRows.value.length === 0) {
return;
}
try {
await ElMessageBox.confirm(i18n.global.t('file.deleteHelper2'), i18n.global.t('commons.button.delete'), {
type: 'warning',
});
} catch {
return;
}
await deleteContainerFile({
containerID: containerID.value,
paths: selectedRows.value.map((item) => item.path),
})
.then(async () => {
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
selectedRows.value = [];
await loadContainerFiles();
})
.catch((err) => {
MsgError(err?.message || i18n.global.t('commons.msg.operationFailed'));
});
};
defineExpose({
acceptParams,
});
</script>
<style scoped lang="scss">
.name-cell {
display: flex;
align-items: center;
gap: 6px;
}
.symlink-target {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.path-breadcrumb {
width: 100%;
padding: 6px 10px;
border: 1px solid var(--el-border-color);
border-radius: 4px;
overflow-x: auto;
}
.preview-content {
margin: 0;
max-height: 70vh;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
</style>
@@ -378,6 +378,7 @@
<CommitDialog @search="search" ref="dialogCommitRef" />
<MonitorDialog ref="dialogMonitorRef" />
<TerminalDialog ref="dialogTerminalRef" />
<ContainerFileDrawer ref="dialogFileBrowserRef" />
<PortJumpDialog ref="dialogPortJumpRef" />
<Backups ref="dialogBackupRef" />
@@ -393,6 +394,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';
@@ -675,6 +677,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: '' });
@@ -782,6 +789,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) => {