diff --git a/agent/app/api/v2/container.go b/agent/app/api/v2/container.go index 7f378619745f..2489819cf318 100644 --- a/agent/app/api/v2/container.go +++ b/agent/app/api/v2/container.go @@ -28,7 +28,7 @@ func (b *BaseApi) SearchContainer(c *gin.Context) { return } - total, list, err := containerService.Page(req) + total, list, err := containerService.Page(c.Request.Context(), req) if err != nil { helper.InternalServer(c, err) return @@ -249,9 +249,10 @@ func (b *BaseApi) ListContainerByImage(c *gin.Context) { // @Success 200 {object} dto.ContainerStatus // @Security ApiKeyAuth // @Security Timestamp +// @Param containersOnly query boolean false "Only count containers" // @Router /containers/status [get] func (b *BaseApi) LoadContainerStatus(c *gin.Context) { - data, err := containerService.LoadStatus() + data, err := containerService.LoadStatus(c.Request.Context(), c.Query("containersOnly") == "true") if err != nil { helper.InternalServer(c, err) return @@ -415,9 +416,14 @@ func (b *BaseApi) LoadResourceLimit(c *gin.Context) { // @Success 200 {array} dto.ContainerListStats // @Security ApiKeyAuth // @Security Timestamp +// @Param ids query string false "Comma-separated container IDs; omitted selects all containers" // @Router /containers/list/stats [get] func (b *BaseApi) ContainerListStats(c *gin.Context) { - data, err := containerService.ContainerListStats() + var ids []string + if _, supplied := c.Request.URL.Query()["ids"]; supplied { + ids = strings.FieldsFunc(c.Query("ids"), func(r rune) bool { return r == ',' }) + } + data, err := containerService.ContainerListStats(c.Request.Context(), ids) if err != nil { helper.InternalServer(c, err) return @@ -964,3 +970,12 @@ func (b *BaseApi) ContainerStreamLogs(c *gin.Context) { containerService.StreamLogs(c, streamLog) } + +func (b *BaseApi) CleanNetworks(c *gin.Context) { + result, err := containerService.CleanNetworks() + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, result) +} diff --git a/agent/app/api/v2/runtime.go b/agent/app/api/v2/runtime.go index fbe22a9e4a9b..64c8b3b22805 100644 --- a/agent/app/api/v2/runtime.go +++ b/agent/app/api/v2/runtime.go @@ -169,7 +169,7 @@ func (b *BaseApi) GetNodePackageRunScript(c *gin.Context) { // @Security ApiKeyAuth // @Security Timestamp // @Router /runtimes/operate [post] -// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"操作运行环境 [id]","formatEN":"Operate runtime [id]"} +// @x-panel-log {"bodyKeys":["ID"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"ID","isList":false,"db":"runtimes","output_column":"name","output_value":"name"}],"formatZH":"操作运行环境 [name]","formatEN":"Operate runtime [name]"} func (b *BaseApi) OperateRuntime(c *gin.Context) { var req request.RuntimeOperate if err := helper.CheckBindAndValidate(&req, c); err != nil { diff --git a/agent/app/api/v2/ssh.go b/agent/app/api/v2/ssh.go index 6dbd1011a6d7..5f6e1d78fd98 100644 --- a/agent/app/api/v2/ssh.go +++ b/agent/app/api/v2/ssh.go @@ -83,6 +83,7 @@ func (b *BaseApi) CreateRootCert(c *gin.Context) { } if err := loadCertAfterDecrypt(&req); err != nil { helper.BadRequest(c, err) + return } if err := sshService.CreateRootCert(req); err != nil { helper.InternalServer(c, err) @@ -107,6 +108,7 @@ func (b *BaseApi) EditRootCert(c *gin.Context) { } if err := loadCertAfterDecrypt(&req); err != nil { helper.BadRequest(c, err) + return } if err := sshService.EditRootCert(req); err != nil { helper.InternalServer(c, err) diff --git a/agent/app/dto/container_network_cleanup.go b/agent/app/dto/container_network_cleanup.go new file mode 100644 index 000000000000..1dc75e606182 --- /dev/null +++ b/agent/app/dto/container_network_cleanup.go @@ -0,0 +1,17 @@ +package dto + +type NetworkCleanupReport struct { + Deleted []NetworkCleanupItem `json:"deleted"` + Skipped []NetworkCleanupItem `json:"skipped"` + Failed []NetworkCleanupItem `json:"failed"` +} + +type NetworkCleanupItem struct { + ID string `json:"id"` + Name string `json:"name"` + Reason string `json:"reason,omitempty"` +} + +type NetworkCleanupTask struct { + TaskID string `json:"taskID"` +} diff --git a/agent/app/dto/ssh.go b/agent/app/dto/ssh.go index de965539f06c..4b07386f9fd7 100644 --- a/agent/app/dto/ssh.go +++ b/agent/app/dto/ssh.go @@ -25,7 +25,7 @@ type RootCertOperate struct { ID uint `json:"id"` Name string `json:"name"` Mode string `json:"mode"` - EncryptionMode string `json:"encryptionMode" validate:"required,oneof=rsa ed25519 ecdsa dsa"` + EncryptionMode string `json:"encryptionMode"` PassPhrase string `json:"passPhrase"` PublicKey string `json:"publicKey"` PrivateKey string `json:"privateKey"` diff --git a/agent/app/service/container.go b/agent/app/service/container.go index d65ef4f1fac4..cf0fdd3bcd5c 100644 --- a/agent/app/service/container.go +++ b/agent/app/service/container.go @@ -58,10 +58,10 @@ type ContainerService struct{} var containerLogAnsiRegex = regexp.MustCompile("\x1b\\[[0-9;?]*[A-Za-z]|\x1b=|\x1b>") type IContainerService interface { - Page(req dto.PageContainer) (int64, interface{}, error) + Page(ctx context.Context, req dto.PageContainer) (int64, interface{}, error) List() []dto.ContainerOptions ListByImage(imageName string) []dto.ContainerOptions - LoadStatus() (dto.ContainerStatus, error) + LoadStatus(ctx context.Context, containersOnly bool) (dto.ContainerStatus, error) PageNetwork(req dto.SearchWithPage) (int64, interface{}, error) ListNetwork() ([]dto.Options, error) PageVolume(req dto.SearchWithPage) (int64, interface{}, error) @@ -80,7 +80,7 @@ type IContainerService interface { ContainerUpdate(req dto.ContainerOperate) error ContainerUpgrade(req dto.ContainerUpgrade) error ContainerInfo(req dto.OperationWithName) (*dto.ContainerOperate, error) - ContainerListStats() ([]dto.ContainerListStats, error) + ContainerListStats(ctx context.Context, ids []string) ([]dto.ContainerListStats, error) ContainerItemStats(ctx context.Context, req dto.OperationWithName) (dto.ContainerItemStats, error) LoadResourceLimit() (*dto.ResourceLimit, error) ContainerRename(req dto.ContainerRename) error @@ -92,6 +92,7 @@ type IContainerService interface { Inspect(req dto.InspectReq) (string, error) DeleteNetwork(req dto.BatchDelete) error + CleanNetworks() (*dto.NetworkCleanupTask, error) CreateNetwork(req dto.NetworkCreate) error DeleteVolume(req dto.BatchDelete) error CreateVolume(req dto.VolumeCreate) error @@ -112,7 +113,7 @@ func NewIContainerService() IContainerService { return &ContainerService{} } -func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, error) { +func (u *ContainerService) Page(ctx context.Context, req dto.PageContainer) (int64, interface{}, error) { client, err := docker.NewDockerClient() if err != nil { return 0, nil, err @@ -123,7 +124,7 @@ func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, erro options.Filters = filters.NewArgs() options.Filters.Add("label", req.Filters) } - containers, err := client.ContainerList(context.Background(), options) + containers, err := client.ContainerList(ctx, options) if err != nil { return 0, nil, err } @@ -206,27 +207,30 @@ func (u *ContainerService) ListByImage(imageName string) []dto.ContainerOptions return options } -func (u *ContainerService) LoadStatus() (dto.ContainerStatus, error) { +func (u *ContainerService) LoadStatus(ctx context.Context, containersOnly bool) (dto.ContainerStatus, error) { var data dto.ContainerStatus client, err := docker.NewDockerClient() if err != nil { return data, err } defer client.Close() - c := context.Background() - - images, _ := client.ImageList(c, image.ListOptions{All: true}) - data.ImageCount = len(images) - repo, _ := imageRepoRepo.List() - data.RepoCount = len(repo) - templates, _ := composeRepo.List() - data.ComposeTemplateCount = len(templates) - networks, _ := client.NetworkList(c, network.ListOptions{}) - data.NetworkCount = len(networks) - volumes, _ := client.VolumeList(c, volume.ListOptions{}) - data.VolumeCount = len(volumes.Volumes) - data.ComposeCount = loadComposeCount(client) - containers, _ := client.ContainerList(c, container.ListOptions{All: true}) + if !containersOnly { + images, _ := client.ImageList(ctx, image.ListOptions{All: true}) + data.ImageCount = len(images) + repo, _ := imageRepoRepo.List() + data.RepoCount = len(repo) + templates, _ := composeRepo.List() + data.ComposeTemplateCount = len(templates) + networks, _ := client.NetworkList(ctx, network.ListOptions{}) + data.NetworkCount = len(networks) + volumes, _ := client.VolumeList(ctx, volume.ListOptions{}) + data.VolumeCount = len(volumes.Volumes) + data.ComposeCount = loadComposeCount(ctx, client) + } + containers, err := client.ContainerList(ctx, container.ListOptions{All: true}) + if err != nil { + return data, err + } data.ContainerCount = len(containers) for _, item := range containers { switch item.State { @@ -292,27 +296,65 @@ func (u *ContainerService) ContainerItemStats(ctx context.Context, req dto.Opera } return data, nil } -func (u *ContainerService) ContainerListStats() ([]dto.ContainerListStats, error) { +func (u *ContainerService) ContainerListStats(ctx context.Context, ids []string) ([]dto.ContainerListStats, error) { client, err := docker.NewDockerClient() if err != nil { return nil, err } defer client.Close() - list, err := client.ContainerList(context.Background(), container.ListOptions{All: true}) + options := container.ListOptions{All: true} + if ids != nil { + if len(ids) == 0 { + return []dto.ContainerListStats{}, nil + } + options.Filters = filters.NewArgs() + for _, id := range ids { + options.Filters.Add("id", id) + } + } + list, err := client.ContainerList(ctx, options) if err != nil { return nil, err } + return collectContainerStats(ctx, list, func(ctx context.Context, id string) dto.ContainerListStats { + return loadCpuAndMem(ctx, client, id) + }), nil +} + +// A fixed worker pool bounds Docker stats requests, including for legacy callers +// that request all containers. Stopped containers do not need a stats sample. +func collectContainerStats(ctx context.Context, list []container.Summary, load func(context.Context, string) dto.ContainerListStats) []dto.ContainerListStats { datas := make([]dto.ContainerListStats, len(list)) + for i, item := range list { + datas[i].ContainerID = item.ID + } + jobs := make(chan int) var wg sync.WaitGroup - wg.Add(len(list)) - for i := 0; i < len(list); i++ { - go func(index int, item container.Summary) { - datas[index] = loadCpuAndMem(client, item.ID) - wg.Done() - }(i, list[i]) + for worker := 0; worker < min(8, len(list)); worker++ { + wg.Add(1) + go func() { + defer wg.Done() + for index := range jobs { + if ctx.Err() != nil || list[index].State != "running" { + continue + } + sampleCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + datas[index] = load(sampleCtx, list[index].ID) + cancel() + } + }() } +dispatch: + for index := range list { + select { + case <-ctx.Done(): + break dispatch + case jobs <- index: + } + } + close(jobs) wg.Wait() - return datas, nil + return datas } func (u *ContainerService) Inspect(req dto.InspectReq) (string, error) { @@ -1715,11 +1757,11 @@ func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo return selected } -func loadCpuAndMem(client *client.Client, containerItem string) dto.ContainerListStats { +func loadCpuAndMem(ctx context.Context, client *client.Client, containerItem string) dto.ContainerListStats { data := dto.ContainerListStats{ ContainerID: containerItem, } - res, err := client.ContainerStats(context.Background(), containerItem, false) + res, err := client.ContainerStats(ctx, containerItem, false) if err != nil { return data } @@ -1942,11 +1984,11 @@ func transPortToStr(ports []container.Port) []string { return docker.SimplifyPorts(ports) } -func loadComposeCount(client *client.Client) int { +func loadComposeCount(ctx context.Context, client *client.Client) int { options := container.ListOptions{All: true} options.Filters = filters.NewArgs() options.Filters.Add("label", composeProjectLabel) - list, err := client.ContainerList(context.Background(), options) + list, err := client.ContainerList(ctx, options) if err != nil { return 0 } diff --git a/agent/app/service/container_network_cleanup.go b/agent/app/service/container_network_cleanup.go new file mode 100644 index 000000000000..0e46868e0a9e --- /dev/null +++ b/agent/app/service/container_network_cleanup.go @@ -0,0 +1,71 @@ +package service + +import ( + "fmt" + "sync" + + "github.com/1Panel-dev/1Panel/agent/app/dto" + "github.com/1Panel-dev/1Panel/agent/app/task" + "github.com/1Panel-dev/1Panel/agent/global" + "github.com/1Panel-dev/1Panel/agent/i18n" + "github.com/1Panel-dev/1Panel/agent/utils/docker" +) + +var networkCleanupMu sync.Mutex + +func (u *ContainerService) CleanNetworks() (*dto.NetworkCleanupTask, error) { + taskItem, err := task.NewTaskWithOps(i18n.GetMsgByKey("Network"), task.TaskClean, task.TaskScopeContainer, "", 0) + if err != nil { + return nil, err + } + taskItem.AddSubTask(i18n.GetMsgByKey("TaskClean"), func(t *task.Task) error { + networkCleanupMu.Lock() + defer networkCleanupMu.Unlock() + if err := t.TaskCtx.Err(); err != nil { + return err + } + cli, err := docker.NewDockerClient() + if err != nil { + return err + } + defer cli.Close() + return executeNetworkCleanup(t, cli) + }, nil) + go func() { + if err := taskItem.Execute(); err != nil { + global.LOG.Errorf("network cleanup task %s failed: %v", taskItem.TaskID, err) + } + }() + return &dto.NetworkCleanupTask{TaskID: taskItem.TaskID}, nil +} + +func executeNetworkCleanup(t *task.Task, cli docker.NetworkCleanupClient) error { + t.Log(i18n.GetMsgByKey("PruneStart")) + report, err := docker.CleanUnusedNetworks(t.TaskCtx, cli, func(status string, item dto.NetworkCleanupItem) { + key := "NetworkCleanupDeleted" + if status == "skipped" || status == "failed" { + key = map[string]string{ + "protected": "NetworkCleanupProtected", + "container_connected": "NetworkCleanupConnected", + "network_in_use": "NetworkCleanupConnected", + "unsupported_network": "NetworkCleanupUnsupported", + "already_removed": "NetworkCleanupGone", + "inspect_failed": "NetworkCleanupInspectFailed", + "remove_failed": "NetworkCleanupRemoveFailed", + }[item.Reason] + } + t.Log(i18n.GetMsgWithMap(key, map[string]interface{}{"name": item.Name, "id": item.ID})) + }) + if report != nil { + t.Log(i18n.GetMsgWithMap("NetworkCleanupSummary", map[string]interface{}{ + "deleted": len(report.Deleted), "skipped": len(report.Skipped), "failed": len(report.Failed), + })) + } + if err != nil { + return err + } + if len(report.Failed) > 0 { + return fmt.Errorf("%s", i18n.GetMsgByKey("NetworkCleanupPartialFailure")) + } + return nil +} diff --git a/agent/app/service/container_network_cleanup_test.go b/agent/app/service/container_network_cleanup_test.go new file mode 100644 index 000000000000..89373f340a6c --- /dev/null +++ b/agent/app/service/container_network_cleanup_test.go @@ -0,0 +1,113 @@ +package service + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/1Panel-dev/1Panel/agent/app/model" + "github.com/1Panel-dev/1Panel/agent/app/task" + "github.com/1Panel-dev/1Panel/agent/constant" + "github.com/1Panel-dev/1Panel/agent/global" + "github.com/1Panel-dev/1Panel/agent/i18n" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/errdefs" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +type networkTaskClient struct{ fail bool } + +func (f networkTaskClient) NetworkList(context.Context, network.ListOptions) ([]network.Inspect, error) { + return []network.Inspect{{ID: "reserved", Name: "1panel-network", Scope: "local"}, {ID: "connected", Name: "busy", Scope: "local"}, {ID: "unused", Name: "free", Scope: "local"}, {ID: "race", Name: "race", Scope: "local"}}, nil +} +func (f networkTaskClient) ContainerList(context.Context, container.ListOptions) ([]container.Summary, error) { + return nil, nil +} +func (f networkTaskClient) NetworkInspect(_ context.Context, id string, _ network.InspectOptions) (network.Inspect, error) { + n := network.Inspect{} + if id == "connected" { + n.Containers = map[string]network.EndpointResource{"container-id": {}} + } + return n, nil +} +func (f networkTaskClient) NetworkRemove(_ context.Context, id string) error { + if id == "race" { + return errdefs.Conflict(errors.New("has active endpoints")) + } + if id != "unused" { + return errors.New("unexpected removal") + } + if f.fail { + return errors.New("remove failed") + } + return nil +} + +func TestNetworkCleanupTaskPersistsLogsAndStatus(t *testing.T) { + oldDB, oldTaskDB, oldDir, oldI18n := global.DB, global.TaskDB, global.Dir, global.I18n + t.Cleanup(func() { global.DB = oldDB; global.TaskDB = oldTaskDB; global.Dir = oldDir; global.I18n = oldI18n }) + dir := t.TempDir() + db, err := gorm.Open(sqlite.Open(filepath.Join(dir, "tasks.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + defer sqlDB.Close() + if err := db.AutoMigrate(&model.Task{}); err != nil { + t.Fatal(err) + } + global.DB = nil + global.TaskDB = db + global.Dir.TaskDir = dir + i18n.Init() + for _, fail := range []bool{false, true} { + name := "success" + wantStatus := constant.StatusSuccess + if fail { + name = "partial failure" + wantStatus = constant.StatusFailed + } + t.Run(name, func(t *testing.T) { + item, err := task.NewTaskWithOps("Network", task.TaskClean, task.TaskScopeContainer, "", 0) + if err != nil { + t.Fatal(err) + } + item.AddSubTask("Clean", func(t *task.Task) error { return executeNetworkCleanup(t, networkTaskClient{fail: fail}) }, nil) + err = item.Execute() + if (err != nil) != fail { + t.Fatalf("unexpected execution error: %v", err) + } + var saved model.Task + if err := db.First(&saved, "id = ?", item.TaskID).Error; err != nil { + t.Fatal(err) + } + if saved.Status != wantStatus { + t.Fatalf("status %s, want %s", saved.Status, wantStatus) + } + content, err := os.ReadFile(saved.LogFile) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"[busy] (connected): containers connected", "[race] (race): containers connected", "[1panel-network] (reserved): reserved network", "Network cleanup finished:", "[TASK-END]"} { + if !strings.Contains(string(content), want) { + t.Fatalf("missing %q in log: %s", want, content) + } + } + want := "Deleted network [free]" + if fail { + want = "Failed to remove network [free]" + } + if !strings.Contains(string(content), want) { + t.Fatalf("missing %q", want) + } + }) + } +} diff --git a/agent/app/service/ssh.go b/agent/app/service/ssh.go index d69ef9dfefe4..2f970d719fdf 100644 --- a/agent/app/service/ssh.go +++ b/agent/app/service/ssh.go @@ -7,6 +7,7 @@ import ( "context" "encoding/base64" "encoding/json" + "encoding/pem" "fmt" "io" "net" @@ -37,6 +38,7 @@ import ( "github.com/1Panel-dev/1Panel/agent/utils/common" "github.com/1Panel-dev/1Panel/agent/utils/re" "github.com/pkg/errors" + "golang.org/x/crypto/ssh" ) const sshPath = "/etc/ssh/sshd_config" @@ -438,15 +440,33 @@ func (u *SSHService) SyncRootCert() error { global.LOG.Errorf("read pubic key of %s for sync failed, err: %v", item, err) continue } - cert.EncryptionMode = loadEncryptioMode(string(pubItem)) + publicKey, _, _, rest, err := ssh.ParseAuthorizedKey(pubItem) + if err != nil || len(bytes.TrimSpace(rest)) != 0 { + continue + } + cert.EncryptionMode = loadSSHKeyType(publicKey) + if cert.EncryptionMode == "" { + continue + } rootCerts = append(rootCerts, cert) } return hostRepo.SyncCert(rootCerts) } func (u *SSHService) CreateRootCert(req dto.RootCertOperate) error { - if cmd.CheckIllegal(req.EncryptionMode, req.PassPhrase) { - return buserr.New("ErrCmdIllegal") + switch req.Mode { + case "generate": + switch req.EncryptionMode { + case "rsa", "ed25519", "ecdsa", "dsa": + default: + return buserr.WithName("ErrNotSupportType", req.EncryptionMode) + } + case "input", "import": + if err := validateSSHKeyPair(&req); err != nil { + return err + } + default: + return buserr.WithName("ErrNotSupportType", req.Mode) } certItem, _ := hostRepo.GetCert(repo.WithByName(req.Name)) if certItem.ID != 0 { @@ -543,6 +563,9 @@ func (u *SSHService) CreateRootCert(req dto.RootCertOperate) error { } func (u *SSHService) EditRootCert(req dto.RootCertOperate) error { + if err := validateSSHKeyPair(&req); err != nil { + return err + } currentUser, err := user.Current() if err != nil { return fmt.Errorf("load current user failed, err: %v", err) @@ -1681,22 +1704,60 @@ func loadDate(currentYear int, DateStr string, nyc *time.Location) time.Time { return itemDate } -func loadEncryptioMode(content string) string { - if strings.HasPrefix(content, "ssh-rsa") { +func loadSSHKeyType(publicKey ssh.PublicKey) string { + switch publicKey.Type() { + case ssh.KeyAlgoRSA: return "rsa" - } - if strings.HasPrefix(content, "ssh-ed25519") { + case ssh.KeyAlgoED25519: return "ed25519" - } - if strings.HasPrefix(content, "ssh-ecdsa") { + case ssh.KeyAlgoECDSA256, ssh.KeyAlgoECDSA384, ssh.KeyAlgoECDSA521: return "ecdsa" - } - if strings.HasPrefix(content, "ssh-dsa") { + case ssh.KeyAlgoDSA: return "dsa" } return "" } +func validateSSHKeyPair(req *dto.RootCertOperate) error { + req.PublicKey = strings.TrimSpace(req.PublicKey) + req.PrivateKey = strings.TrimSpace(req.PrivateKey) + if req.PublicKey == "" || strings.ContainsAny(req.PublicKey, "\r\n") { + return buserr.New("ErrSSHPublicKey") + } + publicKey, comment, options, rest, err := ssh.ParseAuthorizedKey([]byte(req.PublicKey)) + if err != nil || len(options) != 0 || len(bytes.TrimSpace(rest)) != 0 { + return buserr.New("ErrSSHPublicKey") + } + req.EncryptionMode = loadSSHKeyType(publicKey) + if req.EncryptionMode == "" { + return buserr.WithName("ErrNotSupportType", publicKey.Type()) + } + block, rest := pem.Decode([]byte(req.PrivateKey)) + if !strings.HasPrefix(req.PrivateKey, "-----BEGIN ") || block == nil || len(bytes.TrimSpace(rest)) != 0 { + return buserr.New("ErrSSHPrivateKey") + } + signer, err := ssh.ParsePrivateKey([]byte(req.PrivateKey)) + var missingPassphrase *ssh.PassphraseMissingError + if errors.As(err, &missingPassphrase) { + signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(req.PrivateKey), []byte(req.PassPhrase)) + } else if err == nil { + req.PassPhrase = "" + } + if err != nil { + return buserr.New("ErrSSHPrivateKey") + } + if !bytes.Equal(signer.PublicKey().Marshal(), publicKey.Marshal()) { + return buserr.New("ErrSSHKeyMismatch") + } + req.PublicKey = strings.TrimSpace(string(ssh.MarshalAuthorizedKey(publicKey))) + if comment != "" { + req.PublicKey += " " + comment + } + req.PublicKey += "\n" + req.PrivateKey += "\n" + return nil +} + func updateLocalConn(newPort uint) error { conn, _ := settingRepo.GetValueByKey("LocalSSHConn") if len(conn) == 0 { diff --git a/agent/app/task/task.go b/agent/app/task/task.go index 5fa01d58036a..2425d80c7729 100644 --- a/agent/app/task/task.go +++ b/agent/app/task/task.go @@ -154,7 +154,7 @@ func NewTask(name, operate, taskScope, taskID string, resourceID uint) (*Task, e logPath := path.Join(global.Dir.TaskDir, taskScope, taskID+".log") logger := logrus.New() logger.SetFormatter(&SimpleFormatter{}) - logFile, err := os.OpenFile(logPath, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, constant.FilePerm) + logFile, err := os.OpenFile(logPath, os.O_TRUNC|os.O_CREATE|os.O_WRONLY|os.O_APPEND, constant.FilePerm) if err != nil { return nil, fmt.Errorf("failed to open log file: %w", err) } diff --git a/agent/cmd/server/docs/x-log.json b/agent/cmd/server/docs/x-log.json index 5b1d13eb02d1..2735edae5012 100644 --- a/agent/cmd/server/docs/x-log.json +++ b/agent/cmd/server/docs/x-log.json @@ -644,6 +644,13 @@ "formatZH": "创建容器网络 name", "formatEN": "create container network [name]" }, + "/containers/network/clean": { + "bodyKeys": [], + "paramKeys": [], + "beforeFunctions": [], + "formatZH": "清理未使用的容器网络", + "formatEN": "Clean unused container networks" + }, "/containers/network/del": { "bodyKeys": [ "names" @@ -4039,12 +4046,21 @@ }, "/runtimes/operate": { "bodyKeys": [ - "id" + "ID" ], "paramKeys": [], - "beforeFunctions": [], - "formatZH": "操作运行环境 [id]", - "formatEN": "Operate runtime [id]" + "beforeFunctions": [ + { + "input_column": "id", + "input_value": "ID", + "isList": false, + "db": "runtimes", + "output_column": "name", + "output_value": "name" + } + ], + "formatZH": "操作运行环境 [name]", + "formatEN": "Operate runtime [name]" }, "/runtimes/php/config": { "bodyKeys": [ diff --git a/agent/i18n/lang/en.yaml b/agent/i18n/lang/en.yaml index e208725c915c..3b1317eefb31 100644 --- a/agent/i18n/lang/en.yaml +++ b/agent/i18n/lang/en.yaml @@ -731,3 +731,17 @@ FileTaskRename: 'Target filename: {{ .name }}' ErrFirewallBackendCleanupRequired: "The current backend {{ .current }} still contains 1Panel rules. Clean it up before switching to {{ .target }}." ErrDockerIPv4ForwardingDisabled: "IPv4 forwarding is disabled. Set net.ipv4.ip_forward=1 before using Docker's firewall backend." ErrFirewallRuleSavedApplyFailed: "The new rule configuration was saved, but applying it to the firewall failed. Retry by synchronizing: {{ .detail }}" + +ErrSSHPublicKey: "Enter a valid SSH public key" +ErrSSHPrivateKey: "Invalid SSH private key or incorrect passphrase; encrypted keys require the original passphrase" +ErrSSHKeyMismatch: "The SSH public and private keys do not match" + +NetworkCleanupDeleted: "Deleted network [{{ .name }}] ({{ .id }})" +NetworkCleanupProtected: "Skipped network [{{ .name }}] ({{ .id }}): reserved network" +NetworkCleanupConnected: "Skipped network [{{ .name }}] ({{ .id }}): containers connected or network in use; not deleted" +NetworkCleanupUnsupported: "Skipped network [{{ .name }}] ({{ .id }}): special network" +NetworkCleanupGone: "Skipped network [{{ .name }}] ({{ .id }}): already removed" +NetworkCleanupInspectFailed: "Failed to inspect network [{{ .name }}] ({{ .id }}); not deleted" +NetworkCleanupRemoveFailed: "Failed to remove network [{{ .name }}] ({{ .id }})" +NetworkCleanupSummary: "Network cleanup finished: deleted {{ .deleted }}, skipped {{ .skipped }}, failed {{ .failed }}" +NetworkCleanupPartialFailure: "Some networks could not be cleaned; see the task log" diff --git a/agent/i18n/lang/es-ES.yaml b/agent/i18n/lang/es-ES.yaml index 91467f1b0b14..4a4a52204c90 100644 --- a/agent/i18n/lang/es-ES.yaml +++ b/agent/i18n/lang/es-ES.yaml @@ -731,3 +731,7 @@ FileTaskRename: 'Nombre del archivo de destino: {{ .name }}' ErrFirewallBackendCleanupRequired: "El backend actual {{ .current }} aún contiene reglas de 1Panel. Elimínelas antes de cambiar a {{ .target }}." ErrDockerIPv4ForwardingDisabled: "El reenvío IPv4 está desactivado. Configure net.ipv4.ip_forward=1 antes de usar el backend de cortafuegos de Docker." ErrFirewallRuleSavedApplyFailed: "Se guardó la configuración de la nueva regla, pero no se pudo aplicar al cortafuegos. Vuelva a intentarlo mediante la sincronización: {{ .detail }}" + +ErrSSHPublicKey: "Introduce una clave pública SSH válida" +ErrSSHPrivateKey: "Clave privada SSH no válida o contraseña incorrecta; las claves cifradas requieren la contraseña original" +ErrSSHKeyMismatch: "Las claves pública y privada SSH no coinciden" diff --git a/agent/i18n/lang/fa.yaml b/agent/i18n/lang/fa.yaml index 78ff0019a4cf..a974edfc3509 100644 --- a/agent/i18n/lang/fa.yaml +++ b/agent/i18n/lang/fa.yaml @@ -732,3 +732,7 @@ FileTaskRename: 'نام فایل مقصد: {{ .name }}' ErrFirewallBackendCleanupRequired: "بک‌اند فعلی {{ .current }} هنوز شامل قوانین 1Panel است. پیش از تغییر به {{ .target }} آن‌ها را پاک کنید." ErrDockerIPv4ForwardingDisabled: "ارسال IPv4 غیرفعال است. پیش از استفاده از بک‌اند فایروال Docker، مقدار net.ipv4.ip_forward=1 را تنظیم کنید." ErrFirewallRuleSavedApplyFailed: "پیکربندی قانون جدید ذخیره شد، اما اعمال آن در دیوار آتش ناموفق بود. با همگام‌سازی دوباره تلاش کنید: {{ .detail }}" + +ErrSSHPublicKey: "یک کلید عمومی SSH معتبر وارد کنید" +ErrSSHPrivateKey: "کلید خصوصی SSH نامعتبر یا عبارت عبور نادرست است؛ کلیدهای رمزگذاری‌شده به عبارت عبور اصلی نیاز دارند" +ErrSSHKeyMismatch: "کلید عمومی و خصوصی SSH مطابقت ندارند" diff --git a/agent/i18n/lang/ja.yaml b/agent/i18n/lang/ja.yaml index 9653bfebbb7c..47d72c8154db 100644 --- a/agent/i18n/lang/ja.yaml +++ b/agent/i18n/lang/ja.yaml @@ -731,3 +731,7 @@ FileTaskRename: '保存先ファイル名:{{ .name }}' ErrFirewallBackendCleanupRequired: "現在のバックエンド {{ .current }} に 1Panel ルールが残っています。{{ .target }} に切り替える前に削除してください。" ErrDockerIPv4ForwardingDisabled: "IPv4 転送が無効です。Docker のファイアウォールバックエンドを使用する前に net.ipv4.ip_forward=1 を設定してください。" ErrFirewallRuleSavedApplyFailed: "新しいルール設定は保存されましたが、ファイアウォールへの適用に失敗しました。同期で再試行してください:{{ .detail }}" + +ErrSSHPublicKey: "有効な SSH 公開鍵を入力してください" +ErrSSHPrivateKey: "SSH 秘密鍵の形式が無効か、パスフレーズが間違っています。暗号化された鍵には元のパスフレーズが必要です" +ErrSSHKeyMismatch: "SSH 公開鍵と秘密鍵が一致しません" diff --git a/agent/i18n/lang/ko.yaml b/agent/i18n/lang/ko.yaml index bd9aef6128c3..1f72e42e971b 100644 --- a/agent/i18n/lang/ko.yaml +++ b/agent/i18n/lang/ko.yaml @@ -731,3 +731,7 @@ FileTaskRename: '대상 파일 이름: {{ .name }}' ErrFirewallBackendCleanupRequired: "현재 백엔드 {{ .current }}에 1Panel 규칙이 남아 있습니다. {{ .target }}로 전환하기 전에 정리하세요." ErrDockerIPv4ForwardingDisabled: "IPv4 전달이 비활성화되어 있습니다. Docker 방화벽 백엔드를 사용하기 전에 net.ipv4.ip_forward=1을 설정하세요." ErrFirewallRuleSavedApplyFailed: "새 규칙 설정이 저장되었지만 방화벽에 적용하지 못했습니다. 동기화하여 다시 시도하세요: {{ .detail }}" + +ErrSSHPublicKey: "유효한 SSH 공개 키를 입력하세요" +ErrSSHPrivateKey: "SSH 개인 키 형식이 잘못되었거나 암호가 틀립니다. 암호화된 키에는 원래 암호가 필요합니다" +ErrSSHKeyMismatch: "SSH 공개 키와 개인 키가 일치하지 않습니다" diff --git a/agent/i18n/lang/lo.yaml b/agent/i18n/lang/lo.yaml index 1875c8c7f6fb..77fd89f8b16e 100644 --- a/agent/i18n/lang/lo.yaml +++ b/agent/i18n/lang/lo.yaml @@ -722,3 +722,7 @@ FileTaskRename: 'ຊື່ໄຟລ໌ປາຍທາງ: {{ .name }}' ErrFirewallBackendCleanupRequired: "ແບັກເອນປັດຈຸບັນ {{ .current }} ຍັງມີກົດຂອງ 1Panel. ກະລຸນາລຶບອອກກ່ອນປ່ຽນໄປ {{ .target }}." ErrDockerIPv4ForwardingDisabled: "ການສົ່ງຕໍ່ IPv4 ຖືກປິດ. ກະລຸນາຕັ້ງ net.ipv4.ip_forward=1 ກ່ອນໃຊ້ແບັກເອນໄຟວໍຂອງ Docker." ErrFirewallRuleSavedApplyFailed: "ບັນທຶກການຕັ້ງຄ່າກົດໃໝ່ແລ້ວ ແຕ່ນຳໃຊ້ກັບໄຟວໍບໍ່ສຳເລັດ. ລອງອີກຄັ້ງດ້ວຍການຊິງຂໍ້ມູນ: {{ .detail }}" + +ErrSSHPublicKey: "ກະລຸນາປ້ອນຄີສາທາລະນະ SSH ທີ່ຖືກຕ້ອງ" +ErrSSHPrivateKey: "ຄີສ່ວນຕົວ SSH ບໍ່ຖືກຕ້ອງ ຫຼື ລະຫັດຜ່ານຜິດ; ຄີທີ່ເຂົ້າລະຫັດຕ້ອງໃຊ້ລະຫັດຜ່ານເດີມ" +ErrSSHKeyMismatch: "ຄີສາທາລະນະ ແລະ ຄີສ່ວນຕົວ SSH ບໍ່ກົງກັນ" diff --git a/agent/i18n/lang/ms.yaml b/agent/i18n/lang/ms.yaml index dd261ff9443e..2e90ec6762dc 100644 --- a/agent/i18n/lang/ms.yaml +++ b/agent/i18n/lang/ms.yaml @@ -731,3 +731,7 @@ FileTaskRename: 'Nama fail sasaran: {{ .name }}' ErrFirewallBackendCleanupRequired: "Bahagian belakang semasa {{ .current }} masih mengandungi peraturan 1Panel. Buangkannya sebelum beralih kepada {{ .target }}." ErrDockerIPv4ForwardingDisabled: "Pemajuan IPv4 dilumpuhkan. Tetapkan net.ipv4.ip_forward=1 sebelum menggunakan bahagian belakang tembok api Docker." ErrFirewallRuleSavedApplyFailed: "Konfigurasi peraturan baharu telah disimpan, tetapi gagal digunakan pada tembok api. Cuba lagi melalui penyegerakan: {{ .detail }}" + +ErrSSHPublicKey: "Masukkan kunci awam SSH yang sah" +ErrSSHPrivateKey: "Kunci peribadi SSH tidak sah atau frasa laluan salah; kunci yang disulitkan memerlukan frasa laluan asal" +ErrSSHKeyMismatch: "Kunci awam dan peribadi SSH tidak sepadan" diff --git a/agent/i18n/lang/pt-BR.yaml b/agent/i18n/lang/pt-BR.yaml index a8a2544a769c..9b9d7da92592 100644 --- a/agent/i18n/lang/pt-BR.yaml +++ b/agent/i18n/lang/pt-BR.yaml @@ -731,3 +731,7 @@ FileTaskRename: 'Nome do arquivo de destino: {{ .name }}' ErrFirewallBackendCleanupRequired: "O backend atual {{ .current }} ainda contém regras do 1Panel. Remova-as antes de mudar para {{ .target }}." ErrDockerIPv4ForwardingDisabled: "O encaminhamento IPv4 está desativado. Defina net.ipv4.ip_forward=1 antes de usar o backend de firewall do Docker." ErrFirewallRuleSavedApplyFailed: "A configuração da nova regra foi salva, mas não pôde ser aplicada ao firewall. Tente novamente por meio da sincronização: {{ .detail }}" + +ErrSSHPublicKey: "Informe uma chave pública SSH válida" +ErrSSHPrivateKey: "Chave privada SSH inválida ou senha incorreta; chaves criptografadas exigem a senha original" +ErrSSHKeyMismatch: "As chaves pública e privada SSH não correspondem" diff --git a/agent/i18n/lang/ru.yaml b/agent/i18n/lang/ru.yaml index 89ed5305ee1c..ad6838b3143e 100644 --- a/agent/i18n/lang/ru.yaml +++ b/agent/i18n/lang/ru.yaml @@ -731,3 +731,7 @@ FileTaskRename: 'Имя целевого файла: {{ .name }}' ErrFirewallBackendCleanupRequired: "В текущем бэкенде {{ .current }} остались правила 1Panel. Удалите их перед переключением на {{ .target }}." ErrDockerIPv4ForwardingDisabled: "Пересылка IPv4 отключена. Перед использованием бэкенда межсетевого экрана Docker установите net.ipv4.ip_forward=1." ErrFirewallRuleSavedApplyFailed: "Настройки нового правила сохранены, но применить их к межсетевому экрану не удалось. Повторите попытку с помощью синхронизации: {{ .detail }}" + +ErrSSHPublicKey: "Введите действительный открытый ключ SSH" +ErrSSHPrivateKey: "Недопустимый закрытый ключ SSH или неверная парольная фраза; для зашифрованных ключей требуется исходная парольная фраза" +ErrSSHKeyMismatch: "Открытый и закрытый ключи SSH не соответствуют друг другу" diff --git a/agent/i18n/lang/tr.yaml b/agent/i18n/lang/tr.yaml index ca814031479b..adf567a5bc5b 100644 --- a/agent/i18n/lang/tr.yaml +++ b/agent/i18n/lang/tr.yaml @@ -731,3 +731,7 @@ FileTaskRename: 'Hedef dosya adı: {{ .name }}' ErrFirewallBackendCleanupRequired: "Mevcut {{ .current }} arka ucunda hâlâ 1Panel kuralları var. {{ .target }} arka ucuna geçmeden önce bunları temizleyin." ErrDockerIPv4ForwardingDisabled: "IPv4 yönlendirmesi devre dışı. Docker güvenlik duvarı arka ucunu kullanmadan önce net.ipv4.ip_forward=1 ayarını yapın." ErrFirewallRuleSavedApplyFailed: "Yeni kural yapılandırması kaydedildi, ancak güvenlik duvarına uygulanamadı. Eşitleme yaparak yeniden deneyin: {{ .detail }}" + +ErrSSHPublicKey: "Geçerli bir SSH açık anahtarı girin" +ErrSSHPrivateKey: "SSH özel anahtarı geçersiz veya parola yanlış; şifreli anahtarlar mevcut parolayı gerektirir" +ErrSSHKeyMismatch: "SSH açık ve özel anahtarları eşleşmiyor" diff --git a/agent/i18n/lang/zh-Hant.yaml b/agent/i18n/lang/zh-Hant.yaml index ae7d9b747ccd..a2478efe3cb3 100644 --- a/agent/i18n/lang/zh-Hant.yaml +++ b/agent/i18n/lang/zh-Hant.yaml @@ -731,3 +731,17 @@ FileTaskRename: '目標檔名:{{ .name }}' ErrFirewallBackendCleanupRequired: "目前後端 {{ .current }} 中仍有 1Panel 規則,請先清理後再切換至 {{ .target }}。" ErrDockerIPv4ForwardingDisabled: "IPv4 轉送尚未啟用,請先設定 net.ipv4.ip_forward=1,再使用 Docker 防火牆後端。" ErrFirewallRuleSavedApplyFailed: "新規則設定已儲存,但套用至防火牆失敗。可透過同步重試:{{ .detail }}" + +ErrSSHPublicKey: "請輸入有效的 SSH 公鑰" +ErrSSHPrivateKey: "SSH 私鑰格式無效或密碼錯誤;加密私鑰需要填寫原密碼" +ErrSSHKeyMismatch: "SSH 公鑰與私鑰不相符" + +NetworkCleanupDeleted: "網路 [{{ .name }}] ({{ .id }}) 已刪除" +NetworkCleanupProtected: "跳過網路 [{{ .name }}] ({{ .id }}):預設保留網路,未刪除" +NetworkCleanupConnected: "跳過網路 [{{ .name }}] ({{ .id }}):仍有容器連接或正在使用,未刪除" +NetworkCleanupUnsupported: "跳過網路 [{{ .name }}] ({{ .id }}):特殊網路,未刪除" +NetworkCleanupGone: "跳過網路 [{{ .name }}] ({{ .id }}):網路已不存在" +NetworkCleanupInspectFailed: "網路 [{{ .name }}] ({{ .id }}) 檢查失敗,未刪除" +NetworkCleanupRemoveFailed: "網路 [{{ .name }}] ({{ .id }}) 刪除失敗" +NetworkCleanupSummary: "網路清理完成:已刪除 {{ .deleted }},已跳過 {{ .skipped }},失敗 {{ .failed }}" +NetworkCleanupPartialFailure: "部分網路清理失敗,請查看任務日誌" diff --git a/agent/i18n/lang/zh.yaml b/agent/i18n/lang/zh.yaml index 00852883dfd6..ac712ba0553e 100644 --- a/agent/i18n/lang/zh.yaml +++ b/agent/i18n/lang/zh.yaml @@ -731,3 +731,17 @@ FileTaskRename: '目标文件名:{{ .name }}' ErrFirewallBackendCleanupRequired: "当前后端 {{ .current }} 中仍有 1Panel 规则,请先清理后再切换到 {{ .target }}。" ErrDockerIPv4ForwardingDisabled: "IPv4 转发未开启,请先设置 net.ipv4.ip_forward=1,再使用 Docker 防火墙后端。" ErrFirewallRuleSavedApplyFailed: "新规则配置已保存,但应用到防火墙失败。可通过同步重试:{{ .detail }}" + +ErrSSHPublicKey: "请输入有效的 SSH 公钥" +ErrSSHPrivateKey: "SSH 私钥格式无效或密码错误;加密私钥需要填写原密码" +ErrSSHKeyMismatch: "SSH 公钥与私钥不匹配" + +NetworkCleanupDeleted: "网络 [{{ .name }}] ({{ .id }}) 已删除" +NetworkCleanupProtected: "跳过网络 [{{ .name }}] ({{ .id }}):默认保留网络,未删除" +NetworkCleanupConnected: "跳过网络 [{{ .name }}] ({{ .id }}):仍有容器连接或正在使用,未删除" +NetworkCleanupUnsupported: "跳过网络 [{{ .name }}] ({{ .id }}):特殊网络,未删除" +NetworkCleanupGone: "跳过网络 [{{ .name }}] ({{ .id }}):网络已不存在" +NetworkCleanupInspectFailed: "网络 [{{ .name }}] ({{ .id }}) 检查失败,未删除" +NetworkCleanupRemoveFailed: "网络 [{{ .name }}] ({{ .id }}) 删除失败" +NetworkCleanupSummary: "网络清理完成:已删除 {{ .deleted }},已跳过 {{ .skipped }},失败 {{ .failed }}" +NetworkCleanupPartialFailure: "部分网络清理失败,请查看任务日志" diff --git a/agent/router/ro_container.go b/agent/router/ro_container.go index 8a715a18c8c5..cba61e9c4a25 100644 --- a/agent/router/ro_container.go +++ b/agent/router/ro_container.go @@ -77,6 +77,7 @@ func (s *ContainerRouter) InitRouter(Router *gin.RouterGroup) { baRouter.GET("/network", baseApi.ListNetwork) baRouter.POST("/network/del", baseApi.DeleteNetwork) + baRouter.POST("/network/clean", baseApi.CleanNetworks) baRouter.POST("/network/search", baseApi.SearchNetwork) baRouter.POST("/network", baseApi.CreateNetwork) baRouter.GET("/volume", baseApi.ListVolume) diff --git a/agent/utils/docker/network_cleanup.go b/agent/utils/docker/network_cleanup.go new file mode 100644 index 000000000000..e5e24e8cf773 --- /dev/null +++ b/agent/utils/docker/network_cleanup.go @@ -0,0 +1,108 @@ +package docker + +import ( + "context" + "sort" + + "github.com/1Panel-dev/1Panel/agent/app/dto" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/errdefs" +) + +type NetworkCleanupClient interface { + NetworkList(context.Context, network.ListOptions) ([]network.Inspect, error) + ContainerList(context.Context, container.ListOptions) ([]container.Summary, error) + NetworkInspect(context.Context, string, network.InspectOptions) (network.Inspect, error) + NetworkRemove(context.Context, string) error +} + +func CleanUnusedNetworks(ctx context.Context, cli NetworkCleanupClient, onResult ...func(string, dto.NetworkCleanupItem)) (*dto.NetworkCleanupReport, error) { + networks, err := cli.NetworkList(ctx, network.ListOptions{}) + if err != nil { + return nil, err + } + containers, err := cli.ContainerList(ctx, container.ListOptions{All: true}) + if err != nil { + return nil, err + } + used := make(map[string]bool) + for _, c := range containers { + if c.NetworkSettings == nil { + continue + } + for name, endpoint := range c.NetworkSettings.Networks { + used[name] = true + if endpoint != nil { + used[endpoint.NetworkID] = true + } + } + } + report := &dto.NetworkCleanupReport{Deleted: []dto.NetworkCleanupItem{}, Skipped: []dto.NetworkCleanupItem{}, Failed: []dto.NetworkCleanupItem{}} + record := func(status string, item dto.NetworkCleanupItem) { + switch status { + case "deleted": + report.Deleted = append(report.Deleted, item) + case "skipped": + report.Skipped = append(report.Skipped, item) + case "failed": + report.Failed = append(report.Failed, item) + } + for _, notify := range onResult { + if notify != nil { + notify(status, item) + } + } + } + sort.Slice(networks, func(i, j int) bool { return networks[i].Name < networks[j].Name }) + for _, n := range networks { + if err := ctx.Err(); err != nil { + return report, err + } + item := dto.NetworkCleanupItem{ID: n.ID, Name: n.Name} + switch { + case n.Name == "none" || n.Name == "host" || n.Name == "bridge" || n.Name == "1panel-network": + item.Reason = "protected" + case n.Scope != "local" || n.Ingress || n.ConfigOnly: + item.Reason = "unsupported_network" + case used[n.Name] || used[n.ID]: + item.Reason = "container_connected" + } + if item.Reason != "" { + record("skipped", item) + continue + } + inspected, err := cli.NetworkInspect(ctx, n.ID, network.InspectOptions{}) + if err != nil { + if errdefs.IsNotFound(err) { + item.Reason = "already_removed" + record("skipped", item) + } else { + item.Reason = "inspect_failed" + record("failed", item) + } + continue + } + if len(inspected.Containers) > 0 { + item.Reason = "container_connected" + record("skipped", item) + continue + } + if err := cli.NetworkRemove(ctx, n.ID); err != nil { + switch { + case errdefs.IsNotFound(err): + item.Reason = "already_removed" + record("skipped", item) + case errdefs.IsConflict(err): + item.Reason = "network_in_use" + record("skipped", item) + default: + item.Reason = "remove_failed" + record("failed", item) + } + continue + } + record("deleted", item) + } + return report, nil +} diff --git a/agent/utils/docker/network_cleanup_test.go b/agent/utils/docker/network_cleanup_test.go new file mode 100644 index 000000000000..46e605c69e55 --- /dev/null +++ b/agent/utils/docker/network_cleanup_test.go @@ -0,0 +1,72 @@ +package docker + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/errdefs" +) + +type cleanupFake struct { + networks []network.Inspect + containers []container.Summary + inspected map[string]network.Inspect + inspectErrors, removeErrors map[string]error + listError error + removed []string +} + +func (f *cleanupFake) NetworkList(context.Context, network.ListOptions) ([]network.Inspect, error) { + return f.networks, nil +} +func (f *cleanupFake) ContainerList(_ context.Context, o container.ListOptions) ([]container.Summary, error) { + if !o.All { + panic("must include stopped containers") + } + return f.containers, f.listError +} +func (f *cleanupFake) NetworkInspect(_ context.Context, id string, _ network.InspectOptions) (network.Inspect, error) { + return f.inspected[id], f.inspectErrors[id] +} +func (f *cleanupFake) NetworkRemove(_ context.Context, id string) error { + f.removed = append(f.removed, id) + return f.removeErrors[id] +} + +func TestNetworkCleanupProtectsAndReports(t *testing.T) { + f := &cleanupFake{inspected: map[string]network.Inspect{"attached": {Containers: map[string]network.EndpointResource{"container": {}}}}, inspectErrors: map[string]error{"unknown": errors.New("inspect failure")}, removeErrors: map[string]error{"race": errdefs.Conflict(errors.New("has active endpoints")), "gone": errdefs.NotFound(errors.New("gone")), "failed": errors.New("denied")}} + for _, name := range []string{"none", "host", "bridge", "1panel-network", "configured", "attached", "stopped", "free", "unknown", "race", "gone", "failed"} { + f.networks = append(f.networks, network.Inspect{ID: name, Name: name, Scope: "local"}) + } + // Compose ownership labels do not protect an otherwise unused network. + for i := range f.networks { + if f.networks[i].Name == "configured" { + f.networks[i].Labels = map[string]string{"com.docker.compose.project": "demo", "com.docker.compose.network": "default"} + } + } + f.containers = []container.Summary{{State: "exited", NetworkSettings: &container.NetworkSettingsSummary{Networks: map[string]*network.EndpointSettings{"stopped": {NetworkID: "stopped"}}}}} + report, err := CleanUnusedNetworks(context.Background(), f) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(f.removed, []string{"configured", "failed", "free", "gone", "race"}) { + t.Fatal(f.removed) + } + if len(report.Deleted) != 2 || report.Deleted[0].Name != "configured" || report.Deleted[1].Name != "free" || len(report.Failed) != 2 || len(report.Skipped) != 8 { + t.Fatalf("%+v", report) + } +} + +func TestNetworkCleanupDiscoveryFailureDeletesNothing(t *testing.T) { + f := &cleanupFake{networks: []network.Inspect{{ID: "free", Name: "free", Scope: "local"}}, listError: errors.New("cannot list containers")} + if _, err := CleanUnusedNetworks(context.Background(), f); err == nil { + t.Fatal("expected error") + } + if len(f.removed) > 0 { + t.Fatal(f.removed) + } +} diff --git a/core/cmd/server/docs/x-log.json b/core/cmd/server/docs/x-log.json index 5b1d13eb02d1..2735edae5012 100644 --- a/core/cmd/server/docs/x-log.json +++ b/core/cmd/server/docs/x-log.json @@ -644,6 +644,13 @@ "formatZH": "创建容器网络 name", "formatEN": "create container network [name]" }, + "/containers/network/clean": { + "bodyKeys": [], + "paramKeys": [], + "beforeFunctions": [], + "formatZH": "清理未使用的容器网络", + "formatEN": "Clean unused container networks" + }, "/containers/network/del": { "bodyKeys": [ "names" @@ -4039,12 +4046,21 @@ }, "/runtimes/operate": { "bodyKeys": [ - "id" + "ID" ], "paramKeys": [], - "beforeFunctions": [], - "formatZH": "操作运行环境 [id]", - "formatEN": "Operate runtime [id]" + "beforeFunctions": [ + { + "input_column": "id", + "input_value": "ID", + "isList": false, + "db": "runtimes", + "output_column": "name", + "output_value": "name" + } + ], + "formatZH": "操作运行环境 [name]", + "formatEN": "Operate runtime [name]" }, "/runtimes/php/config": { "bodyKeys": [ diff --git a/docs/container-network-cleanup.md b/docs/container-network-cleanup.md new file mode 100644 index 000000000000..5db7758978ce --- /dev/null +++ b/docs/container-network-cleanup.md @@ -0,0 +1,33 @@ +# 批量清理未使用的容器网络 + +`POST /api/v2/containers/network/clean`,使用现有 1Panel 认证,不需要请求体。 +这是异步接口,立即返回 `data.taskID`。实际结果在任务中心和任务日志查看,HTTP 请求结束不会取消任务。 + +规则: + +- 永远保留 `none`、`host`、`bridge`、`1panel-network`。 +- 保留所有容器(包括停止容器)引用的网络,删除前再通过 NetworkInspect 检查连接。 +- 不检查 Compose 文件或应用配置引用;仅被这些配置引用、没有容器连接的普通网络也会被删除。 +- 当前仅清理本地普通网络,跳过 Swarm、ingress 和 config-only 网络。 +- Docker 删除时发现新连接会拒绝删除;不会强制断开容器。 + +成功响应示例: + +```json +{"code":200,"data":{"taskID":"服务端生成的任务 UUID"}} +``` + +任务类型为 Container,操作为 TaskClean。网络页“清理网络”按钮调用此接口,拿到 taskID 后立即打开任务日志,并刷新任务中心。 +每个网络处理完成后写入名称、ID 和结果;有容器连接或 Docker 返回占用冲突时,明确记录“未删除”。最后记录已删除、跳过和失败数量。 +跳过在用网络属于正常结果;检查/删除失败会将任务标记为失败,已删除的网络不回滚。 + +日志调用链: + +- `agent/app/service/container_network_cleanup.go`:NewTaskWithOps 创建任务,AddSubTask 注册清理步骤,Execute 执行任务,t.Log 写逐项日志。 +- `agent/app/task/task.go`:任务持久化,维护状态,写入开始、结束标记;日志文件为 `/Container/.log`。 +- `frontend/src/views/container/network/index.vue`:请求成功后调用 openTaskLog(taskID)。 +- `frontend/src/components/log/task/index.vue`:openWithTaskID 打开日志弹窗。 +- `frontend/src/components/log/file/index.vue`:通过 `/logs/tasks/read` 按行读取任务日志。 + +范围:不检查 Compose 文件或应用配置引用;仅清理本地普通网络。原 `/containers/prune` 接口仍保持原逻辑。 +测试使用假 Docker 客户端和临时任务数据库,不执行真实网络删除。 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3b327fac7326..86c51d31dbca 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -126,7 +126,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1014,7 +1013,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1031,7 +1029,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1048,7 +1045,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1065,7 +1061,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1082,7 +1077,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1099,7 +1093,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1116,7 +1109,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1133,7 +1125,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1150,7 +1141,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1167,7 +1157,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1184,7 +1173,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1201,7 +1189,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1218,7 +1205,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1235,7 +1221,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1252,7 +1237,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1269,7 +1253,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1286,7 +1269,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1303,7 +1285,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1320,7 +1301,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1337,7 +1317,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1354,7 +1333,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1371,7 +1349,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1388,7 +1365,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1405,7 +1381,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1422,7 +1397,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1439,7 +1413,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1773,18 +1746,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -2013,7 +1974,6 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2053,7 +2013,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2074,7 +2033,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2095,7 +2053,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2116,7 +2073,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2137,7 +2093,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2158,7 +2113,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2179,7 +2133,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2200,7 +2153,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2221,7 +2173,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2242,7 +2193,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2263,7 +2213,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2284,7 +2233,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2305,7 +2253,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2350,7 +2297,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2367,7 +2313,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2384,7 +2329,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2401,7 +2345,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2418,7 +2361,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2435,7 +2377,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2452,7 +2393,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2469,7 +2409,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2486,7 +2425,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2503,7 +2441,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2520,7 +2457,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2537,7 +2473,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2554,7 +2489,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2571,7 +2505,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2978,7 +2911,6 @@ "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/lodash": "*" } @@ -3007,12 +2939,11 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", - "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", - "dev": true, + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~8.9.0" } @@ -3315,7 +3246,6 @@ "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/types": "8.68.0", @@ -3999,7 +3929,6 @@ "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.4.0.tgz", "integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.4.0", @@ -4046,15 +3975,13 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4311,7 +4238,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4341,7 +4267,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.26", "caniuse-lite": "^1.0.30001812", @@ -4682,7 +4607,6 @@ "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", @@ -5377,7 +5301,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "dev": true, "license": "Apache-2.0", "optional": true, "bin": { @@ -5770,7 +5693,6 @@ "devOptional": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -5832,7 +5754,6 @@ "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -5892,7 +5813,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6244,7 +6164,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -6346,7 +6265,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6826,7 +6744,7 @@ "version": "5.1.9", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/imurmurhash": { @@ -6899,7 +6817,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6925,7 +6843,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -6970,7 +6888,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -7056,7 +6973,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -7085,7 +7002,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -7133,7 +7049,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -7749,15 +7664,13 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash-es": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash-unified": { "version": "1.0.3", @@ -7961,7 +7874,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.5.0", @@ -8259,7 +8171,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -8447,7 +8358,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, "license": "MIT", "optional": true }, @@ -8703,7 +8613,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -8728,7 +8637,6 @@ "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/devtools-api": "^7.7.7" }, @@ -8809,7 +8717,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", @@ -8921,7 +8828,6 @@ "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9290,7 +9196,6 @@ "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/types": "=0.143.0", "@rolldown/pluginutils": "^1.0.0" @@ -9400,9 +9305,8 @@ "version": "1.105.0", "resolved": "https://registry.npmjs.org/sass/-/sass-1.105.0.tgz", "integrity": "sha512-4EjSuRTyG8M4yRXXY1PT9OUV5zz23xBxJkbIAprohKHqf6HOU4MCpE3U3vAC+iO1gxobBjESNbcj8rYAQZWnyA==", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^5.0.0", "immutable": "^5.1.5", @@ -9628,29 +9532,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -9985,34 +9866,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/terser": { - "version": "5.44.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", - "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/text-extensions": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-1.9.0.tgz", @@ -10094,7 +9947,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10106,7 +9958,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -10171,7 +10022,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10210,7 +10060,7 @@ "version": "8.9.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unimport": { @@ -10600,7 +10450,6 @@ "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", @@ -10875,7 +10724,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -10896,7 +10744,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -10917,7 +10764,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -10938,7 +10784,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -10959,7 +10804,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -10980,7 +10824,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11001,7 +10844,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11022,7 +10864,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11043,7 +10884,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11064,7 +10904,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11085,7 +10924,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -11124,7 +10962,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz", "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.42", "@vue/compiler-sfc": "3.5.42", @@ -11178,7 +11015,6 @@ "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" @@ -11205,7 +11041,6 @@ "integrity": "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", diff --git a/frontend/src/api/modules/container.ts b/frontend/src/api/modules/container.ts index e5176cfa515a..0d0abbd65680 100644 --- a/frontend/src/api/modules/container.ts +++ b/frontend/src/api/modules/container.ts @@ -3,13 +3,12 @@ import { ResPage, SearchWithPage } from '../interface'; import { Container } from '../interface/container'; import { TimeoutEnum } from '@/enums/http-enum'; -export const searchContainer = (params: Container.ContainerSearch, currentNode?: string) => { - return http.post>( - `/containers/search`, - params, - TimeoutEnum.T_40S, - currentNode ? { CurrentNode: currentNode } : undefined, - ); +export const searchContainer = (params: Container.ContainerSearch, currentNode?: string, signal?: AbortSignal) => { + return http.postWithConfig>(`/containers/search`, params, { + timeout: TimeoutEnum.T_40S, + headers: currentNode ? { CurrentNode: currentNode } : undefined, + signal, + }); }; export const listContainer = () => { return http.post>(`/containers/list`, {}); @@ -44,12 +43,11 @@ export const downloadContainerFile = (params: { containerID: string; path: strin timeout: TimeoutEnum.T_40S, }); }; -export const loadContainerStatus = (currentNode?: string) => { - return http.get( - `/containers/status`, - {}, - currentNode ? { headers: { CurrentNode: currentNode } } : {}, - ); +export const loadContainerStatus = (currentNode?: string, containersOnly = false, signal?: AbortSignal) => { + return http.get(`/containers/status`, containersOnly ? { containersOnly: true } : {}, { + headers: currentNode ? { CurrentNode: currentNode } : undefined, + signal, + }); }; export const loadResourceLimit = () => { return http.get(`/containers/limit`); @@ -90,12 +88,11 @@ export const containerItemStats = (containerID: string, currentNode?: string) => currentNode ? { CurrentNode: currentNode } : undefined, ); }; -export const containerListStats = (currentNode?: string) => { - return http.get>( - `/containers/list/stats`, - {}, - currentNode ? { headers: { CurrentNode: currentNode } } : {}, - ); +export const containerListStats = (currentNode?: string, ids?: string[], signal?: AbortSignal) => { + return http.get>(`/containers/list/stats`, ids ? { ids: ids.join(',') } : {}, { + headers: currentNode ? { CurrentNode: currentNode } : undefined, + signal, + }); }; export const containerStats = (id: string) => { return http.get(`/containers/stats/${id}`); @@ -164,6 +161,9 @@ export const searchNetwork = (params: SearchWithPage) => { export const listNetwork = () => { return http.get>(`/containers/network`); }; +export const cleanNetworks = () => { + return http.post<{ taskID: string }>(`/containers/network/clean`, {}); +}; export const deleteNetwork = (params: Container.BatchDelete) => { return http.post(`/containers/network/del`, params); }; diff --git a/frontend/src/composables/useSearchPersistence.ts b/frontend/src/composables/useSearchPersistence.ts new file mode 100644 index 000000000000..fcef3d923205 --- /dev/null +++ b/frontend/src/composables/useSearchPersistence.ts @@ -0,0 +1,63 @@ +import { storeToRefs } from 'pinia'; +import { watch, type Ref } from 'vue'; +import GlobalStore from '@/store/modules/global'; + +type SearchValue = string | number | undefined; +type SearchFields = Record>; + +export const useSearchPersistence = (page: string, fields: SearchFields, scope?: () => SearchValue) => { + const { currentNode } = storeToRefs(GlobalStore()); + const defaults = Object.fromEntries(Object.entries(fields).map(([name, field]) => [name, field.value])); + let initialized = false; + let restoring = false; + let activeKey: string | undefined; + + watch( + () => { + const context = scope?.(); + if (scope && context === undefined) return undefined; + return `1panel:search:v1:${JSON.stringify([currentNode.value, page, context ?? ''])}`; + }, + (key) => { + activeKey = key; + let saved: Record = {}; + try { + const parsed = activeKey ? JSON.parse(localStorage.getItem(activeKey) || '{}') : {}; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) saved = parsed; + } catch {} + restoring = true; + for (const [name, field] of Object.entries(fields)) { + const value = saved[name]; + const initial = defaults[name]; + let fallback = initial; + if (initialized) { + fallback = typeof initial === 'string' ? '' : undefined; + } + const valid = + (typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value))) && + (fallback === undefined || typeof value === typeof fallback); + if (value === null) { + field.value = typeof initial === 'string' ? '' : undefined; + } else if (valid) { + field.value = value as SearchValue; + } else { + field.value = fallback; + } + } + restoring = false; + initialized = true; + }, + { immediate: true, flush: 'sync' }, + ); + + watch( + () => Object.fromEntries(Object.entries(fields).map(([name, field]) => [name, field.value ?? null])), + (value) => { + if (restoring || !activeKey) return; + try { + localStorage.setItem(activeKey, JSON.stringify(value)); + } catch {} + }, + { flush: 'sync' }, + ); +}; diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index f2a43c34a983..14b75ce5fad2 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -2888,6 +2888,9 @@ const message = { privateKey: 'Private Key', publicKey: 'Public Key', password: 'Password', + existingPassPhrase: 'Existing private key passphrase', + existingPassPhraseHelper: + 'Enter the original passphrase if the private key is encrypted; otherwise leave blank. This does not change the passphrase.', createMode: 'Creation Method', generate: 'Auto-generate', unSyncPass: 'Key password cannot be synchronized', diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index cdabe72a42e1..4be0207ce370 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -2930,6 +2930,9 @@ const message = { privateKey: 'Clave privada', publicKey: 'Clave pública', password: 'Contraseña', + existingPassPhrase: 'Contraseña actual de la clave privada', + existingPassPhraseHelper: + 'Introduce la contraseña original si la clave privada está cifrada; en caso contrario, deja el campo vacío. Esto no cambia la contraseña.', createMode: 'Método de creación', generate: 'Autogenerar', unSyncPass: 'La contraseña de la clave no se puede sincronizar', diff --git a/frontend/src/lang/modules/fa.ts b/frontend/src/lang/modules/fa.ts index d47aecd2ac99..1ef3bc82a2a1 100644 --- a/frontend/src/lang/modules/fa.ts +++ b/frontend/src/lang/modules/fa.ts @@ -2861,6 +2861,9 @@ const message = { privateKey: 'کلید خصوصی', publicKey: 'کلید عمومی', password: 'رمز عبور', + existingPassPhrase: 'عبارت عبور فعلی کلید خصوصی', + existingPassPhraseHelper: + 'اگر کلید خصوصی رمزگذاری شده است، عبارت عبور اصلی را وارد کنید؛ در غیر این صورت خالی بگذارید. این کار عبارت عبور را تغییر نمی‌دهد.', createMode: 'روش ایجاد', generate: 'تولید خودکار', unSyncPass: 'رمز عبور کلید قابل همگام‌سازی نیست', diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index 819866e7a430..ba3336f960c6 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -2865,6 +2865,9 @@ const message = { privateKey: '秘密鍵', publicKey: '公開鍵', password: 'パスワード', + existingPassPhrase: '既存の秘密鍵のパスフレーズ', + existingPassPhraseHelper: + '秘密鍵が暗号化されている場合は元のパスフレーズを入力し、それ以外は空欄にしてください。パスフレーズは変更されません。', createMode: '作成方法', generate: '自動生成', unSyncPass: '鍵パスワードは同期できません', diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index 22cf61f590c8..24725ebe716e 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -2827,6 +2827,9 @@ const message = { privateKey: '개인 키', publicKey: '공개 키', password: '비밀번호', + existingPassPhrase: '기존 개인 키 암호', + existingPassPhraseHelper: + '개인 키가 암호화되어 있으면 원래 암호를 입력하고, 그렇지 않으면 비워 두세요. 이 작업은 암호를 변경하지 않습니다.', createMode: '생성 방식', generate: '자동 생성', unSyncPass: '키 비밀번호 동기화 불가', diff --git a/frontend/src/lang/modules/lo.ts b/frontend/src/lang/modules/lo.ts index 12e16868aefb..40038f6bdf4c 100644 --- a/frontend/src/lang/modules/lo.ts +++ b/frontend/src/lang/modules/lo.ts @@ -2807,6 +2807,9 @@ const message = { privateKey: 'ຄີສ່ວນຕົວ', publicKey: 'ຄີສາທາລະນະ', password: 'ລະຫັດຜ່ານ', + existingPassPhrase: 'ລະຫັດຜ່ານເດີມຂອງຄີສ່ວນຕົວ', + existingPassPhraseHelper: + 'ຖ້າຄີສ່ວນຕົວຖືກເຂົ້າລະຫັດ ໃຫ້ປ້ອນລະຫັດຜ່ານເດີມ; ຖ້າບໍ່ ໃຫ້ປະໄວ້ຫວ່າງ. ການດຳເນີນການນີ້ບໍ່ປ່ຽນລະຫັດຜ່ານ.', createMode: 'ວິທີການສ້າງ', generate: 'ສ້າງອັດຕະໂນມັດ', unSyncPass: 'ບໍ່ສາມາດຊິງຄ໌ລະຫັດຜ່ານຄີໄດ້', diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index 2a6054108834..ae6b54ebe163 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -2930,6 +2930,9 @@ const message = { privateKey: 'Kunci Persendirian', publicKey: 'Kunci Awam', password: 'Kata Laluan', + existingPassPhrase: 'Frasa laluan kunci peribadi sedia ada', + existingPassPhraseHelper: + 'Masukkan frasa laluan asal jika kunci peribadi disulitkan; jika tidak, biarkan kosong. Ini tidak mengubah frasa laluan.', createMode: 'Kaedah Penciptaan', generate: 'Jana Automatik', unSyncPass: 'Kata laluan kunci tidak dapat diselaraskan', diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index fd5a60def20e..fc4d6a39f0ba 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -2929,6 +2929,9 @@ const message = { privateKey: 'Chave Privada', publicKey: 'Chave Pública', password: 'Senha', + existingPassPhrase: 'Senha atual da chave privada', + existingPassPhraseHelper: + 'Informe a senha original se a chave privada estiver criptografada; caso contrário, deixe em branco. Isso não altera a senha.', createMode: 'Método de Criação', generate: 'Gerar Automaticamente', unSyncPass: 'Senha da chave não pode ser sincronizada', diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index f7b45cba4cf2..feef1be0211d 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -2903,6 +2903,9 @@ const message = { privateKey: 'Приватный ключ', publicKey: 'Публичный ключ', password: 'Пароль', + existingPassPhrase: 'Текущая парольная фраза закрытого ключа', + existingPassPhraseHelper: + 'Если закрытый ключ зашифрован, введите исходную парольную фразу; иначе оставьте поле пустым. Это не изменяет парольную фразу.', createMode: 'Способ создания', generate: 'Автогенерация', unSyncPass: 'Пароль ключа не может быть синхронизирован', diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index 965ebd93704e..a8f2887fa0a3 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -2921,6 +2921,9 @@ const message = { privateKey: 'Özel Anahtar', publicKey: 'Genel Anahtar', password: 'Parola', + existingPassPhrase: 'Mevcut özel anahtar parolası', + existingPassPhraseHelper: + 'Özel anahtar şifreliyse mevcut parolayı girin; değilse boş bırakın. Bu işlem parolayı değiştirmez.', createMode: 'Oluşturma Yöntemi', generate: 'Otomatik Oluştur', unSyncPass: 'Anahtar parolası senkronize edilemez', diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index 537457e7fc2f..216b070a49b8 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -2720,6 +2720,8 @@ const message = { privateKey: '私鑰', publicKey: '公鑰', password: '密碼', + existingPassPhrase: '現有私鑰密碼', + existingPassPhraseHelper: '私鑰已加密時填寫原密碼,未加密則留空;此處不會修改私鑰密碼。', createMode: '建立方式', generate: '自動生成', unSyncPass: '金鑰密碼無法同步', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index 8fc0dafc6d10..dd0e77f5ce26 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -2751,6 +2751,8 @@ const message = { privateKey: '私钥', publicKey: '公钥', password: '密码', + existingPassPhrase: '已有私钥密码', + existingPassPhraseHelper: '私钥已加密时填写原密码,未加密则留空;此处不会修改私钥密码。', createMode: '创建方式', generate: '自动生成', unSyncPass: '密钥密码无法同步', diff --git a/frontend/src/store/modules/process.ts b/frontend/src/store/modules/process.ts index f2795fd5f0c4..13a5a4ecebf6 100644 --- a/frontend/src/store/modules/process.ts +++ b/frontend/src/store/modules/process.ts @@ -1,5 +1,6 @@ +import { useSearchPersistence } from '@/composables/useSearchPersistence'; import { defineStore } from 'pinia'; -import { ref, reactive } from 'vue'; +import { ref, reactive, toRef } from 'vue'; import { MsgError } from '@/utils/message'; import { checkStreamAuth } from '@/utils/stream-auth'; @@ -46,6 +47,17 @@ export const ProcessStore = defineStore('ProcessStore', () => { port: undefined, }); + useSearchPersistence('host/process/process', { + pid: toRef(psSearch, 'pid'), + name: toRef(psSearch, 'name'), + username: toRef(psSearch, 'username'), + }); + useSearchPersistence('host/process/network', { + processID: toRef(netSearch, 'processID'), + processName: toRef(netSearch, 'processName'), + port: toRef(netSearch, 'port'), + }); + let pendingRequestType: 'ps' | 'net' | null = null; let queuedRequestType: 'ps' | 'net' | null = null; diff --git a/frontend/src/views/ai/agents/agent/config/tabs/plugins.vue b/frontend/src/views/ai/agents/agent/config/tabs/plugins.vue index 5187d3580230..1a799486868e 100644 --- a/frontend/src/views/ai/agents/agent/config/tabs/plugins.vue +++ b/frontend/src/views/ai/agents/agent/config/tabs/plugins.vue @@ -172,6 +172,7 @@