Compare commits

...
Author SHA1 Message Date
guoyuqi 262618a4a9 fix: fix container list 2026-10-08 17:45:09 +08:00
guoyuqi b374b70e71 fix: fix container list 2026-10-08 17:44:29 +08:00
guoyuqi 4ea3f7e4b8 fix: fix clean network 2026-10-08 17:44:29 +08:00
guoyuqi a917880cac fix: fix clean network 2026-10-08 17:44:29 +08:00
guoyuqi d41dd382ab fix: fix container list 2026-10-08 17:44:29 +08:00
Yuki Guoandssongliu 2701af9054 refactor: select bind (#13993)
* refactor: select bind

* refactor: simplify table selection state handling

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-08 17:11:28 +08:00
Yuki Guoandssongliu 4954067736 refactor: search bind (#13992)
* refactor: search bind

* refactor: simplify search persistence

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-08 16:19:29 +08:00
ssongliu b962144e7d fix(ssh): validate imported key pairs and cancel stale file reads (#13991) 2026-10-08 14:06:40 +08:00
Yuki Guo 8f1be42b6b fix: fix log text (#13990) 2026-10-08 11:32:09 +08:00
Yuki Guo c6969f231b fix: fix operation log (#13989) 2026-10-08 11:08:04 +08:00
4kerccand4kercc 3571fb8d86 fix: speed up SFTP backup upload with concurrent writes (#13976)
The SFTP uploader used io.Copy(dstFile, srcFile). Since the source is an
*os.File it implements io.WriterTo, so io.Copy took the os.File.WriteTo
branch and issued one synchronous SFTP WRITE per packet, waiting for the
server reply every time. Throughput was therefore bounded by packetSize/RTT
instead of the link capacity: on a ~68ms RTT link a 440MB backup reached
only ~0.25 MB/s, while rclone / OpenSSH sftp reach 16-19 MB/s on the very
same link and target.

pkg/sftp already provides a pipelined write path (File.ReadFrom, enabled by
UseConcurrentWrites and bounded by MaxConcurrentRequestsPerFile, default 64).
Use it for uploads, and truncate the remote file to the number of bytes
actually written when the concurrent write fails, to avoid leaving holes.

Measured against the same 68ms RTT target: a 96MiB upload went from 6m40s
(~0.25 MB/s) to ~6s (~16 MB/s); a 950MB three-site backup job from ~2h40m to
~2m04s.

Co-authored-by: 4kercc <13767585+4kercc@users.noreply.github.com>
2026-10-08 09:45:51 +08:00
91 changed files with 1267 additions and 399 deletions
+18 -3
View File
@@ -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)
}
+1 -1
View File
@@ -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 {
+2
View File
@@ -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)
@@ -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"`
}
+1 -1
View File
@@ -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"`
+76 -34
View File
@@ -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))
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 i, item := range list {
datas[i].ContainerID = item.ID
}
jobs := make(chan int)
var wg sync.WaitGroup
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
}
@@ -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
}
@@ -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)
}
})
}
}
+72 -11
View File
@@ -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 {
+1 -1
View File
@@ -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)
}
+20 -4
View File
@@ -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": [
+14
View File
@@ -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"
+4
View File
@@ -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"
+4
View File
@@ -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 مطابقت ندارند"
+4
View File
@@ -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 公開鍵と秘密鍵が一致しません"
+4
View File
@@ -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 공개 키와 개인 키가 일치하지 않습니다"
+4
View File
@@ -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 ບໍ່ກົງກັນ"
+4
View File
@@ -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"
+4
View File
@@ -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"
+4
View File
@@ -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 не соответствуют друг другу"
+4
View File
@@ -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"
+14
View File
@@ -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: "部分網路清理失敗,請查看任務日誌"
+14
View File
@@ -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: "部分网络清理失败,请查看任务日志"
+1
View File
@@ -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)
+10 -2
View File
@@ -97,7 +97,7 @@ func (s sftpClient) Upload(ctx context.Context, src, target string) (bool, error
}()
defer close(done)
client, err := sftp.NewClient(sshClient)
client, err := sftp.NewClient(sshClient, sftp.UseConcurrentWrites(true))
if err != nil {
return false, err
}
@@ -127,7 +127,15 @@ func (s sftpClient) Upload(ctx context.Context, src, target string) (bool, error
}
defer dstFile.Close()
if _, err := io.Copy(dstFile, srcFile); err != nil {
// ReadFrom uses the pipelined write path of pkg/sftp. A plain io.Copy would
// pick os.File.WriteTo (the source is an *os.File), which issues one
// synchronous SFTP WRITE per packet and waits for the reply every time, so
// the throughput is bounded by packetSize/RTT.
written, err := dstFile.ReadFrom(srcFile)
if err != nil {
// A failed concurrent write may leave holes in the remote file, so cut
// it back to the number of bytes actually written.
_ = dstFile.Truncate(written)
if ctxErr := ctx.Err(); ctxErr != nil {
return false, ctxErr
}
+108
View File
@@ -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
}
@@ -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)
}
}
+20 -4
View File
@@ -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": [
+33
View File
@@ -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`:任务持久化,维护状态,写入开始、结束标记;日志文件为 `<global.Dir.TaskDir>/Container/<taskID>.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 客户端和临时任务数据库,不执行真实网络删除。
+13 -178
View File
@@ -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",
+19 -19
View File
@@ -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<ResPage<Container.ContainerInfo>>(
`/containers/search`,
params,
TimeoutEnum.T_40S,
currentNode ? { CurrentNode: currentNode } : undefined,
);
export const searchContainer = (params: Container.ContainerSearch, currentNode?: string, signal?: AbortSignal) => {
return http.postWithConfig<ResPage<Container.ContainerInfo>>(`/containers/search`, params, {
timeout: TimeoutEnum.T_40S,
headers: currentNode ? { CurrentNode: currentNode } : undefined,
signal,
});
};
export const listContainer = () => {
return http.post<Array<Container.ContainerOption>>(`/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<Container.ContainerStatus>(
`/containers/status`,
{},
currentNode ? { headers: { CurrentNode: currentNode } } : {},
);
export const loadContainerStatus = (currentNode?: string, containersOnly = false, signal?: AbortSignal) => {
return http.get<Container.ContainerStatus>(`/containers/status`, containersOnly ? { containersOnly: true } : {}, {
headers: currentNode ? { CurrentNode: currentNode } : undefined,
signal,
});
};
export const loadResourceLimit = () => {
return http.get<Container.ResourceLimit>(`/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<Array<Container.ContainerListStats>>(
`/containers/list/stats`,
{},
currentNode ? { headers: { CurrentNode: currentNode } } : {},
);
export const containerListStats = (currentNode?: string, ids?: string[], signal?: AbortSignal) => {
return http.get<Array<Container.ContainerListStats>>(`/containers/list/stats`, ids ? { ids: ids.join(',') } : {}, {
headers: currentNode ? { CurrentNode: currentNode } : undefined,
signal,
});
};
export const containerStats = (id: string) => {
return http.get<Container.ContainerStats>(`/containers/stats/${id}`);
@@ -164,6 +161,9 @@ export const searchNetwork = (params: SearchWithPage) => {
export const listNetwork = () => {
return http.get<Array<Container.Options>>(`/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);
};
@@ -11,6 +11,7 @@
<fu-table
v-if="currentViewMode === 'table'"
v-bind="$attrs"
:data="props.data"
ref="tableRef"
@select="handleSelect"
@selection-change="handleSelectionChange"
@@ -175,7 +176,7 @@ import { useResponsivePagination } from './useResponsivePagination';
import { useTableSelection } from './useTableSelection';
const slots = useSlots();
const attrs = useAttrs();
const { isMobile, openMenuTabs } = useGlobalStore();
const { isMobile, openMenuTabs, currentNode } = useGlobalStore();
defineOptions({ name: 'ComplexTable' });
export type DropdownProps = FuTableOperationButton;
@@ -184,6 +185,8 @@ type ViewMode = 'table' | 'card';
const props = defineProps({
header: String,
data: { type: Array as PropType<any[]>, default: () => [] },
selectionContext: { type: [String, Number, Array, Object, Function], default: undefined },
paginationConfig: {
type: Object,
required: false,
@@ -239,7 +242,7 @@ const selectionColumn = computed(() =>
columnNodes.value.find((column) => (column.props as Record<string, any> | null)?.type === 'selection'),
);
const tableData = computed(() => {
const data = attrs.data;
const data = props.data;
return Array.isArray(data) ? data : [];
});
const cardContentStyle = computed(() => {
@@ -295,7 +298,7 @@ const {
isDisabled: isRightButtonDisabled,
click: rightButtonClick,
} = useContextMenu(() => props.rightButtons);
const getTableData = () => (Array.isArray(attrs.data) ? attrs.data : []);
const getTableData = () => props.data || [];
const isRowSelectable = (row: any) => {
const selectable = (selectionColumn.value?.props as Record<string, any> | null)?.selectable;
return typeof selectable === 'function' ? selectable(row) : true;
@@ -304,7 +307,6 @@ const {
selectedRows,
isRowSelected,
clearSelects,
pruneSelection,
toggleSelection,
selectRow,
syncTableSelection,
@@ -313,7 +315,18 @@ const {
handleRowClick,
handleKeyDown: handleSelectionKeyDown,
handleKeyUp,
} = useTableSelection(tableRef, getTableData, (rows) => emit('update:selects', rows), isRowSelectable);
} = useTableSelection(
tableRef,
getTableData,
(rows) => emit('update:selects', rows),
isRowSelectable,
(row) => {
const key = attrs.rowKey ?? attrs['row-key'];
if (typeof key === 'function') return row == null ? undefined : key(row);
if (typeof key === 'string') return key.split('.').reduce((value, part) => value?.[part], row);
return row?.id;
},
);
const toggleCardSelection = (row: any, selected: boolean) => {
selectRow(row, selected);
};
@@ -446,7 +459,26 @@ watch([currentViewMode, tableData, () => props.syncCardContentHeight], scheduleC
flush: 'post',
});
watch(tableData, pruneSelection);
watch([() => props.paginationConfig?.currentPage, () => props.paginationConfig?.pageSize], clearSelects, {
flush: 'sync',
});
watch(
[
currentNode,
() =>
JSON.stringify(
typeof props.selectionContext === 'function' ? props.selectionContext() : props.selectionContext,
),
],
() => {
clearSelects();
if (props.paginationConfig) {
props.paginationConfig.currentPage = 1;
}
},
{ flush: 'sync' },
);
onBeforeUnmount(() => {
window.removeEventListener('resize', calcHeight);
@@ -1,4 +1,4 @@
import { shallowRef, ref, toRaw, type Ref } from 'vue';
import { shallowRef, ref, toRaw, watch, type Ref } from 'vue';
type TableRef = { refElTable?: any } | undefined;
@@ -7,6 +7,7 @@ export const useTableSelection = (
getTableData: () => any[],
onSelectionChange: (rows: any[]) => void,
isRowSelectable: (row: any) => boolean = () => true,
getRowKey: (row: any) => unknown = (row) => row?.id,
) => {
const selectedRows = shallowRef<any[]>([]);
const shiftPressed = ref(false);
@@ -15,8 +16,13 @@ export const useTableSelection = (
let isSyncingTableSelection = false;
let skipNextSelectionChange = false;
// Element Plus can return reactive proxies for rows supplied as plain objects.
const sameRow = (left: any, right: any) => toRaw(left) === toRaw(right);
let isRefreshingData = false;
const sameRow = (left: any, right: any) => {
if (left == null || right == null) return left === right;
const leftKey = getRowKey(left);
const rightKey = getRowKey(right);
return leftKey != null && rightKey != null ? leftKey === rightKey : toRaw(left) === toRaw(right);
};
const hasRow = (rows: any[], row: any) => rows.some((item) => sameRow(item, row));
const isRowSelected = (row: any) => hasRow(selectedRows.value, row);
const getTable = () => tableRef.value?.refElTable;
@@ -39,8 +45,12 @@ export const useTableSelection = (
const tableData = getTableData();
const nextRows = selectedRows.value.filter((row) => hasRow(tableData, row) && isRowSelectable(row));
const currentRows = table.getSelectionRows();
currentRows.filter((row) => !hasRow(nextRows, row)).forEach((row) => table.toggleRowSelection(row, false));
nextRows.filter((row) => !hasRow(currentRows, row)).forEach((row) => table.toggleRowSelection(row, true));
currentRows
.filter((row) => !nextRows.some((item) => toRaw(item) === toRaw(row)))
.forEach((row) => table.toggleRowSelection(row, false));
nextRows
.filter((row) => !currentRows.some((item) => toRaw(item) === toRaw(row)))
.forEach((row) => table.toggleRowSelection(row, true));
} finally {
isSyncingTableSelection = false;
}
@@ -73,7 +83,7 @@ export const useTableSelection = (
return true;
};
const handleSelectionChange = (rows: any[]) => {
if (isSyncingTableSelection) {
if (isSyncingTableSelection || isRefreshingData) {
return;
}
if (skipNextSelectionChange) {
@@ -104,16 +114,35 @@ export const useTableSelection = (
rangeBaseRows.value = [];
};
const pruneSelection = () => {
const nextRows = selectedRows.value.filter((row) => hasRow(getTableData(), row));
if (nextRows.length !== selectedRows.value.length) {
const data = getTableData();
const nextRows = data.filter((row) => hasRow(selectedRows.value, row) && isRowSelectable(row));
if (nextRows.length !== selectedRows.value.length || nextRows.some((row, i) => row !== selectedRows.value[i])) {
setSelectedRows(nextRows);
}
if (lastSelectedRow.value && !hasRow(nextRows, lastSelectedRow.value)) {
lastSelectedRow.value = null;
rangeBaseRows.value = [];
}
syncTableSelection();
lastSelectedRow.value = nextRows.find((row) => sameRow(row, lastSelectedRow.value)) ?? null;
rangeBaseRows.value = lastSelectedRow.value ? nextRows.filter((row) => hasRow(rangeBaseRows.value, row)) : [];
};
// Guard before the child table reacts to replacement data and emits an empty selection.
watch(
getTableData,
() => {
isRefreshingData = true;
skipNextSelectionChange = false;
pruneSelection();
},
{ flush: 'sync' },
);
watch(
getTableData,
() => {
try {
syncTableSelection();
} finally {
isRefreshingData = false;
}
},
{ flush: 'post' },
);
const toggleSelection = () => {
const selectableRows = getTableData().filter(isRowSelectable);
const allSelected = selectableRows.length > 0 && selectableRows.every(isRowSelected);
@@ -155,7 +184,6 @@ export const useTableSelection = (
selectedRows,
isRowSelected,
clearSelects,
pruneSelection,
toggleSelection,
selectRow,
syncTableSelection,
@@ -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<string, Ref<SearchValue>>;
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<string, unknown> = {};
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' },
);
};
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -2861,6 +2861,9 @@ const message = {
privateKey: 'کلید خصوصی',
publicKey: 'کلید عمومی',
password: 'رمز عبور',
existingPassPhrase: 'عبارت عبور فعلی کلید خصوصی',
existingPassPhraseHelper:
'اگر کلید خصوصی رمزگذاری شده است، عبارت عبور اصلی را وارد کنید؛ در غیر این صورت خالی بگذارید. این کار عبارت عبور را تغییر نمی‌دهد.',
createMode: 'روش ایجاد',
generate: 'تولید خودکار',
unSyncPass: 'رمز عبور کلید قابل همگام‌سازی نیست',
+3
View File
@@ -2865,6 +2865,9 @@ const message = {
privateKey: '秘密鍵',
publicKey: '公開鍵',
password: 'パスワード',
existingPassPhrase: '既存の秘密鍵のパスフレーズ',
existingPassPhraseHelper:
'秘密鍵が暗号化されている場合は元のパスフレーズを入力し、それ以外は空欄にしてください。パスフレーズは変更されません。',
createMode: '作成方法',
generate: '自動生成',
unSyncPass: '鍵パスワードは同期できません',
+3
View File
@@ -2827,6 +2827,9 @@ const message = {
privateKey: '개인 키',
publicKey: '공개 키',
password: '비밀번호',
existingPassPhrase: '기존 개인 키 암호',
existingPassPhraseHelper:
'개인 키가 암호화되어 있으면 원래 암호를 입력하고, 그렇지 않으면 비워 두세요. 이 작업은 암호를 변경하지 않습니다.',
createMode: '생성 방식',
generate: '자동 생성',
unSyncPass: '키 비밀번호 동기화 불가',
+3
View File
@@ -2807,6 +2807,9 @@ const message = {
privateKey: 'ຄີສ່ວນຕົວ',
publicKey: 'ຄີສາທາລະນະ',
password: 'ລະຫັດຜ່ານ',
existingPassPhrase: 'ລະຫັດຜ່ານເດີມຂອງຄີສ່ວນຕົວ',
existingPassPhraseHelper:
'ຖ້າຄີສ່ວນຕົວຖືກເຂົ້າລະຫັດ ໃຫ້ປ້ອນລະຫັດຜ່ານເດີມ; ຖ້າບໍ່ ໃຫ້ປະໄວ້ຫວ່າງ. ການດຳເນີນການນີ້ບໍ່ປ່ຽນລະຫັດຜ່ານ.',
createMode: 'ວິທີການສ້າງ',
generate: 'ສ້າງອັດຕະໂນມັດ',
unSyncPass: 'ບໍ່ສາມາດຊິງຄ໌ລະຫັດຜ່ານຄີໄດ້',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -2903,6 +2903,9 @@ const message = {
privateKey: 'Приватный ключ',
publicKey: 'Публичный ключ',
password: 'Пароль',
existingPassPhrase: 'Текущая парольная фраза закрытого ключа',
existingPassPhraseHelper:
'Если закрытый ключ зашифрован, введите исходную парольную фразу; иначе оставьте поле пустым. Это не изменяет парольную фразу.',
createMode: 'Способ создания',
generate: 'Автогенерация',
unSyncPass: 'Пароль ключа не может быть синхронизирован',
+3
View File
@@ -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',
+2
View File
@@ -2720,6 +2720,8 @@ const message = {
privateKey: '私鑰',
publicKey: '公鑰',
password: '密碼',
existingPassPhrase: '現有私鑰密碼',
existingPassPhraseHelper: '私鑰已加密時填寫原密碼,未加密則留空;此處不會修改私鑰密碼。',
createMode: '建立方式',
generate: '自動生成',
unSyncPass: '金鑰密碼無法同步',
+2
View File
@@ -2751,6 +2751,8 @@ const message = {
privateKey: '私钥',
publicKey: '公钥',
password: '密码',
existingPassPhrase: '已有私钥密码',
existingPassPhraseHelper: '私钥已加密时填写原密码,未加密则留空;此处不会修改私钥密码。',
createMode: '创建方式',
generate: '自动生成',
unSyncPass: '密钥密码无法同步',
+13 -1
View File
@@ -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;
@@ -172,6 +172,7 @@
</template>
<script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { computed, ref, watch } from 'vue';
import { Refresh, Search } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
@@ -192,6 +193,11 @@ const searching = ref(false);
const operating = ref('');
const keyword = ref('');
const marketKeyword = ref('');
useSearchPersistence(
'ai/agents/agent/config/tabs/plugins',
{ keyword, marketKeyword },
() => agentId.value || undefined,
);
const origin = ref('');
const status = ref('');
const page = ref(1);
@@ -299,8 +305,6 @@ async function operate(plugin: AI.AgentPluginItem, action: PluginOperate) {
async function load(id: number) {
agentId.value = id;
mode.value = 'installed';
keyword.value = '';
marketKeyword.value = '';
origin.value = '';
status.value = '';
page.value = 1;
@@ -138,6 +138,7 @@
</template>
<script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { computed, ref, watch } from 'vue';
import { Refresh, Search } from '@element-plus/icons-vue';
import { useI18n } from 'vue-i18n';
@@ -168,6 +169,11 @@ const getDefaultMarketSource = (): HermesSkillMarketSource => {
const marketSource = ref<HermesSkillMarketSource>(getDefaultMarketSource());
const marketSearched = ref(false);
const agentId = ref(0);
useSearchPersistence(
'ai/agents/agent/config/tabs/skills/hermes',
{ installedKeyword, marketKeyword },
() => agentId.value || undefined,
);
const installedSkills = ref<AI.AgentSkillItem[]>([]);
const marketResults = ref<AI.AgentSkillSearchItem[]>([]);
const installingSkill = ref('');
@@ -315,8 +321,6 @@ const handleModeChange = async (value: SkillViewMode) => {
const load = async (id: number) => {
agentId.value = id;
mode.value = 'market';
installedKeyword.value = '';
marketKeyword.value = '';
marketSource.value = getDefaultMarketSource();
marketSearched.value = false;
installedSkills.value = [];
@@ -142,6 +142,7 @@
</template>
<script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { computed, ref, watch } from 'vue';
import { Refresh, Search } from '@element-plus/icons-vue';
import { useI18n } from 'vue-i18n';
@@ -180,6 +181,11 @@ const getDefaultMarketSource = (): SkillMarketSource => {
const marketSource = ref<SkillMarketSource>(getDefaultMarketSource());
const marketSearched = ref(false);
const agentId = ref(0);
useSearchPersistence(
'ai/agents/agent/config/tabs/skills/openclaw',
{ installedKeyword, marketKeyword },
() => agentId.value || undefined,
);
const skills = ref<AI.AgentSkillItem[]>([]);
const marketResults = ref<AI.AgentSkillSearchItem[]>([]);
const updatingSkill = ref('');
@@ -272,6 +272,7 @@
</template>
<script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { defineAsyncComponent, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { deleteAgentCheck, pageAgents, resetAgentToken, unbindAgentWebsite, updateAgentRemark } from '@/api/modules/ai';
@@ -340,6 +341,7 @@ const isActive = ref(false);
const isExist = ref(false);
const noApp = ref(false);
const searchName = ref('');
useSearchPersistence('ai/agents/agent/index', { search: searchName });
const defaultHttpsPort = ref(443);
const openrestyPortLoaded = ref(false);
const websiteDomainsMap = ref<Record<number, Website.Domain[]>>({});
@@ -69,6 +69,7 @@
</template>
<script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { onMounted, reactive, ref } from 'vue';
import { deleteAgentAccount, getAgentProviders, pageAgentAccounts } from '@/api/modules/ai';
import { AI } from '@/api/interface/ai';
@@ -85,6 +86,7 @@ const items = ref<AI.AgentAccountItem[]>([]);
const addRef = ref();
const modelPoolRef = ref();
const searchName = ref('');
useSearchPersistence('ai/agents/model/index', { search: searchName });
const apiType = ref('');
const apiTypeOptions = ref<string[]>([]);
@@ -65,6 +65,8 @@
</template>
<template #main>
<ComplexTable
:selection-context="() => [searchName]"
row-key="id"
:pagination-config="paginationConfig"
v-model:selects="selects"
:class="{ mask: maskShow }"
@@ -184,6 +186,7 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import AppStatus from '@/components/app-status/index.vue';
import AddDialog from '@/views/ai/model/ollama/add/index.vue';
import Conn from '@/views/ai/model/ollama/conn/index.vue';
@@ -234,6 +237,7 @@ const paginationConfig = reactive({
total: 0,
});
const searchName = ref();
useSearchPersistence('ai/model/ollama/index', { search: searchName });
const appInstallID = ref(0);
const opRef = ref();
+3 -1
View File
@@ -70,8 +70,9 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { App } from '@/api/interface/app';
import { onMounted, reactive, ref } from 'vue';
import { toRef, onMounted, reactive, ref } from 'vue';
import { searchApp, syncApp, syncCutomAppStore, syncLocalApp, getCurrentNodeCustomAppConfig } from '@/api/modules/app';
import Install from '../detail/install/index.vue';
import router from '@/routers';
@@ -104,6 +105,7 @@ const req = reactive({
resource: 'all',
showCurrentArch: false,
});
useSearchPersistence('app-store/apps/index', { search: toRef(req, 'name') });
const apps = ref<App.AppItem[]>([]);
const loading = ref(false);
@@ -139,6 +139,7 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import AppCard from '@/views/app-store/installed/app/card.vue';
import Backups from '@/components/backup/index.vue';
import Uploads from '@/components/upload/index.vue';
@@ -156,7 +157,7 @@ import IgnoreApp from '@/views/app-store/installed/ignore/create/index.vue';
import TerminalDialog from '@/views/container/container/terminal/index.vue';
import { searchAppInstalled, installedOp, appInstalledDeleteCheck, updateAppInstallSort } from '@/api/modules/app';
import { onMounted, onUnmounted, reactive, ref, nextTick } from 'vue';
import { toRef, onMounted, onUnmounted, reactive, ref, nextTick } from 'vue';
import Sortable from 'sortablejs';
import i18n from '@/lang';
import { ElMessageBox } from 'element-plus';
@@ -210,6 +211,7 @@ const searchReq = reactive({
update: false,
sync: false,
});
useSearchPersistence('app-store/installed/index', { search: toRef(searchReq, 'name') });
const router = useRouter();
const activeName = ref(i18n.global.t('app.installed'));
const mode = ref('installed');
@@ -521,6 +521,7 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { computed, h, ref } from 'vue';
import CodemirrorPro from '@/components/codemirror-pro/index.vue';
import ContainerLog from '@/components/log/container/index.vue';
@@ -575,6 +576,7 @@ const dialogBackupRef = ref();
const uploadRef = ref();
const searchName = ref('');
useSearchPersistence('container/compose/index', { search: searchName });
const includeAppStore = ref(localStorage.getItem('includeAppStore') !== 'false');
const showType = ref('compose');
const containerStats = ref<any[]>([]);
@@ -56,6 +56,7 @@
</template>
<template #main>
<ComplexTable
:selection-context="() => [searchName, activeTag, includeAppStore, props.filters]"
:pagination-config="paginationConfig"
v-model:view-mode="viewMode"
v-model:selects="selects"
@@ -473,6 +474,7 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import PruneDialog from '@/views/container/container/prune/index.vue';
import RenameDialog from '@/views/container/container/rename/index.vue';
import UpgradeDialog from '@/views/container/container/upgrade/index.vue';
@@ -488,7 +490,7 @@ import Uploads from '@/components/upload/index.vue';
import DockerStatus from '@/views/container/docker-status/index.vue';
import ContainerLogDialog from '@/components/log/container-drawer/index.vue';
import Status from '@/components/status/index.vue';
import { computed, reactive, ref } from 'vue';
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
import {
containerItemStats,
containerListStats,
@@ -534,6 +536,7 @@ const paginationConfig = reactive({
order: 'ascending',
});
const searchName = ref();
useSearchPersistence('container/container/index', { search: searchName });
const dialogUpgradeRef = ref();
const dialogCommitRef = ref();
const dialogPortJumpRef = ref();
@@ -700,47 +703,87 @@ const dialogContainerLogRef = ref();
const dialogRenameRef = ref();
const dialogPruneRef = ref();
const search = async (column?: any) => {
if (!isActive.value || !isExist.value) {
let requestController: AbortController | undefined;
let requestVersion = 0;
let refreshPending = false;
onBeforeUnmount(() => {
requestVersion++;
requestController?.abort();
});
const loadContainers = async (background = false, column?: any) => {
if (!isActive.value || !isExist.value || (background && refreshPending)) {
return;
}
requestController?.abort();
const controller = new AbortController();
requestController = controller;
const version = ++requestVersion;
const isCurrent = () => version === requestVersion && !controller.signal.aborted;
refreshPending = true;
loading.value = !background;
localStorage.setItem('includeAppStore', includeAppStore.value ? 'true' : 'false');
let filterItem = (router.currentRoute.value.query?.filters as string) || '';
paginationConfig.orderBy = column?.order ? column.prop : paginationConfig.orderBy;
paginationConfig.order = column?.order ? column.order : paginationConfig.order;
let params = {
const params = {
name: searchName.value,
state: paginationConfig.state || 'all',
page: paginationConfig.currentPage,
pageSize: paginationConfig.pageSize,
filters: filterItem,
filters: props.filters || (router.currentRoute.value.query?.filters as string) || '',
orderBy: paginationConfig.orderBy,
order: paginationConfig.order,
excludeAppStore: !includeAppStore.value,
};
loading.value = true;
const [containerResult, statsResult, statusResult] = await Promise.allSettled([
searchContainer(params),
containerListStats(),
loadContainerStatus(),
]);
loading.value = false;
if (containerResult.status === 'fulfilled') {
const containers = containerResult.value.data.items || [];
// Counts and resource samples must never hold up the table itself.
const statusRequest = loadContainerStatus(undefined, true, controller.signal)
.then((res) => {
if (isCurrent()) {
updateTags(res.data || {});
}
})
.catch(() => {});
try {
const res = await searchContainer(params, undefined, controller.signal);
if (!isCurrent()) {
return;
}
const containers = res.data.items || [];
syncContainerRows(containers);
paginationConfig.total = containerResult.value.data.total;
}
if (statsResult.status === 'fulfilled') {
applyStatsToRows(statsResult.value.data || []);
}
if (statusResult.status === 'fulfilled') {
updateTags(statusResult.value.data || {});
paginationConfig.total = res.data.total;
loading.value = false;
// Clear old usage when a previously running container has stopped.
applyStatsToRows(
containers
.filter((item) => item.state !== 'running')
.map((item) => ({
containerID: item.containerID,
...Object.fromEntries(statFields.map((field) => [field, 0])),
})),
);
const ids = containers.filter((item) => item.state === 'running').map((item) => item.containerID);
if (ids.length > 0) {
const stats = await containerListStats(undefined, ids, controller.signal);
if (isCurrent()) {
applyStatsToRows(stats.data || []);
}
}
} catch {
// The HTTP layer reports failures; keep the last successful rows visible.
} finally {
if (isCurrent()) {
loading.value = false;
}
await statusRequest;
if (isCurrent()) {
refreshPending = false;
}
}
};
const search = (column?: any) => loadContainers(false, column);
const searchWithStatus = (item: string) => {
activeTag.value = item;
paginationConfig.state = activeTag.value;
@@ -769,21 +812,7 @@ const changePinned = (row: any, isPinned: boolean) => {
});
};
const refresh = async () => {
let filterItem = props.filters ? props.filters : '';
let params = {
name: searchName.value,
state: paginationConfig.state || 'all',
page: paginationConfig.currentPage,
pageSize: paginationConfig.pageSize,
filters: filterItem,
orderBy: paginationConfig.orderBy,
order: paginationConfig.order,
};
const [containerResult, statsResult] = await Promise.all([searchContainer(params), containerListStats()]);
syncContainerRows(containerResult.data.items || []);
applyStatsToRows(statsResult.data || []);
};
const refresh = () => loadContainers(true);
const loadSize = async (row: any) => {
containerItemStats(row.containerID).then((res) => {
+3 -1
View File
@@ -157,7 +157,8 @@
</template>
<script lang="ts" setup>
import { reactive, ref, computed } from 'vue';
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { toRef, reactive, ref, computed } from 'vue';
import { dateFormat } from '@/utils/date';
import { newUUID } from '@/utils/id';
import { computeSize2 } from '@/utils/size';
@@ -199,6 +200,7 @@ const paginationConfig = reactive({
orderBy: 'createdAt',
order: 'null',
});
useSearchPersistence('container/image/index', { search: toRef(paginationConfig, 'name') });
const columns = ref([]);
const isActive = ref(false);
+8 -10
View File
@@ -26,6 +26,8 @@
</template>
<template #main>
<ComplexTable
:selection-context="() => [searchName]"
row-key="id"
:pagination-config="paginationConfig"
v-model:selects="selects"
:data="data"
@@ -93,12 +95,12 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import CreateDialog from '@/views/container/network/create/index.vue';
import DetailDrawer from '@/views/container/network/detail/index.vue';
import { reactive, ref } from 'vue';
import { dateFormat } from '@/utils/date';
import { newUUID } from '@/utils/id';
import { deleteNetwork, searchNetwork, inspect, containerPrune } from '@/api/modules/container';
import { deleteNetwork, searchNetwork, inspect, cleanNetworks } from '@/api/modules/container';
import { Container } from '@/api/interface/container';
import TaskLog from '@/components/log/task/index.vue';
import i18n from '@/lang';
@@ -118,6 +120,7 @@ const paginationConfig = reactive({
total: 0,
});
const searchName = ref();
useSearchPersistence('container/network/index', { search: searchName });
const opRef = ref();
const isActive = ref(false);
@@ -138,15 +141,10 @@ const onClean = () => {
type: 'info',
}).then(async () => {
loading.value = true;
let params = {
taskID: newUUID(),
pruneType: 'network',
withTagAll: false,
};
await containerPrune(params)
.then(() => {
await cleanNetworks()
.then((res) => {
loading.value = false;
openTaskLog(params.taskID);
openTaskLog(res.data.taskID);
})
.catch(() => {
loading.value = false;
@@ -59,6 +59,7 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import OperatorDialog from '@/views/container/repo/operator/index.vue';
import { reactive, ref } from 'vue';
import { dateFormat } from '@/utils/date';
@@ -77,6 +78,7 @@ const paginationConfig = reactive({
total: 0,
});
const searchName = ref();
useSearchPersistence('container/repo/index', { search: searchName });
const opRef = ref();
const confirmDialog = ref();
@@ -38,6 +38,8 @@
</template>
<template #main>
<ComplexTable
:selection-context="() => [searchName]"
row-key="id"
:pagination-config="paginationConfig"
v-model:selects="selects"
:data="data"
@@ -79,6 +81,7 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { reactive, ref } from 'vue';
import { dateFormat, getCurrentDateFormatted } from '@/utils/date';
import { downloadWithContent } from '@/utils/file';
@@ -108,6 +111,7 @@ const paginationConfig = reactive({
total: 0,
});
const searchName = ref();
useSearchPersistence('container/template/index', { search: searchName });
const search = async () => {
if (!isActive.value || !isExist.value) {
@@ -26,6 +26,8 @@
</template>
<template #main>
<ComplexTable
:selection-context="() => [searchName]"
row-key="name"
:pagination-config="paginationConfig"
v-model:selects="selects"
:data="data"
@@ -122,6 +124,7 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import CreateDialog from '@/views/container/volume/create/index.vue';
import CodemirrorDrawer from '@/components/codemirror-pro/drawer.vue';
import DockerStatus from '@/views/container/docker-status/index.vue';
@@ -155,6 +158,7 @@ const paginationConfig = reactive({
total: 0,
});
const searchName = ref();
useSearchPersistence('container/volume/index', { search: searchName });
const isActive = ref(false);
const isExist = ref(false);
@@ -60,6 +60,8 @@
</template>
<template #main>
<ComplexTable
:selection-context="() => [searchName, searchGroupID]"
row-key="id"
:pagination-config="paginationConfig"
:default-sort="
paginationConfig.order !== 'null'
@@ -270,6 +272,7 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import Records from '@/views/cronjob/cronjob/record/index.vue';
import Backups from '@/views/cronjob/cronjob/backup/index.vue';
import Import from '@/views/cronjob/cronjob/import/index.vue';
@@ -329,6 +332,7 @@ const pageState = usePageState(() => ({
}));
const paginationConfig = pageState.paginationConfig;
const { defaultGroupID, searchName, searchGroupID } = toRefs(pageState);
useSearchPersistence('cronjob/cronjob/index', { search: searchName });
const search = async (column?: any) => {
if (column) {
@@ -60,6 +60,8 @@
</template>
<template #main>
<ComplexTable
:selection-context="() => [searchInfo, group]"
row-key="id"
v-model:selects="selects"
:pagination-config="paginationConfig"
:data="data"
@@ -130,6 +132,7 @@
</template>
<script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { dateFormat, getCurrentDateFormatted } from '@/utils/date';
import { deepCopy } from '@/utils/misc';
import { newUUID } from '@/utils/id';
@@ -167,6 +170,7 @@ const paginationConfig = reactive({
total: 0,
});
const searchInfo = ref<string>('');
useSearchPersistence('cronjob/library/index', { search: searchInfo });
const group = ref<string>('');
const groupOptions = ref();
@@ -323,6 +323,7 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { nextTick, onMounted, reactive, ref } from 'vue';
import { dateFormat } from '@/utils/date';
import { getRandomStr } from '@/utils/id';
@@ -365,6 +366,7 @@ const loading = ref(false);
const maskShow = ref(true);
const submitLoading = ref(false);
const searchName = ref('');
useSearchPersistence('database/mongodb/index', { search: searchName });
const createVisible = ref(false);
const appStatusRef = ref();
const bindRef = ref();
@@ -75,6 +75,7 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { dateFormat } from '@/utils/date';
import { onMounted, reactive, ref } from 'vue';
import { deleteCheckDatabase, searchDatabases } from '@/api/modules/database';
@@ -100,6 +101,7 @@ const paginationConfig = reactive({
order: 'null',
});
const searchName = ref();
useSearchPersistence('database/mongodb/remote/index', { search: searchName });
const search = async (column?: any) => {
paginationConfig.orderBy = column?.order ? column.prop : paginationConfig.orderBy;
@@ -226,6 +226,7 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import BindDialog from '@/views/database/mysql/bind/index.vue';
import UserDialog from '@/views/database/mysql/user/index.vue';
import OperateDialog from '@/views/database/mysql/create/index.vue';
@@ -300,6 +301,7 @@ const paginationConfig = reactive({
order: 'null',
});
const searchName = ref();
useSearchPersistence('database/mysql/index', { search: searchName });
const mysqlContainer = ref();
const mysqlStatus = ref();
@@ -74,6 +74,7 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { dateFormat } from '@/utils/date';
import { onMounted, reactive, ref } from 'vue';
import { deleteCheckDatabase, searchDatabases } from '@/api/modules/database';
@@ -99,6 +100,7 @@ const paginationConfig = reactive({
order: 'null',
});
const searchName = ref();
useSearchPersistence('database/mysql/remote/index', { search: searchName });
const search = async (column?: any) => {
paginationConfig.orderBy = column?.order ? column.prop : paginationConfig.orderBy;
@@ -241,6 +241,7 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import BindDialog from '@/views/database/postgresql/bind/index.vue';
import OperateDialog from '@/views/database/postgresql/create/index.vue';
import DeleteDialog from '@/views/database/postgresql/delete/index.vue';
@@ -312,6 +313,7 @@ const paginationConfig = reactive({
order: 'null',
});
const searchName = ref();
useSearchPersistence('database/postgresql/index', { search: searchName });
const postgresqlContainer = ref();
const postgresqlStatus = ref();
@@ -74,6 +74,7 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { dateFormat } from '@/utils/date';
import { onMounted, reactive, ref } from 'vue';
import { deleteCheckDatabase, searchDatabases } from '@/api/modules/database';
@@ -99,6 +100,7 @@ const paginationConfig = reactive({
order: 'null',
});
const searchName = ref();
useSearchPersistence('database/postgresql/remote/index', { search: searchName });
const search = async (column?: any) => {
paginationConfig.orderBy = column?.order ? column.prop : paginationConfig.orderBy;
@@ -73,6 +73,7 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { dateFormat } from '@/utils/date';
import { onMounted, reactive, ref } from 'vue';
import { deleteCheckDatabase, searchDatabases } from '@/api/modules/database';
@@ -98,6 +99,7 @@ const paginationConfig = reactive({
order: 'null',
});
const searchName = ref();
useSearchPersistence('database/redis/remote/index', { search: searchName });
const search = async (column?: any) => {
paginationConfig.orderBy = column?.order ? column.prop : paginationConfig.orderBy;
@@ -739,7 +739,8 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { toRef, computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import {
addFavorite,
batchGetFileRemarks,
@@ -852,6 +853,7 @@ const initData = () => ({
sortOrder: 'ascending',
});
let req = reactive(initData());
useSearchPersistence('host/file-management/index', { search: toRef(req, 'search') });
let loading = ref(false);
const paths = ref<FilePaths[]>([]);
const breadcrumbVisiblePaths = ref<FilePaths[]>([]);
@@ -232,6 +232,7 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { computed, reactive, ref, watch } from 'vue';
import FireRouter from '@/views/host/firewall/index.vue';
import DockerGuardStatus from '@/views/host/firewall/docker/status/index.vue';
@@ -274,6 +275,7 @@ const taskLogRef = ref<InstanceType<typeof TaskLog>>();
const openRuleTask = (taskID: string) => taskLogRef.value?.openWithTaskID(taskID, true);
const orphanDrawerVisible = ref(false);
const searchName = ref('');
useSearchPersistence('host/firewall/docker/index', { search: searchName });
const selects = ref<Firewall.DockerGuardContainer[]>([]);
const orphanSelects = ref<Firewall.DockerGuardEndpoint[]>([]);
const data = reactive<Firewall.DockerGuardList>({
@@ -57,6 +57,18 @@
</template>
<template #main>
<ComplexTable
:selection-context="() => [searchName, fireName]"
:row-key="
(row) =>
JSON.stringify([
row.family,
row.protocol,
row.port,
row.targetIP,
row.targetPort,
row.interface,
])
"
:pagination-config="paginationConfig"
v-model:selects="selects"
@search="search"
@@ -133,6 +145,7 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import OperateDialog from './operate/index.vue';
import ImportDialog from './import/index.vue';
import RuleSync from '@/views/host/firewall/sync/index.vue';
@@ -153,6 +166,7 @@ import { ElMessageBox } from 'element-plus';
const loading = ref();
const selects = ref<any>([]);
const searchName = ref();
useSearchPersistence('host/firewall/forward/index', { search: searchName });
const isInit = ref(false);
const isBind = ref(false);
@@ -185,6 +185,7 @@
<template #main>
<div v-loading="loading">
<ComplexTable
:selection-context="() => [searchName, selectedRuleFilters, visibleIptablesChains]"
v-model:selects="selects"
:pagination-config="paginationConfig"
:data="allRows"
@@ -397,6 +398,7 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { Firewall } from '@/api/interface/firewall';
import { Process } from '@/api/interface/process';
import {
@@ -516,6 +518,7 @@ const visibleIptablesChains = ref<string[]>(
loadCachedFilterValues(chainFilterStorageKey, iptablesChains, ['1PANEL_BASIC']),
);
const searchName = ref('');
useSearchPersistence('host/firewall/rule/index', { search: searchName });
const inventoryItems = ref<Firewall.InventoryItem[]>([]);
const positionRanges = ref<Partial<Record<Firewall.Family, Firewall.PositionRange>>>({});
const inventoryTotal = ref(0);
@@ -659,7 +662,6 @@ const loadRules = async (refreshUsage: boolean) => {
paginationConfig.total = total;
inventoryTotal.value = response.data.allTotal || 0;
managedTotal.value = response.data.managedTotal || 0;
selects.value = [];
} finally {
if (requestID === searchRequestID) loading.value = false;
}
+2
View File
@@ -93,6 +93,7 @@
</template>
<script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import ConfirmDialog from '@/components/confirm-dialog/index.vue';
import { dateFormatServerTimezone } from '@/utils/date';
import { downloadFile } from '@/utils/file';
@@ -119,6 +120,7 @@ const exportConfig = reactive({
status: 'All',
});
const searchInfo = ref();
useSearchPersistence('host/ssh/log/log', { search: searchInfo });
const searchStatus = ref('All');
const search = async () => {
@@ -24,8 +24,9 @@
</template>
<script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import FireRouter from '@/views/host/ssh/index.vue';
import { ref, onMounted, onUnmounted, reactive } from 'vue';
import { toRef, ref, onMounted, onUnmounted, reactive } from 'vue';
import i18n from '@/lang';
import { stopProcess } from '@/api/modules/process';
import { MsgError, MsgSuccess } from '@/utils/message';
@@ -37,6 +38,7 @@ const sshSearch = reactive({
type: 'ssh',
loginUser: '',
});
useSearchPersistence('host/ssh/session/index', { search: toRef(sshSearch, 'loginUser') });
const buttons = [
{
@@ -16,8 +16,15 @@
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="$t('ssh.createMode')" prop="mode" v-if="dialogData.title === 'create'">
<el-radio-group v-model="dialogData.rowData.mode" @change="onModeChange">
<el-radio value="generate">{{ $t('ssh.generate') }}</el-radio>
<el-radio value="input">{{ $t('ssh.input') }}</el-radio>
<el-radio value="import">{{ $t('ssh.import') }}</el-radio>
</el-radio-group>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-col :span="12" v-if="isGenerate">
<el-form-item :label="$t('ssh.encryptionMode')" prop="encryptionMode">
<el-select v-model="dialogData.rowData.encryptionMode">
<el-option label="ED25519" value="ed25519" />
@@ -28,50 +35,38 @@
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('commons.login.password')" prop="passPhrase">
<el-form-item :label="$t(isGenerate ? 'ssh.password' : 'ssh.existingPassPhrase')" prop="passPhrase">
<el-input v-model="dialogData.rowData.passPhrase" type="password" show-password>
<template #append>
<template #append v-if="isGenerate">
<el-button @click="random">
{{ $t('commons.button.random') }}
</el-button>
</template>
</el-input>
<span class="input-help" v-if="!isGenerate">{{ $t('ssh.existingPassPhraseHelper') }}</span>
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="$t('ssh.createMode')" prop="mode" v-if="dialogData.title === 'create'">
<el-radio-group v-model="dialogData.rowData.mode">
<el-radio value="generate">{{ $t('ssh.generate') }}</el-radio>
<el-radio value="input">{{ $t('ssh.input') }}</el-radio>
<el-radio value="import">{{ $t('ssh.import') }}</el-radio>
</el-radio-group>
</el-form-item>
<div v-if="dialogData.rowData.mode === 'input'">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t('ssh.privateKey')" prop="privateKey">
<el-input type="textarea" :rows="2" v-model="dialogData.rowData.privateKey" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('ssh.publicKey')" prop="publicKey">
<el-input type="textarea" :rows="2" v-model="dialogData.rowData.publicKey" />
</el-form-item>
</el-col>
</el-row>
</div>
<div v-if="dialogData.rowData.mode === 'import'">
<div v-if="!isGenerate">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t('ssh.privateKey')" prop="privateKey">
<el-input
v-if="dialogData.rowData.mode === 'input'"
type="textarea"
:rows="4"
v-model="dialogData.rowData.privateKey"
/>
<el-upload
v-else
action="#"
:auto-upload="false"
ref="uploadPrivateRef"
class="upload mt-2 w-full"
:limit="1"
:on-change="privateOnChange"
:on-change="(file) => onKeyChange('privateKey', file)"
:on-exceed="privateExceed"
:on-remove="() => onKeyRemove('privateKey')"
>
<el-button size="small" icon="Upload">
{{ $t('commons.button.upload') }}
@@ -81,14 +76,22 @@
</el-col>
<el-col :span="12">
<el-form-item :label="$t('ssh.publicKey')" prop="publicKey">
<el-input
v-if="dialogData.rowData.mode === 'input'"
type="textarea"
:rows="4"
v-model="dialogData.rowData.publicKey"
/>
<el-upload
v-else
action="#"
:auto-upload="false"
ref="uploadPublicRef"
class="upload mt-2 w-full"
:limit="1"
:on-change="publicOnChange"
:on-change="(file) => onKeyChange('publicKey', file)"
:on-exceed="publicExceed"
:on-remove="() => onKeyRemove('publicKey')"
>
<el-button size="small" icon="Upload">
{{ $t('commons.button.upload') }}
@@ -104,7 +107,7 @@
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="drawerVisible = false">{{ $t('commons.button.cancel') }}</el-button>
<el-button @click="handleClose">{{ $t('commons.button.cancel') }}</el-button>
<el-button type="primary" @click="onConfirm(formRef)">
{{ $t('commons.button.confirm') }}
</el-button>
@@ -114,7 +117,7 @@
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
import i18n from '@/lang';
import { ElForm, genFileId, UploadFile, UploadProps, UploadRawFile } from 'element-plus';
import { Host } from '@/api/interface/host';
@@ -139,37 +142,91 @@ type FormInstance = InstanceType<typeof ElForm>;
const formRef = ref();
const uploadPrivateRef = ref();
const uploadPublicRef = ref();
const isGenerate = computed(() => dialogData.value.title === 'create' && dialogData.value.rowData?.mode === 'generate');
type KeyField = 'privateKey' | 'publicKey';
const keyReaders: Partial<Record<KeyField, FileReader>> = {};
const cancelKeyRead = (field?: KeyField) => {
const fields: KeyField[] = field ? [field] : ['privateKey', 'publicKey'];
for (const key of fields) {
const reader = keyReaders[key];
delete keyReaders[key];
reader?.abort();
}
};
onBeforeUnmount(() => cancelKeyRead());
const acceptParams = (params: DialogProps): void => {
cancelKeyRead();
dialogData.value = params;
params.rowData.passPhrase ||= '';
params.rowData.privateKey ||= '';
params.rowData.publicKey ||= '';
if (params.title === 'edit') {
params.rowData.mode = 'input';
dialogData.value.rowData.publicKey = Base64.decode(params.rowData.publicKey);
dialogData.value.rowData.privateKey = Base64.decode(params.rowData.privateKey);
if (params.rowData.passPhrase) {
dialogData.value.rowData.passPhrase = Base64.decode(params.rowData.passPhrase);
if (dialogData.value.rowData.passPhrase === '<UN-SET>') {
dialogData.value.rowData.passPhrase = '';
}
}
}
if (!isGenerate.value) {
dialogData.value.rowData.encryptionMode = '';
}
title.value = i18n.global.t('commons.button.' + dialogData.value.title);
drawerVisible.value = true;
};
const emit = defineEmits<{ (e: 'search'): void }>();
function checkPassword(rule: any, value: any, callback: any) {
if (dialogData.value.rowData.passPhrase !== '') {
if (isGenerate.value && value) {
const reg = /^[A-Za-z0-9]{6,15}$/;
if (!reg.test(dialogData.value.rowData.passPhrase)) {
if (!reg.test(value)) {
return callback(new Error(i18n.global.t('ssh.passwordHelper')));
}
}
callback();
}
const checkKey = (rule: { field: string }, value: string, callback: (error?: Error) => void) => {
if (isGenerate.value) {
return callback();
}
const content = value?.trim();
if (!content) {
return callback(new Error(i18n.global.t('commons.rule.requiredInput')));
}
const pattern =
rule.field === 'publicKey'
? /^(?:ssh-(?:rsa|ed25519|dss)|ecdsa-sha2-nistp(?:256|384|521))[ \t]+[A-Za-z0-9+/]+={0,2}(?:[ \t]+[^\r\n]*)?$/
: /^-----BEGIN ((?:OPENSSH |RSA |EC |DSA |ENCRYPTED )?PRIVATE KEY)-----\r?\n(?:Proc-Type: 4,ENCRYPTED\r?\nDEK-Info: [^\r\n]+\r?\n\r?\n)?[A-Za-z0-9+/][A-Za-z0-9+/=\r\n]*\r?\n-----END \1-----$/;
if (!pattern.test(content)) {
return callback(new Error(i18n.global.t('commons.rule.formatErr')));
}
callback();
};
const onModeChange = () => {
cancelKeyRead();
dialogData.value.rowData.encryptionMode = isGenerate.value ? 'ed25519' : '';
dialogData.value.rowData.passPhrase = '';
dialogData.value.rowData.privateKey = '';
dialogData.value.rowData.publicKey = '';
uploadPrivateRef.value?.clearFiles();
uploadPublicRef.value?.clearFiles();
formRef.value?.clearValidate();
};
const rules = reactive({
name: Rules.simpleName,
encryptionMode: Rules.requiredSelect,
passPhrase: [{ validator: checkPassword, trigger: 'blur' }],
privateKey: [Rules.requiredInput],
publicKey: [Rules.requiredInput],
privateKey: [{ required: true, validator: checkKey, trigger: ['blur', 'change'] }],
publicKey: [{ required: true, validator: checkKey, trigger: ['blur', 'change'] }],
});
const onConfirm = async (formEl: FormInstance | undefined) => {
@@ -177,23 +234,27 @@ const onConfirm = async (formEl: FormInstance | undefined) => {
formEl.validate(async (valid) => {
if (!valid) return;
loading.value = true;
const request = {
...dialogData.value.rowData,
encryptionMode: isGenerate.value ? dialogData.value.rowData.encryptionMode : '',
};
if (dialogData.value.title === 'create') {
await createCert(dialogData.value.rowData)
await createCert(request)
.then(() => {
loading.value = false;
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
drawerVisible.value = false;
handleClose();
emit('search');
})
.catch(() => {
loading.value = false;
});
} else {
await editCert(dialogData.value.rowData)
await editCert(request)
.then(() => {
loading.value = false;
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
drawerVisible.value = false;
handleClose();
emit('search');
})
.catch(() => {
@@ -203,35 +264,41 @@ const onConfirm = async (formEl: FormInstance | undefined) => {
});
};
const privateOnChange = (_uploadFile: UploadFile) => {
const onKeyRemove = (field: KeyField) => {
cancelKeyRead(field);
dialogData.value.rowData[field] = '';
formRef.value?.validateField(field).catch(() => {});
};
const onKeyChange = (field: KeyField, uploadFile: UploadFile) => {
const rowData = dialogData.value.rowData;
if (!drawerVisible.value || rowData.mode !== 'import') return;
cancelKeyRead(field);
rowData[field] = '';
const reader = new FileReader();
keyReaders[field] = reader;
reader.onload = (e) => {
try {
dialogData.value.rowData.privateKey = e.target.result as string;
} catch (error) {
MsgError(i18n.global.t('commons.msg.errImport') + error.message);
}
if (keyReaders[field] !== reader || dialogData.value.rowData !== rowData) return;
delete keyReaders[field];
rowData[field] = e.target.result as string;
formRef.value?.validateField(field).catch(() => {});
};
reader.readAsText(_uploadFile.raw);
reader.onerror = () => {
if (keyReaders[field] !== reader || dialogData.value.rowData !== rowData) return;
delete keyReaders[field];
MsgError(i18n.global.t('commons.msg.errImport'));
};
reader.readAsText(uploadFile.raw);
};
const privateExceed: UploadProps['onExceed'] = (files) => {
onKeyRemove('privateKey');
uploadPrivateRef.value!.clearFiles();
const file = files[0] as UploadRawFile;
file.uid = genFileId();
uploadPrivateRef.value!.handleStart(file);
};
const publicOnChange = (_uploadFile: UploadFile) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
dialogData.value.rowData.publicKey = e.target.result as string;
} catch (error) {
MsgError(i18n.global.t('commons.msg.errImport') + error.message);
}
};
reader.readAsText(_uploadFile.raw);
};
const publicExceed: UploadProps['onExceed'] = (files) => {
onKeyRemove('publicKey');
uploadPublicRef.value!.clearFiles();
const file = files[0] as UploadRawFile;
file.uid = genFileId();
@@ -243,6 +310,7 @@ const random = async () => {
};
const handleClose = () => {
cancelKeyRead();
drawerVisible.value = false;
};
@@ -101,6 +101,7 @@
</template>
<script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { Log } from '@/api/interface/log';
import { getSystemLogStatus, listRunningServices, readSystemLogs } from '@/api/modules/log';
@@ -113,6 +114,7 @@ const logs = ref<Log.SystemLogItem[]>([]);
const loading = ref(false);
const watching = ref(false);
const keyword = ref('');
useSearchPersistence('log/host-system/index', { keyword });
const priority = ref('');
const service = ref('');
const services = ref<string[]>([]);
+2
View File
@@ -45,6 +45,7 @@
</template>
<script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import ConfirmDialog from '@/components/confirm-dialog/index.vue';
import LogRouter from '@/views/log/router/index.vue';
import { dateFormat } from '@/utils/date';
@@ -66,6 +67,7 @@ const paginationConfig = reactive({
total: 0,
});
const searchInfo = ref<string>('');
useSearchPersistence('log/login/index', { search: searchInfo });
const searchStatus = ref<string>('');
const search = async () => {
@@ -125,6 +125,7 @@
</template>
<script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import ConfirmDialog from '@/components/confirm-dialog/index.vue';
import LogRouter from '@/views/log/router/index.vue';
import { dateFormat } from '@/utils/date';
@@ -145,6 +146,7 @@ const paginationConfig = reactive({
total: 0,
});
const searchName = ref<string>('');
useSearchPersistence('log/operation/index', { search: searchName });
const searchGroup = ref<string>('');
const searchStatus = ref<string>('');
const searchNode = ref<string>('');
@@ -120,8 +120,9 @@
</div>
</template>
<script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { dateFormat } from '@/utils/date';
import { onMounted, ref } from 'vue';
import { toRef, onMounted, ref } from 'vue';
import { searchBackup, deleteBackup, refreshToken } from '@/api/modules/backup';
import Operate from '@/views/setting/backup-account/operate/index.vue';
import DetailShow from '@/components/detail-show/index.vue';
@@ -142,6 +143,7 @@ const paginationConfig = reactive({
type: '',
name: '',
});
useSearchPersistence('setting/backup-account/index', { search: toRef(paginationConfig, 'name') });
const opRef = ref();
const dialogRef = ref();
const detailRef = ref();
@@ -19,6 +19,8 @@
</template>
<template #main>
<ComplexTable
:selection-context="() => [searchName]"
row-key="id"
:pagination-config="paginationConfig"
v-model:selects="selects"
:data="data"
@@ -223,6 +225,7 @@
</template>
<script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import {
searchSnapshotPage,
snapshotDelete,
@@ -258,6 +261,7 @@ const paginationConfig = reactive({
order: 'null',
});
const searchName = ref();
useSearchPersistence('setting/snapshot/index', { search: searchName });
const opRef = ref();
@@ -100,6 +100,7 @@
</template>
<script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { Command } from '@/api/interface/command';
import GroupDialog from '@/components/group/index.vue';
import OperateDialog from '@/views/terminal/command/operate/index.vue';
@@ -123,6 +124,7 @@ const paginationConfig = reactive({
order: 'ascending',
});
const info = ref();
useSearchPersistence('terminal/command/index', { search: info });
const group = ref<string>('');
const dialogRef = ref();
const opRef = ref();
@@ -79,6 +79,7 @@
</template>
<script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import GroupDialog from '@/components/agent-group/index.vue';
import OperateDialog from '@/views/terminal/host/operate/index.vue';
import { deleteHost, editHostGroup, searchHosts } from '@/api/modules/terminal';
@@ -99,6 +100,7 @@ const paginationConfig = reactive({
total: 0,
});
const info = ref();
useSearchPersistence('terminal/host/index', { search: info });
const group = ref<string>('');
const opRef = ref();
@@ -49,6 +49,8 @@
</el-card>
<template #main v-if="clamStatus.isExist">
<ComplexTable
:selection-context="() => [searchName]"
row-key="id"
:class="{ mask: !clamStatus.isRunning }"
v-if="!isSettingShow"
:pagination-config="paginationConfig"
@@ -188,6 +190,7 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { onMounted, reactive, ref } from 'vue';
import i18n from '@/lang';
import { MsgSuccess } from '@/utils/message';
@@ -215,6 +218,7 @@ const paginationConfig = reactive({
order: 'null',
});
const searchName = ref();
useSearchPersistence('toolbox/clam/index', { search: searchName });
const opRef = ref();
const dialogRef = ref();
+2
View File
@@ -174,6 +174,7 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { onMounted, reactive, ref } from 'vue';
import i18n from '@/lang';
import { MsgError, MsgSuccess } from '@/utils/message';
@@ -200,6 +201,7 @@ const paginationConfig = reactive({
order: 'null',
});
const searchName = ref();
useSearchPersistence('toolbox/ftp/index', { search: searchName });
const form = reactive({
isActive: true,
@@ -33,6 +33,7 @@
</template>
<script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { Runtime } from '@/api/interface/runtime';
import { GetPHPExtensions, InstallPHPExtension, UnInstallPHPExtension } from '@/api/modules/runtime';
import i18n from '@/lang';
@@ -48,6 +49,7 @@ const supportExtensions = ref([]);
const loading = ref(false);
const taskLogRef = ref();
const searchName = ref('');
useSearchPersistence('website/runtime/php/extension-management/index', { search: searchName });
const data = ref([]);
const handleClose = () => {
@@ -137,7 +139,6 @@ const searchByName = () => {
};
const acceptParams = (req: Runtime.Runtime): void => {
searchName.value = '';
open.value = true;
runtime.value = req;
search();
+3 -1
View File
@@ -221,7 +221,8 @@
</template>
<script lang="ts" setup>
import { defineAsyncComponent, onMounted, reactive, ref } from 'vue';
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { toRef, defineAsyncComponent, onMounted, reactive, ref } from 'vue';
import { deleteSSL, downloadFile, pushSSLToNode, searchSSL, updateSSL } from '@/api/modules/website';
import DnsAccount from './dns-account/index.vue';
import AcmeAccount from './acme-account/index.vue';
@@ -286,6 +287,7 @@ const req = reactive({
orderBy: 'updated_at',
order: 'descending',
});
useSearchPersistence('website/ssl/index', { search: toRef(req, 'domain') });
const pushForm = ref({
id: 0,
pushNode: true,
@@ -59,6 +59,7 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { Website } from '@/api/interface/website';
import { deleteTemplate, searchTemplates } from '@/api/modules/website';
import TemplateOperate from '@/views/website/template/operate/index.vue';
@@ -71,6 +72,7 @@ import { reactive, ref, onMounted } from 'vue';
const loading = ref(false);
const data = ref<Website.Template[]>([]);
const searchName = ref('');
useSearchPersistence('website/template/index', { search: searchName });
const opRef = ref();
const operateRef = ref();
const outputCreateRef = ref();
+5 -2
View File
@@ -66,6 +66,8 @@
</template>
<template v-if="!openNginxConfig" #main>
<ComplexTable
:selection-context="() => [req.name, req.type, req.websiteGroupId]"
row-key="id"
:pagination-config="paginationConfig"
:default-sort="tableSort.order ? tableSort : undefined"
v-model:view-mode="viewMode"
@@ -344,6 +346,7 @@
</template>
<script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import Backups from '@/components/backup/index.vue';
import UploadDialog from '@/components/upload/index.vue';
import DefaultServer from '@/views/website/website/default/index.vue';
@@ -358,7 +361,7 @@ import BatchSetGroup from '@/views/website/website/batch-op/group.vue';
import BatchSetHttps from '@/views/website/website/batch-op/https.vue';
import i18n from '@/lang';
import { onMounted, reactive, ref, computed } from 'vue';
import { toRef, onMounted, reactive, ref, computed } from 'vue';
import { batchOperate, opWebsite, searchWebsites, updateWebsite } from '@/api/modules/website';
import { Website } from '@/api/interface/website';
import { App } from '@/api/interface/app';
@@ -448,6 +451,7 @@ const pageState = usePageState(() => ({
}));
const paginationConfig = pageState.paginationConfig;
const req = pageState.req;
useSearchPersistence('website/website/index', { search: toRef(req, 'name') });
const tableSort = pageState.tableSort;
const goRouter = async (key: string) => {
@@ -496,7 +500,6 @@ const search = async () => {
req.pageSize = paginationConfig.pageSize;
loading.value = true;
data.value = [];
await searchWebsites(req)
.then((res) => {
data.value = res.data.items;