Compare commits

...
Author SHA1 Message Date
guoyuqi 200ddbfa28 feat: redis sync and image upload 2026-09-30 17:37:33 +08:00
guoyuqi f34418da6d refactor: select bind 2026-09-29 17:11:06 +08:00
guoyuqi 7ff86c0ee3 Merge remote-tracking branch 'origin/dev-v2' into pr@dev-v2@fix-cleanDockerNetwork 2026-09-29 11:09:13 +08:00
guoyuqi 5a58f44548 refactor: search bind 2026-09-29 11:00:30 +08:00
guoyuqi c75cb5d7ce fix: fix log text 2026-09-28 14:06:40 +08:00
guoyuqi 2207147e7f fix: fix operation log 2026-09-28 11:03:33 +08:00
guoyuqi b5604eab11 fix: fix container list 2026-09-24 15:06:32 +08:00
guoyuqi b498882708 Merge remote-tracking branch 'origin/dev-v2' into pr@dev-v2@fix-cleanDockerNetwork 2026-09-24 14:09:56 +08:00
guoyuqi 381d71663c Merge remote-tracking branch 'origin/pr@dev-v2@fix-cleanDockerNetwork' into pr@dev-v2@fix-cleanDockerNetwork
# Conflicts:
#	agent/app/api/v2/container.go
#	agent/app/dto/container_network_cleanup.go
#	agent/app/service/container_network_cleanup.go
#	agent/utils/docker/network_cleanup.go
2026-09-24 09:43:10 +08:00
guoyuqi f7f4135193 fix: fix clean network 2026-09-24 09:41:59 +08:00
guoyuqi dbf6d9e5fe fix: fix clean network 2026-09-23 17:54:15 +08:00
87 changed files with 1834 additions and 495 deletions
+18 -3
View File
@@ -28,7 +28,7 @@ func (b *BaseApi) SearchContainer(c *gin.Context) {
return return
} }
total, list, err := containerService.Page(req) total, list, err := containerService.Page(c.Request.Context(), req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -249,9 +249,10 @@ func (b *BaseApi) ListContainerByImage(c *gin.Context) {
// @Success 200 {object} dto.ContainerStatus // @Success 200 {object} dto.ContainerStatus
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Param containersOnly query boolean false "Only count containers"
// @Router /containers/status [get] // @Router /containers/status [get]
func (b *BaseApi) LoadContainerStatus(c *gin.Context) { 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 { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -415,9 +416,14 @@ func (b *BaseApi) LoadResourceLimit(c *gin.Context) {
// @Success 200 {array} dto.ContainerListStats // @Success 200 {array} dto.ContainerListStats
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Param ids query string false "Comma-separated container IDs; omitted selects all containers"
// @Router /containers/list/stats [get] // @Router /containers/list/stats [get]
func (b *BaseApi) ContainerListStats(c *gin.Context) { 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 { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -964,3 +970,12 @@ func (b *BaseApi) ContainerStreamLogs(c *gin.Context) {
containerService.StreamLogs(c, streamLog) 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)
}
+24 -3
View File
@@ -86,17 +86,38 @@ func (b *BaseApi) CheckHasCli(c *gin.Context) {
// @Tags Database Redis // @Tags Database Redis
// @Summary Install redis-cli // @Summary Install redis-cli
// @Success 200 // @Accept json
// @Param request body dto.RedisCliInstall true "request"
// @Success 200 {object} dto.RedisCliStatus
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /databases/redis/install/cli [post] // @Router /databases/redis/install/cli [post]
func (b *BaseApi) InstallCli(c *gin.Context) { func (b *BaseApi) InstallCli(c *gin.Context) {
if err := redisService.InstallCli(); err != nil { var req dto.RedisCliInstall
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := redisService.InstallCli(req)
if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
} }
helper.SuccessWithData(c, data)
}
helper.Success(c) // @Tags Database Redis
// @Summary Load redis-cli installation status
// @Success 200 {object} dto.RedisCliStatus
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /databases/redis/cli/status [get]
func (b *BaseApi) LoadRedisCliStatus(c *gin.Context) {
data, err := redisService.LoadCliStatus()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
} }
// @Tags Database Redis // @Tags Database Redis
+1 -1
View File
@@ -169,7 +169,7 @@ func (b *BaseApi) GetNodePackageRunScript(c *gin.Context) {
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /runtimes/operate [post] // @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) { func (b *BaseApi) OperateRuntime(c *gin.Context) {
var req request.RuntimeOperate var req request.RuntimeOperate
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
@@ -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"`
}
+11
View File
@@ -22,6 +22,17 @@ type DBBaseInfo struct {
Port int64 `json:"port"` Port int64 `json:"port"`
} }
type RedisCliInstall struct {
TaskID string `json:"taskID" validate:"omitempty,uuid"`
}
type RedisCliStatus struct {
Installed bool `json:"installed"`
TaskID string `json:"taskID"`
Status string `json:"status"`
ErrorMsg string `json:"errorMsg"`
}
// mysql // mysql
type MysqlDBSearch struct { type MysqlDBSearch struct {
PageInfo PageInfo
+85 -25
View File
@@ -58,10 +58,10 @@ type ContainerService struct{}
var containerLogAnsiRegex = regexp.MustCompile("\x1b\\[[0-9;?]*[A-Za-z]|\x1b=|\x1b>") var containerLogAnsiRegex = regexp.MustCompile("\x1b\\[[0-9;?]*[A-Za-z]|\x1b=|\x1b>")
type IContainerService interface { type IContainerService interface {
Page(req dto.PageContainer) (int64, interface{}, error) Page(ctx context.Context, req dto.PageContainer) (int64, interface{}, error)
List() []dto.ContainerOptions List() []dto.ContainerOptions
ListByImage(imageName string) []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) PageNetwork(req dto.SearchWithPage) (int64, interface{}, error)
ListNetwork() ([]dto.Options, error) ListNetwork() ([]dto.Options, error)
PageVolume(req dto.SearchWithPage) (int64, interface{}, error) PageVolume(req dto.SearchWithPage) (int64, interface{}, error)
@@ -80,7 +80,7 @@ type IContainerService interface {
ContainerUpdate(req dto.ContainerOperate) error ContainerUpdate(req dto.ContainerOperate) error
ContainerUpgrade(req dto.ContainerUpgrade) error ContainerUpgrade(req dto.ContainerUpgrade) error
ContainerInfo(req dto.OperationWithName) (*dto.ContainerOperate, 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) ContainerItemStats(ctx context.Context, req dto.OperationWithName) (dto.ContainerItemStats, error)
LoadResourceLimit() (*dto.ResourceLimit, error) LoadResourceLimit() (*dto.ResourceLimit, error)
ContainerRename(req dto.ContainerRename) error ContainerRename(req dto.ContainerRename) error
@@ -92,6 +92,7 @@ type IContainerService interface {
Inspect(req dto.InspectReq) (string, error) Inspect(req dto.InspectReq) (string, error)
DeleteNetwork(req dto.BatchDelete) error DeleteNetwork(req dto.BatchDelete) error
CleanNetworks() (*dto.NetworkCleanupTask, error)
CreateNetwork(req dto.NetworkCreate) error CreateNetwork(req dto.NetworkCreate) error
DeleteVolume(req dto.BatchDelete) error DeleteVolume(req dto.BatchDelete) error
CreateVolume(req dto.VolumeCreate) error CreateVolume(req dto.VolumeCreate) error
@@ -112,7 +113,9 @@ func NewIContainerService() IContainerService {
return &ContainerService{} 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) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
client, err := docker.NewDockerClient() client, err := docker.NewDockerClient()
if err != nil { if err != nil {
return 0, nil, err return 0, nil, err
@@ -123,7 +126,7 @@ func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, erro
options.Filters = filters.NewArgs() options.Filters = filters.NewArgs()
options.Filters.Add("label", req.Filters) options.Filters.Add("label", req.Filters)
} }
containers, err := client.ContainerList(context.Background(), options) containers, err := client.ContainerList(ctx, options)
if err != nil { if err != nil {
return 0, nil, err return 0, nil, err
} }
@@ -206,15 +209,16 @@ func (u *ContainerService) ListByImage(imageName string) []dto.ContainerOptions
return options 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 var data dto.ContainerStatus
client, err := docker.NewDockerClient() client, err := docker.NewDockerClient()
if err != nil { if err != nil {
return data, err return data, err
} }
defer client.Close() defer client.Close()
c := context.Background() c, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if !containersOnly {
images, _ := client.ImageList(c, image.ListOptions{All: true}) images, _ := client.ImageList(c, image.ListOptions{All: true})
data.ImageCount = len(images) data.ImageCount = len(images)
repo, _ := imageRepoRepo.List() repo, _ := imageRepoRepo.List()
@@ -225,8 +229,12 @@ func (u *ContainerService) LoadStatus() (dto.ContainerStatus, error) {
data.NetworkCount = len(networks) data.NetworkCount = len(networks)
volumes, _ := client.VolumeList(c, volume.ListOptions{}) volumes, _ := client.VolumeList(c, volume.ListOptions{})
data.VolumeCount = len(volumes.Volumes) data.VolumeCount = len(volumes.Volumes)
data.ComposeCount = loadComposeCount(client) data.ComposeCount = loadComposeCount(c, client)
containers, _ := client.ContainerList(c, container.ListOptions{All: true}) }
containers, err := client.ContainerList(c, container.ListOptions{All: true})
if err != nil {
return data, err
}
data.ContainerCount = len(containers) data.ContainerCount = len(containers)
for _, item := range containers { for _, item := range containers {
switch item.State { switch item.State {
@@ -292,27 +300,67 @@ func (u *ContainerService) ContainerItemStats(ctx context.Context, req dto.Opera
} }
return data, nil 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() client, err := docker.NewDockerClient()
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer client.Close() defer client.Close()
list, err := client.ContainerList(context.Background(), container.ListOptions{All: true}) ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
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 { if err != nil {
return nil, err 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)) datas := make([]dto.ContainerListStats, len(list))
var wg sync.WaitGroup for i, item := range list {
wg.Add(len(list)) datas[i].ContainerID = item.ID
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])
} }
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() wg.Wait()
return datas, nil return datas
} }
func (u *ContainerService) Inspect(req dto.InspectReq) (string, error) { func (u *ContainerService) Inspect(req dto.InspectReq) (string, error) {
@@ -484,6 +532,10 @@ func (u *ContainerService) LoadResourceLimit() (*dto.ResourceLimit, error) {
} }
func (u *ContainerService) ContainerCreate(req dto.ContainerOperate, inThread bool) error { func (u *ContainerService) ContainerCreate(req dto.ContainerOperate, inThread bool) error {
return u.containerCreate(req, inThread, "")
}
func (u *ContainerService) containerCreate(req dto.ContainerOperate, inThread bool, taskName string) error {
client, err := docker.NewDockerClient() client, err := docker.NewDockerClient()
if err != nil { if err != nil {
return err return err
@@ -497,7 +549,10 @@ func (u *ContainerService) ContainerCreate(req dto.ContainerOperate, inThread bo
return buserr.New("ErrContainerName") return buserr.New("ErrContainerName")
} }
taskItem, err := task.NewTaskWithOps(req.Name, task.TaskCreate, task.TaskScopeContainer, req.TaskID, 1) if taskName == "" {
taskName = task.GetTaskName(req.Name, task.TaskCreate, task.TaskScopeContainer)
}
taskItem, err := task.NewTask(taskName, task.TaskCreate, task.TaskScopeContainer, req.TaskID, 1)
if err != nil { if err != nil {
unlock() unlock()
_ = client.Close() _ = client.Close()
@@ -558,6 +613,11 @@ func (u *ContainerService) ContainerCreate(req dto.ContainerOperate, inThread bo
}, nil) }, nil)
if inThread { if inThread {
if err := taskItem.Prepare(); err != nil {
unlock()
_ = client.Close()
return err
}
go func() { go func() {
defer unlock() defer unlock()
defer client.Close() defer client.Close()
@@ -1715,11 +1775,11 @@ func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo
return selected 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{ data := dto.ContainerListStats{
ContainerID: containerItem, ContainerID: containerItem,
} }
res, err := client.ContainerStats(context.Background(), containerItem, false) res, err := client.ContainerStats(ctx, containerItem, false)
if err != nil { if err != nil {
return data return data
} }
@@ -1942,11 +2002,11 @@ func transPortToStr(ports []container.Port) []string {
return docker.SimplifyPorts(ports) 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 := container.ListOptions{All: true}
options.Filters = filters.NewArgs() options.Filters = filters.NewArgs()
options.Filters.Add("label", composeProjectLabel) options.Filters.Add("label", composeProjectLabel)
list, err := client.ContainerList(context.Background(), options) list, err := client.ContainerList(ctx, options)
if err != nil { if err != nil {
return 0 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)
}
})
}
}
+58 -4
View File
@@ -8,6 +8,12 @@ import (
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"sync"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/1Panel-dev/1Panel/agent/app/repo" "github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
@@ -23,6 +29,11 @@ import (
type RedisService struct{} type RedisService struct{}
const redisCliTaskName = "RedisCliEnable"
// The CLI container is shared by all remote Redis databases on this node.
var redisCliInstallMutex sync.Mutex
type IRedisService interface { type IRedisService interface {
UpdateConf(req dto.RedisConfUpdate) error UpdateConf(req dto.RedisConfUpdate) error
UpdatePersistenceConf(req dto.RedisConfPersistenceUpdate) error UpdatePersistenceConf(req dto.RedisConfPersistenceUpdate) error
@@ -33,7 +44,8 @@ type IRedisService interface {
LoadPersistenceConf(req dto.LoadRedisStatus) (*dto.RedisPersistence, error) LoadPersistenceConf(req dto.LoadRedisStatus) (*dto.RedisPersistence, error)
CheckHasCli() bool CheckHasCli() bool
InstallCli() error InstallCli(req dto.RedisCliInstall) (*dto.RedisCliStatus, error)
LoadCliStatus() (*dto.RedisCliStatus, error)
} }
func NewIRedisService() IRedisService { func NewIRedisService() IRedisService {
@@ -71,20 +83,62 @@ func (u *RedisService) CheckHasCli() bool {
return false return false
} }
for _, item := range containerLists { for _, item := range containerLists {
if strings.ReplaceAll(item.Names[0], "/", "") == "1Panel-redis-cli-tools" { if len(item.Names) > 0 && strings.TrimPrefix(item.Names[0], "/") == "1Panel-redis-cli-tools" {
return true return true
} }
} }
return false return false
} }
func (u *RedisService) InstallCli() error { func (u *RedisService) LoadCliStatus() (*dto.RedisCliStatus, error) {
result := &dto.RedisCliStatus{}
latest, err := taskRepo.GetFirst(repo.WithByName(redisCliTaskName), repo.WithByType(task.TaskScopeContainer), repo.WithOrderDesc("created_at"))
if errors.Is(err, gorm.ErrRecordNotFound) {
result.Installed = u.CheckHasCli()
return result, nil
}
if err != nil {
return nil, err
}
result.TaskID = latest.ID
result.Status = latest.Status
result.ErrorMsg = latest.ErrorMsg
result.Installed = u.CheckHasCli()
return result, nil
}
func (u *RedisService) InstallCli(req dto.RedisCliInstall) (*dto.RedisCliStatus, error) {
if !redisCliInstallMutex.TryLock() {
return nil, buserr.New("TaskIsExecuting")
}
defer redisCliInstallMutex.Unlock()
status, err := u.LoadCliStatus()
if err != nil {
return nil, err
}
if status.Status == constant.StatusExecuting || status.Installed {
return status, nil
}
if req.TaskID == "" {
req.TaskID = uuid.NewString()
}
// Never reuse an existing task ID: doing so would truncate its log.
if _, err := taskRepo.GetFirst(taskRepo.WithByID(req.TaskID)); !errors.Is(err, gorm.ErrRecordNotFound) {
if err != nil {
return nil, err
}
return nil, buserr.New("TaskIsExecuting")
}
item := dto.ContainerOperate{ item := dto.ContainerOperate{
TaskID: req.TaskID,
Name: "1Panel-redis-cli-tools", Name: "1Panel-redis-cli-tools",
Image: "redis:7.4.4", Image: "redis:7.4.4",
Networks: []dto.ContainerNetwork{{Network: "1panel-network"}}, Networks: []dto.ContainerNetwork{{Network: "1panel-network"}},
} }
return NewIContainerService().ContainerCreate(item, false) if err := (&ContainerService{}).containerCreate(item, true, redisCliTaskName); err != nil {
return nil, err
}
return &dto.RedisCliStatus{TaskID: req.TaskID, Status: constant.StatusExecuting}, nil
} }
func (u *RedisService) ChangePassword(req dto.ChangeRedisPass) error { func (u *RedisService) ChangePassword(req dto.ChangeRedisPass) error {
+8 -16
View File
@@ -5,7 +5,6 @@ import (
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -325,18 +324,16 @@ func (u *ImageService) ImageLoad(req dto.ImageLoad) error {
} }
go func() { go func() {
client, err := docker.NewDockerClient()
if err != nil {
taskItem.Log("Failed to create Docker client: " + err.Error())
return
}
defer client.Close()
for _, itemPath := range req.Paths { for _, itemPath := range req.Paths {
currentPath := itemPath currentPath := itemPath
itemName := path.Base(currentPath) itemName := path.Base(currentPath)
taskItem.AddSubTask(i18n.GetWithName("TaskImport", itemName), func(t *task.Task) error { taskItem.AddSubTask(i18n.GetWithName("TaskImport", itemName), func(t *task.Task) error {
taskItem.Logf("----------------- %s -----------------", itemName) taskItem.Logf("----------------- %s -----------------", itemName)
client, err := docker.NewDockerClient()
if err != nil {
return err
}
defer client.Close()
file, err := os.Open(currentPath) file, err := os.Open(currentPath)
if err != nil { if err != nil {
return err return err
@@ -347,14 +344,9 @@ func (u *ImageService) ImageLoad(req dto.ImageLoad) error {
return err return err
} }
defer res.Body.Close() defer res.Body.Close()
content, err := io.ReadAll(res.Body) return consumeImageLoadResponse(res.Body, func(message string) {
if err != nil { taskItem.Log(message)
return err })
}
if strings.Contains(string(content), "Error") {
return errors.New(string(content))
}
return nil
}, nil) }, nil)
} }
_ = taskItem.Execute() _ = taskItem.Execute()
+37
View File
@@ -0,0 +1,37 @@
package service
import (
"encoding/json"
"errors"
"io"
"strings"
)
// Docker may report load failures in a successful HTTP response's JSON stream.
func consumeImageLoadResponse(reader io.Reader, log func(string)) error {
decoder := json.NewDecoder(reader)
for {
var message struct {
Stream string `json:"stream"`
Error string `json:"error"`
ErrorDetail struct {
Message string `json:"message"`
} `json:"errorDetail"`
}
if err := decoder.Decode(&message); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
if message.Error != "" {
return errors.New(message.Error)
}
if message.ErrorDetail.Message != "" {
return errors.New(message.ErrorDetail.Message)
}
if text := strings.TrimSpace(message.Stream); text != "" {
log(text)
}
}
}
+12 -2
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") logPath := path.Join(global.Dir.TaskDir, taskScope, taskID+".log")
logger := logrus.New() logger := logrus.New()
logger.SetFormatter(&SimpleFormatter{}) 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 { if err != nil {
return nil, fmt.Errorf("failed to open log file: %w", err) return nil, fmt.Errorf("failed to open log file: %w", err)
} }
@@ -283,8 +283,18 @@ func (t *Task) updateTask(task *model.Task) {
_ = t.taskRepo.Update(context.Background(), task) _ = t.taskRepo.Update(context.Background(), task)
} }
func (t *Task) Execute() error { // Prepare makes a task visible before dispatching it to a background worker.
func (t *Task) Prepare() error {
if err := t.taskRepo.Save(context.Background(), t.Task); err != nil { if err := t.taskRepo.Save(context.Background(), t.Task); err != nil {
_ = t.logFile.Close()
global.RemoveTaskCancel(t.TaskID)
return err
}
return nil
}
func (t *Task) Execute() error {
if err := t.Prepare(); err != nil {
return err return err
} }
var err error var err error
+20 -4
View File
@@ -644,6 +644,13 @@
"formatZH": "创建容器网络 name", "formatZH": "创建容器网络 name",
"formatEN": "create container network [name]" "formatEN": "create container network [name]"
}, },
"/containers/network/clean": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "清理未使用的容器网络",
"formatEN": "Clean unused container networks"
},
"/containers/network/del": { "/containers/network/del": {
"bodyKeys": [ "bodyKeys": [
"names" "names"
@@ -4028,12 +4035,21 @@
}, },
"/runtimes/operate": { "/runtimes/operate": {
"bodyKeys": [ "bodyKeys": [
"id" "ID"
], ],
"paramKeys": [], "paramKeys": [],
"beforeFunctions": [], "beforeFunctions": [
"formatZH": "操作运行环境 [id]", {
"formatEN": "Operate runtime [id]" "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": { "/runtimes/php/config": {
"bodyKeys": [ "bodyKeys": [
+10
View File
@@ -731,3 +731,13 @@ FileTaskRename: 'Target filename: {{ .name }}'
ErrFirewallBackendCleanupRequired: "The current backend {{ .current }} still contains 1Panel rules. Clean it up before switching to {{ .target }}." 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." 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 }}" ErrFirewallRuleSavedApplyFailed: "The new rule configuration was saved, but applying it to the firewall failed. Retry by synchronizing: {{ .detail }}"
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"
+10
View File
@@ -731,3 +731,13 @@ FileTaskRename: '目標檔名:{{ .name }}'
ErrFirewallBackendCleanupRequired: "目前後端 {{ .current }} 中仍有 1Panel 規則,請先清理後再切換至 {{ .target }}。" ErrFirewallBackendCleanupRequired: "目前後端 {{ .current }} 中仍有 1Panel 規則,請先清理後再切換至 {{ .target }}。"
ErrDockerIPv4ForwardingDisabled: "IPv4 轉送尚未啟用,請先設定 net.ipv4.ip_forward=1,再使用 Docker 防火牆後端。" ErrDockerIPv4ForwardingDisabled: "IPv4 轉送尚未啟用,請先設定 net.ipv4.ip_forward=1,再使用 Docker 防火牆後端。"
ErrFirewallRuleSavedApplyFailed: "新規則設定已儲存,但套用至防火牆失敗。可透過同步重試:{{ .detail }}" ErrFirewallRuleSavedApplyFailed: "新規則設定已儲存,但套用至防火牆失敗。可透過同步重試:{{ .detail }}"
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: "部分網路清理失敗,請查看任務日誌"
+10
View File
@@ -731,3 +731,13 @@ FileTaskRename: '目标文件名:{{ .name }}'
ErrFirewallBackendCleanupRequired: "当前后端 {{ .current }} 中仍有 1Panel 规则,请先清理后再切换到 {{ .target }}。" ErrFirewallBackendCleanupRequired: "当前后端 {{ .current }} 中仍有 1Panel 规则,请先清理后再切换到 {{ .target }}。"
ErrDockerIPv4ForwardingDisabled: "IPv4 转发未开启,请先设置 net.ipv4.ip_forward=1,再使用 Docker 防火墙后端。" ErrDockerIPv4ForwardingDisabled: "IPv4 转发未开启,请先设置 net.ipv4.ip_forward=1,再使用 Docker 防火墙后端。"
ErrFirewallRuleSavedApplyFailed: "新规则配置已保存,但应用到防火墙失败。可通过同步重试:{{ .detail }}" ErrFirewallRuleSavedApplyFailed: "新规则配置已保存,但应用到防火墙失败。可通过同步重试:{{ .detail }}"
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.GET("/network", baseApi.ListNetwork)
baRouter.POST("/network/del", baseApi.DeleteNetwork) baRouter.POST("/network/del", baseApi.DeleteNetwork)
baRouter.POST("/network/clean", baseApi.CleanNetworks)
baRouter.POST("/network/search", baseApi.SearchNetwork) baRouter.POST("/network/search", baseApi.SearchNetwork)
baRouter.POST("/network", baseApi.CreateNetwork) baRouter.POST("/network", baseApi.CreateNetwork)
baRouter.GET("/volume", baseApi.ListVolume) baRouter.GET("/volume", baseApi.ListVolume)
+1
View File
@@ -43,6 +43,7 @@ func (s *DatabaseRouter) InitRouter(Router *gin.RouterGroup) {
cmdRouter.POST("/redis/status", baseApi.LoadRedisStatus) cmdRouter.POST("/redis/status", baseApi.LoadRedisStatus)
cmdRouter.POST("/redis/conf", baseApi.LoadRedisConf) cmdRouter.POST("/redis/conf", baseApi.LoadRedisConf)
cmdRouter.GET("/redis/check", baseApi.CheckHasCli) cmdRouter.GET("/redis/check", baseApi.CheckHasCli)
cmdRouter.GET("/redis/cli/status", baseApi.LoadRedisCliStatus)
cmdRouter.POST("/redis/install/cli", baseApi.InstallCli) cmdRouter.POST("/redis/install/cli", baseApi.InstallCli)
cmdRouter.POST("/redis/password", baseApi.ChangeRedisPassword) cmdRouter.POST("/redis/password", baseApi.ChangeRedisPassword)
cmdRouter.POST("/redis/conf/update", baseApi.UpdateRedisConf) cmdRouter.POST("/redis/conf/update", baseApi.UpdateRedisConf)
+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)
}
}
+67 -1
View File
@@ -15010,6 +15010,30 @@ const docTemplate = `{
] ]
} }
}, },
"/databases/redis/cli/status": {
"get": {
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.RedisCliStatus"
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Load redis-cli installation status",
"tags": [
"Database Redis"
]
}
},
"/databases/redis/conf": { "/databases/redis/conf": {
"post": { "post": {
"consumes": [ "consumes": [
@@ -15092,9 +15116,26 @@ const docTemplate = `{
}, },
"/databases/redis/install/cli": { "/databases/redis/install/cli": {
"post": { "post": {
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/dto.RedisCliInstall"
}
}
],
"responses": { "responses": {
"200": { "200": {
"description": "OK" "description": "OK",
"schema": {
"$ref": "#/definitions/dto.RedisCliStatus"
}
} }
}, },
"security": [ "security": [
@@ -39731,6 +39772,31 @@ const docTemplate = `{
], ],
"type": "object" "type": "object"
}, },
"dto.RedisCliInstall": {
"type": "object",
"properties": {
"taskID": {
"type": "string"
}
}
},
"dto.RedisCliStatus": {
"type": "object",
"properties": {
"errorMsg": {
"type": "string"
},
"installed": {
"type": "boolean"
},
"status": {
"type": "string"
},
"taskID": {
"type": "string"
}
}
},
"dto.RedisConf": { "dto.RedisConf": {
"properties": { "properties": {
"containerName": { "containerName": {
+67 -1
View File
@@ -15006,6 +15006,30 @@
] ]
} }
}, },
"/databases/redis/cli/status": {
"get": {
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.RedisCliStatus"
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Load redis-cli installation status",
"tags": [
"Database Redis"
]
}
},
"/databases/redis/conf": { "/databases/redis/conf": {
"post": { "post": {
"consumes": [ "consumes": [
@@ -15088,9 +15112,26 @@
}, },
"/databases/redis/install/cli": { "/databases/redis/install/cli": {
"post": { "post": {
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/dto.RedisCliInstall"
}
}
],
"responses": { "responses": {
"200": { "200": {
"description": "OK" "description": "OK",
"schema": {
"$ref": "#/definitions/dto.RedisCliStatus"
}
} }
}, },
"security": [ "security": [
@@ -39727,6 +39768,31 @@
], ],
"type": "object" "type": "object"
}, },
"dto.RedisCliInstall": {
"type": "object",
"properties": {
"taskID": {
"type": "string"
}
}
},
"dto.RedisCliStatus": {
"type": "object",
"properties": {
"errorMsg": {
"type": "string"
},
"installed": {
"type": "boolean"
},
"status": {
"type": "string"
},
"taskID": {
"type": "string"
}
}
},
"dto.RedisConf": { "dto.RedisConf": {
"properties": { "properties": {
"containerName": { "containerName": {
+20 -4
View File
@@ -644,6 +644,13 @@
"formatZH": "创建容器网络 name", "formatZH": "创建容器网络 name",
"formatEN": "create container network [name]" "formatEN": "create container network [name]"
}, },
"/containers/network/clean": {
"bodyKeys": [],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "清理未使用的容器网络",
"formatEN": "Clean unused container networks"
},
"/containers/network/del": { "/containers/network/del": {
"bodyKeys": [ "bodyKeys": [
"names" "names"
@@ -4028,12 +4035,21 @@
}, },
"/runtimes/operate": { "/runtimes/operate": {
"bodyKeys": [ "bodyKeys": [
"id" "ID"
], ],
"paramKeys": [], "paramKeys": [],
"beforeFunctions": [], "beforeFunctions": [
"formatZH": "操作运行环境 [id]", {
"formatEN": "Operate runtime [id]" "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": { "/runtimes/php/config": {
"bodyKeys": [ "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 客户端和临时任务数据库,不执行真实网络删除。
+10 -173
View File
@@ -126,7 +126,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.7", "@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7", "@babel/generator": "^7.29.7",
@@ -1014,7 +1013,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1031,7 +1029,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1048,7 +1045,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1065,7 +1061,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1082,7 +1077,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1099,7 +1093,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1116,7 +1109,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1133,7 +1125,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1150,7 +1141,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1167,7 +1157,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1184,7 +1173,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1201,7 +1189,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1218,7 +1205,6 @@
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1235,7 +1221,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1252,7 +1237,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1269,7 +1253,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1286,7 +1269,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1303,7 +1285,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1320,7 +1301,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1337,7 +1317,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1354,7 +1333,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1371,7 +1349,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1388,7 +1365,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1405,7 +1381,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1422,7 +1397,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1439,7 +1413,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1773,18 +1746,6 @@
"node": ">=6.0.0" "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": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -2013,7 +1974,6 @@
"version": "2.5.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
"integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -2053,7 +2013,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2074,7 +2033,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2095,7 +2053,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2116,7 +2073,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2137,7 +2093,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2158,7 +2113,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2179,7 +2133,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2200,7 +2153,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2221,7 +2173,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2242,7 +2193,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2263,7 +2213,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2284,7 +2233,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2305,7 +2253,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2350,7 +2297,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2367,7 +2313,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2384,7 +2329,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2401,7 +2345,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2418,7 +2361,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2435,7 +2377,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2452,7 +2393,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2469,7 +2409,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2486,7 +2425,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2503,7 +2441,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2520,7 +2457,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2537,7 +2473,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2554,7 +2489,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2571,7 +2505,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2984,7 +2917,6 @@
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/lodash": "*" "@types/lodash": "*"
} }
@@ -3016,9 +2948,8 @@
"version": "26.4.0", "version": "26.4.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz",
"integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~8.3.0" "undici-types": "~8.3.0"
} }
@@ -3321,7 +3252,6 @@
"integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/scope-manager": "8.68.0",
"@typescript-eslint/types": "8.68.0", "@typescript-eslint/types": "8.68.0",
@@ -4005,7 +3935,6 @@
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.4.0.tgz", "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.4.0.tgz",
"integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==", "integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/web-bluetooth": "^0.0.21", "@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.4.0", "@vueuse/metadata": "14.4.0",
@@ -4052,15 +3981,13 @@
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.18.0", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -4317,7 +4244,6 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -4347,7 +4273,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.11.12", "baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001809", "caniuse-lite": "^1.0.30001809",
@@ -4688,7 +4613,6 @@
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.0.0", "@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0", "@codemirror/commands": "^6.0.0",
@@ -5373,7 +5297,6 @@
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"bin": { "bin": {
@@ -5766,7 +5689,6 @@
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },
@@ -5828,7 +5750,6 @@
"integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
@@ -5888,7 +5809,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"eslint-config-prettier": "bin/cli.js" "eslint-config-prettier": "bin/cli.js"
}, },
@@ -6240,7 +6160,6 @@
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -6342,7 +6261,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -6822,7 +6740,7 @@
"version": "5.1.9", "version": "5.1.9",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
"integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/imurmurhash": { "node_modules/imurmurhash": {
@@ -6895,7 +6813,7 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -6921,7 +6839,7 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-extglob": "^2.1.1" "is-extglob": "^2.1.1"
@@ -6966,7 +6884,6 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"engines": { "engines": {
@@ -7052,7 +6969,7 @@
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
@@ -7743,15 +7660,13 @@
"version": "4.18.1", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/lodash-es": { "node_modules/lodash-es": {
"version": "4.18.1", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/lodash-unified": { "node_modules/lodash-unified": {
"version": "1.0.3", "version": "1.0.3",
@@ -7955,7 +7870,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"argparse": "^2.0.1", "argparse": "^2.0.1",
"entities": "^4.5.0", "entities": "^4.5.0",
@@ -8253,7 +8167,6 @@
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -8441,7 +8354,6 @@
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
@@ -8697,7 +8609,6 @@
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"engines": { "engines": {
@@ -8722,7 +8633,6 @@
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/devtools-api": "^7.7.7" "@vue/devtools-api": "^7.7.7"
}, },
@@ -8803,7 +8713,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.17", "nanoid": "^3.3.17",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -8915,7 +8824,6 @@
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -9284,7 +9192,6 @@
"integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.143.0", "@oxc-project/types": "=0.143.0",
"@rolldown/pluginutils": "^1.0.0" "@rolldown/pluginutils": "^1.0.0"
@@ -9394,9 +9301,8 @@
"version": "1.103.1", "version": "1.103.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.103.1.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.103.1.tgz",
"integrity": "sha512-9icZURbP51S6S0QGoyaeqk9uB06GNWxsFYWfH5RgpFgqK5FA8tJcM3AdVxrZEVJ7dz+L87nG95gBKf4VuaMHGw==", "integrity": "sha512-9icZURbP51S6S0QGoyaeqk9uB06GNWxsFYWfH5RgpFgqK5FA8tJcM3AdVxrZEVJ7dz+L87nG95gBKf4VuaMHGw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"immutable": "^5.1.5", "immutable": "^5.1.5",
@@ -9622,29 +9528,6 @@
"node": ">=0.10.0" "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": { "node_modules/spdx-correct": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz",
@@ -9989,34 +9872,6 @@
"url": "https://opencollective.com/webpack" "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": { "node_modules/text-extensions": {
"version": "1.9.0", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-1.9.0.tgz", "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-1.9.0.tgz",
@@ -10098,7 +9953,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -10110,7 +9964,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -10175,7 +10028,6 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -10214,7 +10066,7 @@
"version": "8.3.0", "version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unimport": { "node_modules/unimport": {
@@ -10604,7 +10456,6 @@
"integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.33.0", "lightningcss": "^1.33.0",
"picomatch": "^4.0.5", "picomatch": "^4.0.5",
@@ -10879,7 +10730,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -10900,7 +10750,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -10921,7 +10770,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -10942,7 +10790,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -10963,7 +10810,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -10984,7 +10830,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -11005,7 +10850,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -11026,7 +10870,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -11047,7 +10890,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -11068,7 +10910,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -11089,7 +10930,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -11128,7 +10968,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz",
"integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==", "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.42", "@vue/compiler-dom": "3.5.42",
"@vue/compiler-sfc": "3.5.42", "@vue/compiler-sfc": "3.5.42",
@@ -11182,7 +11021,6 @@
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js" "vue-demi-switch": "bin/vue-demi-switch.js"
@@ -11209,7 +11047,6 @@
"integrity": "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==", "integrity": "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"debug": "^4.4.0", "debug": "^4.4.0",
"eslint-scope": "^8.2.0 || ^9.0.0", "eslint-scope": "^8.2.0 || ^9.0.0",
+7
View File
@@ -1,6 +1,13 @@
import { ReqPage } from '.'; import { ReqPage } from '.';
export namespace Database { export namespace Database {
export interface RedisCliStatus {
installed: boolean;
taskID: string;
status: string;
errorMsg: string;
}
export interface SearchDBWithPage { export interface SearchDBWithPage {
info: string; info: string;
database: string; database: string;
+26 -21
View File
@@ -3,13 +3,12 @@ import { ResPage, SearchWithPage } from '../interface';
import { Container } from '../interface/container'; import { Container } from '../interface/container';
import { TimeoutEnum } from '@/enums/http-enum'; import { TimeoutEnum } from '@/enums/http-enum';
export const searchContainer = (params: Container.ContainerSearch, currentNode?: string) => { export const searchContainer = (params: Container.ContainerSearch, currentNode?: string, signal?: AbortSignal) => {
return http.post<ResPage<Container.ContainerInfo>>( return http.postWithConfig<ResPage<Container.ContainerInfo>>(`/containers/search`, params, {
`/containers/search`, timeout: TimeoutEnum.T_40S,
params, headers: currentNode ? { CurrentNode: currentNode } : undefined,
TimeoutEnum.T_40S, signal,
currentNode ? { CurrentNode: currentNode } : undefined, });
);
}; };
export const listContainer = () => { export const listContainer = () => {
return http.post<Array<Container.ContainerOption>>(`/containers/list`, {}); return http.post<Array<Container.ContainerOption>>(`/containers/list`, {});
@@ -44,12 +43,11 @@ export const downloadContainerFile = (params: { containerID: string; path: strin
timeout: TimeoutEnum.T_40S, timeout: TimeoutEnum.T_40S,
}); });
}; };
export const loadContainerStatus = (currentNode?: string) => { export const loadContainerStatus = (currentNode?: string, containersOnly = false, signal?: AbortSignal) => {
return http.get<Container.ContainerStatus>( return http.get<Container.ContainerStatus>(`/containers/status`, containersOnly ? { containersOnly: true } : {}, {
`/containers/status`, headers: currentNode ? { CurrentNode: currentNode } : undefined,
{}, signal,
currentNode ? { headers: { CurrentNode: currentNode } } : {}, });
);
}; };
export const loadResourceLimit = () => { export const loadResourceLimit = () => {
return http.get<Container.ResourceLimit>(`/containers/limit`); return http.get<Container.ResourceLimit>(`/containers/limit`);
@@ -90,12 +88,11 @@ export const containerItemStats = (containerID: string, currentNode?: string) =>
currentNode ? { CurrentNode: currentNode } : undefined, currentNode ? { CurrentNode: currentNode } : undefined,
); );
}; };
export const containerListStats = (currentNode?: string) => { export const containerListStats = (currentNode?: string, ids?: string[], signal?: AbortSignal) => {
return http.get<Array<Container.ContainerListStats>>( return http.get<Array<Container.ContainerListStats>>(`/containers/list/stats`, ids ? { ids: ids.join(',') } : {}, {
`/containers/list/stats`, headers: currentNode ? { CurrentNode: currentNode } : undefined,
{}, signal,
currentNode ? { headers: { CurrentNode: currentNode } } : {}, });
);
}; };
export const containerStats = (id: string) => { export const containerStats = (id: string) => {
return http.get<Container.ContainerStats>(`/containers/stats/${id}`); return http.get<Container.ContainerStats>(`/containers/stats/${id}`);
@@ -144,8 +141,13 @@ export const imagePull = (params: Container.ImagePull) => {
export const imagePush = (params: Container.ImagePush) => { export const imagePush = (params: Container.ImagePush) => {
return http.post<string>(`/containers/image/push`, params); return http.post<string>(`/containers/image/push`, params);
}; };
export const imageLoad = (params: Container.ImageLoad) => { export const imageLoad = (params: Container.ImageLoad, currentNode?: string) => {
return http.post(`/containers/image/load`, params, TimeoutEnum.T_10M); return http.post(
`/containers/image/load`,
params,
TimeoutEnum.T_10M,
currentNode ? { CurrentNode: currentNode } : undefined,
);
}; };
export const imageSave = (params: Container.ImageSave) => { export const imageSave = (params: Container.ImageSave) => {
return http.post(`/containers/image/save`, params, TimeoutEnum.T_10M); return http.post(`/containers/image/save`, params, TimeoutEnum.T_10M);
@@ -164,6 +166,9 @@ export const searchNetwork = (params: SearchWithPage) => {
export const listNetwork = () => { export const listNetwork = () => {
return http.get<Array<Container.Options>>(`/containers/network`); 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) => { export const deleteNetwork = (params: Container.BatchDelete) => {
return http.post(`/containers/network/del`, params); return http.post(`/containers/network/del`, params);
}; };
+7 -2
View File
@@ -193,8 +193,13 @@ export const redisPersistenceConf = (type: string, database: string) => {
export const checkRedisCli = () => { export const checkRedisCli = () => {
return http.get<boolean>(`/databases/redis/check`); return http.get<boolean>(`/databases/redis/check`);
}; };
export const installRedisCli = () => { export const loadRedisCliStatus = (node: string) => {
return http.post(`/databases/redis/install/cli`, {}, TimeoutEnum.T_5M); return http.get<Database.RedisCliStatus>(`/databases/redis/cli/status?operateNode=${encodeURIComponent(node)}`);
};
export const installRedisCli = (taskID: string, node: string) => {
return http.post<Database.RedisCliStatus>(`/databases/redis/install/cli?operateNode=${encodeURIComponent(node)}`, {
taskID,
});
}; };
export const changeRedisPassword = (database: string, password: string) => { export const changeRedisPassword = (database: string, password: string) => {
if (password) { if (password) {
@@ -16,10 +16,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { useRoute } from 'vue-router';
defineOptions({ name: 'TableSearch' }); defineOptions({ name: 'TableSearch' });
const emit = defineEmits(['search', 'update:searchName']); const emit = defineEmits(['search', 'update:searchName']);
const searchInfo = ref(); const searchInfo = ref();
const route = useRoute();
const props = defineProps({ const props = defineProps({
placeholder: String, placeholder: String,
disabled: { disabled: {
@@ -42,6 +44,16 @@ watch(
{ immediate: true }, { immediate: true },
); );
// Also discard text typed without pressing Enter when leaving a cached tab.
watch(
() => route.path,
() => {
searchInfo.value = '';
emit('update:searchName', '');
},
{ flush: 'sync' },
);
const search = () => { const search = () => {
emit('update:searchName', searchInfo.value); emit('update:searchName', searchInfo.value);
emit('search'); emit('search');
@@ -11,6 +11,7 @@
<fu-table <fu-table
v-if="currentViewMode === 'table'" v-if="currentViewMode === 'table'"
v-bind="$attrs" v-bind="$attrs"
:data="props.data"
ref="tableRef" ref="tableRef"
@select="handleSelect" @select="handleSelect"
@selection-change="handleSelectionChange" @selection-change="handleSelectionChange"
@@ -166,16 +167,19 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, useAttrs } from 'vue'; import { computed, onActivated, onMounted, ref, useAttrs } from 'vue';
import { useRoute } from 'vue-router';
import { useGlobalStore } from '@/composables/useGlobalStore'; import { useGlobalStore } from '@/composables/useGlobalStore';
import { flattenVNodes, type FuTableOperationButton } from '@/components/table/shared'; import { flattenVNodes, type FuTableOperationButton } from '@/components/table/shared';
import { useCardColumns } from './useCardColumns'; import { useCardColumns } from './useCardColumns';
import { useContextMenu } from './useContextMenu'; import { useContextMenu } from './useContextMenu';
import { useResponsivePagination } from './useResponsivePagination'; import { useResponsivePagination } from './useResponsivePagination';
import { useTableSelection } from './useTableSelection'; import { useTableSelection } from './useTableSelection';
import { useTablePageState } from './useTablePageState';
const slots = useSlots(); const slots = useSlots();
const attrs = useAttrs(); const attrs = useAttrs();
const { isMobile, openMenuTabs } = useGlobalStore(); const route = useRoute();
const { isMobile, openMenuTabs, currentNode } = useGlobalStore();
defineOptions({ name: 'ComplexTable' }); defineOptions({ name: 'ComplexTable' });
export type DropdownProps = FuTableOperationButton; export type DropdownProps = FuTableOperationButton;
@@ -184,6 +188,8 @@ type ViewMode = 'table' | 'card';
const props = defineProps({ const props = defineProps({
header: String, header: String,
data: { type: Array as PropType<any[]>, default: () => [] },
selectionContext: { type: [String, Number, Array, Object, Function], default: undefined },
paginationConfig: { paginationConfig: {
type: Object, type: Object,
required: false, required: false,
@@ -239,7 +245,7 @@ const selectionColumn = computed(() =>
columnNodes.value.find((column) => (column.props as Record<string, any> | null)?.type === 'selection'), columnNodes.value.find((column) => (column.props as Record<string, any> | null)?.type === 'selection'),
); );
const tableData = computed(() => { const tableData = computed(() => {
const data = attrs.data; const data = props.data;
return Array.isArray(data) ? data : []; return Array.isArray(data) ? data : [];
}); });
const cardContentStyle = computed(() => { const cardContentStyle = computed(() => {
@@ -295,7 +301,7 @@ const {
isDisabled: isRightButtonDisabled, isDisabled: isRightButtonDisabled,
click: rightButtonClick, click: rightButtonClick,
} = useContextMenu(() => props.rightButtons); } = useContextMenu(() => props.rightButtons);
const getTableData = () => (Array.isArray(attrs.data) ? attrs.data : []); const getTableData = () => props.data || [];
const isRowSelectable = (row: any) => { const isRowSelectable = (row: any) => {
const selectable = (selectionColumn.value?.props as Record<string, any> | null)?.selectable; const selectable = (selectionColumn.value?.props as Record<string, any> | null)?.selectable;
return typeof selectable === 'function' ? selectable(row) : true; return typeof selectable === 'function' ? selectable(row) : true;
@@ -304,7 +310,6 @@ const {
selectedRows, selectedRows,
isRowSelected, isRowSelected,
clearSelects, clearSelects,
pruneSelection,
toggleSelection, toggleSelection,
selectRow, selectRow,
syncTableSelection, syncTableSelection,
@@ -313,7 +318,18 @@ const {
handleRowClick, handleRowClick,
handleKeyDown: handleSelectionKeyDown, handleKeyDown: handleSelectionKeyDown,
handleKeyUp, 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) => { const toggleCardSelection = (row: any, selected: boolean) => {
selectRow(row, selected); selectRow(row, selected);
}; };
@@ -329,10 +345,12 @@ const handleRightClick = (row, column, event) => {
}; };
function currentChange() { function currentChange() {
clearSelects();
emit('search'); emit('search');
} }
function sizeChange() { function sizeChange() {
clearSelects();
props.paginationConfig.currentPage = 1; props.paginationConfig.currentPage = 1;
localStorage.setItem(props.paginationConfig.cacheSizeKey, props.paginationConfig.pageSize); localStorage.setItem(props.paginationConfig.cacheSizeKey, props.paginationConfig.pageSize);
emit('search'); emit('search');
@@ -446,7 +464,22 @@ watch([currentViewMode, tableData, () => props.syncCardContentHeight], scheduleC
flush: 'post', flush: 'post',
}); });
watch(tableData, pruneSelection); useTablePageState(
() => props.paginationConfig,
() => [
route.path,
currentNode.value,
typeof props.selectionContext === 'function' ? props.selectionContext() : props.selectionContext,
],
clearSelects,
);
// Kept-alive tabs do not mount again. Reload their reset query on subsequent visits.
let hasActivated = false;
onActivated(() => {
if (hasActivated) emit('search');
hasActivated = true;
});
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('resize', calcHeight); window.removeEventListener('resize', calcHeight);
@@ -0,0 +1,21 @@
import { watch } from 'vue';
type Pagination = { currentPage?: number; pageSize?: number } | undefined;
/** Refreshing data leaves page state alone; only user navigation or a new query clears selection. */
export const useTablePageState = (
getPagination: () => Pagination,
getContext: () => unknown,
clearSelection: () => void,
) => {
watch(() => [getPagination()?.currentPage, getPagination()?.pageSize], clearSelection, { flush: 'sync' });
watch(
() => JSON.stringify(getContext()),
() => {
clearSelection();
const pagination = getPagination();
if (pagination) pagination.currentPage = 1;
},
{ flush: 'sync' },
);
};
@@ -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; type TableRef = { refElTable?: any } | undefined;
@@ -7,6 +7,7 @@ export const useTableSelection = (
getTableData: () => any[], getTableData: () => any[],
onSelectionChange: (rows: any[]) => void, onSelectionChange: (rows: any[]) => void,
isRowSelectable: (row: any) => boolean = () => true, isRowSelectable: (row: any) => boolean = () => true,
getRowKey: (row: any) => unknown = (row) => row?.id,
) => { ) => {
const selectedRows = shallowRef<any[]>([]); const selectedRows = shallowRef<any[]>([]);
const shiftPressed = ref(false); const shiftPressed = ref(false);
@@ -15,8 +16,13 @@ export const useTableSelection = (
let isSyncingTableSelection = false; let isSyncingTableSelection = false;
let skipNextSelectionChange = false; let skipNextSelectionChange = false;
// Element Plus can return reactive proxies for rows supplied as plain objects. let isRefreshingData = false;
const sameRow = (left: any, right: any) => toRaw(left) === toRaw(right); 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 hasRow = (rows: any[], row: any) => rows.some((item) => sameRow(item, row));
const isRowSelected = (row: any) => hasRow(selectedRows.value, row); const isRowSelected = (row: any) => hasRow(selectedRows.value, row);
const getTable = () => tableRef.value?.refElTable; const getTable = () => tableRef.value?.refElTable;
@@ -39,8 +45,12 @@ export const useTableSelection = (
const tableData = getTableData(); const tableData = getTableData();
const nextRows = selectedRows.value.filter((row) => hasRow(tableData, row) && isRowSelectable(row)); const nextRows = selectedRows.value.filter((row) => hasRow(tableData, row) && isRowSelectable(row));
const currentRows = table.getSelectionRows(); const currentRows = table.getSelectionRows();
currentRows.filter((row) => !hasRow(nextRows, row)).forEach((row) => table.toggleRowSelection(row, false)); currentRows
nextRows.filter((row) => !hasRow(currentRows, row)).forEach((row) => table.toggleRowSelection(row, true)); .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 { } finally {
isSyncingTableSelection = false; isSyncingTableSelection = false;
} }
@@ -73,7 +83,7 @@ export const useTableSelection = (
return true; return true;
}; };
const handleSelectionChange = (rows: any[]) => { const handleSelectionChange = (rows: any[]) => {
if (isSyncingTableSelection) { if (isSyncingTableSelection || isRefreshingData) {
return; return;
} }
if (skipNextSelectionChange) { if (skipNextSelectionChange) {
@@ -104,16 +114,35 @@ export const useTableSelection = (
rangeBaseRows.value = []; rangeBaseRows.value = [];
}; };
const pruneSelection = () => { const pruneSelection = () => {
const nextRows = selectedRows.value.filter((row) => hasRow(getTableData(), row)); const data = getTableData();
if (nextRows.length !== selectedRows.value.length) { 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); setSelectedRows(nextRows);
} }
if (lastSelectedRow.value && !hasRow(nextRows, lastSelectedRow.value)) { lastSelectedRow.value = nextRows.find((row) => sameRow(row, lastSelectedRow.value)) ?? null;
lastSelectedRow.value = null; rangeBaseRows.value = lastSelectedRow.value ? nextRows.filter((row) => hasRow(rangeBaseRows.value, row)) : [];
rangeBaseRows.value = [];
}
syncTableSelection();
}; };
// 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 toggleSelection = () => {
const selectableRows = getTableData().filter(isRowSelectable); const selectableRows = getTableData().filter(isRowSelectable);
const allSelected = selectableRows.length > 0 && selectableRows.every(isRowSelected); const allSelected = selectableRows.length > 0 && selectableRows.every(isRowSelected);
+2 -7
View File
@@ -1,11 +1,6 @@
import { reactive, type UnwrapNestedRefs } from 'vue'; import { reactive, type UnwrapNestedRefs } from 'vue';
import { useRoute } from 'vue-router';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { getPageState } from '@/utils/page-state-cache';
/** A new page visit starts from its defaults instead of restoring the previous page number. */
export const usePageState = <T extends object>(factory: () => T): UnwrapNestedRefs<T> => { export const usePageState = <T extends object>(factory: () => T): UnwrapNestedRefs<T> => {
const route = useRoute(); return reactive(factory());
const { currentNode } = useGlobalStore();
const key = `${currentNode.value}:${String(route.name || route.path)}`;
return reactive(getPageState(key, factory));
}; };
@@ -0,0 +1,14 @@
import { storeToRefs } from 'pinia';
import GlobalStore from '@/store/modules/global';
import router from '@/routers/router';
import { bindSearchReset } from '@/utils/search-reset';
/** Kept as a compatible entry point; searches now reset on tab, node or resource changes. */
export const useSearchPersistence = (
page: string,
fields: Parameters<typeof bindSearchReset>[1],
scope?: () => string | number | undefined,
) => {
const { currentNode } = storeToRefs(GlobalStore());
return bindSearchReset(() => [router.currentRoute.value.path, currentNode.value, page, scope?.()], fields);
};
+10
View File
@@ -739,6 +739,11 @@ const message = {
keyspaceMisses: 'Number of failed attempts to find the database key.', keyspaceMisses: 'Number of failed attempts to find the database key.',
hit: 'Find the database key hit ratio.', hit: 'Find the database key hit ratio.',
latestForkUsec: 'The number of microseconds spent on the last fork() operation.', latestForkUsec: 'The number of microseconds spent on the last fork() operation.',
redisCliEnableTask: 'Enable remote Redis terminal',
redisCliEnabling: 'Enabling remote Redis terminal',
redisCliEnabled: 'Remote Redis terminal enabled',
redisCliEnableFailed: 'Failed to enable remote Redis terminal. Check the task log and retry.',
redisCliTaskSubmitted: 'Task submitted. View progress in the task center.',
redisCliHelper: '"redis-cli" not detected. Enable the service first.', redisCliHelper: '"redis-cli" not detected. Enable the service first.',
redisQuickCmd: 'Redis quick commands', redisQuickCmd: 'Redis quick commands',
recoverHelper: 'This will overwrite data with [{0}]. Continue?', recoverHelper: 'This will overwrite data with [{0}]. Continue?',
@@ -1841,6 +1846,11 @@ const message = {
imageName: 'Image name', imageName: 'Image name',
pull: 'Pull', pull: 'Pull',
path: 'Path', path: 'Path',
imageImportSource: 'Import source',
imageUploadDrop: 'Drop an image file here, or click to upload',
imageUploadHelper:
'Any file format is allowed. Docker validates the contents. Uploaded files are retained for retrying imports or manual deletion.',
imageImportSubmitted: 'Image import task submitted. Check the task log for the result.',
importImage: 'Import', importImage: 'Import',
buildArgs: 'Build Arguments', buildArgs: 'Build Arguments',
imageBuild: 'Build', imageBuild: 'Build',
+9
View File
@@ -709,6 +709,11 @@ const message = {
keyspaceMisses: '尋找資料庫鍵失敗的次數', keyspaceMisses: '尋找資料庫鍵失敗的次數',
hit: '尋找資料庫鍵命中率', hit: '尋找資料庫鍵命中率',
latestForkUsec: '最近一次 fork() 操作耗費的微秒數', latestForkUsec: '最近一次 fork() 操作耗費的微秒數',
redisCliEnableTask: '啟用 Redis 遠端終端',
redisCliEnabling: '正在啟用 Redis 遠端終端',
redisCliEnabled: 'Redis 遠端終端已啟用',
redisCliEnableFailed: 'Redis 遠端終端啟用失敗,請查看任務日誌後重試',
redisCliTaskSubmitted: '啟用任務已提交,可在任務中心查看進度',
redisCliHelper: '未偵測到 redis-cli 服務,請先啟用服務', redisCliHelper: '未偵測到 redis-cli 服務,請先啟用服務',
redisQuickCmd: 'Redis 快速指令', redisQuickCmd: 'Redis 快速指令',
recoverHelper: '即將使用 [{0}] 對資料進行覆蓋,是否繼續?', recoverHelper: '即將使用 [{0}] 對資料進行覆蓋,是否繼續?',
@@ -1746,6 +1751,10 @@ const message = {
delInsecureHelper: '刪除授信需要重新啟動 docker 服務,是否刪除?', delInsecureHelper: '刪除授信需要重新啟動 docker 服務,是否刪除?',
pull: '拉取', pull: '拉取',
path: '路徑', path: '路徑',
imageImportSource: '匯入方式',
imageUploadDrop: '拖曳映像檔案至此處,或點擊上傳',
imageUploadHelper: '不限制檔案格式,由 Docker 驗證內容。上傳的檔案會保留,可重試匯入或手動刪除。',
imageImportSubmitted: '映像匯入任務已提交,請查看任務日誌',
importImage: '匯入映像', importImage: '匯入映像',
imageBuild: '構建映像', imageBuild: '構建映像',
buildArgs: '構建參數', buildArgs: '構建參數',
+9
View File
@@ -722,6 +722,11 @@ const message = {
keyspaceMisses: '查找数据库键失败的次数', keyspaceMisses: '查找数据库键失败的次数',
hit: '查找数据库键命中率', hit: '查找数据库键命中率',
latestForkUsec: '最近一次 fork() 操作耗费的微秒数', latestForkUsec: '最近一次 fork() 操作耗费的微秒数',
redisCliEnableTask: '启用 Redis 远程终端',
redisCliEnabling: '正在启用 Redis 远程终端',
redisCliEnabled: 'Redis 远程终端已启用',
redisCliEnableFailed: 'Redis 远程终端启用失败,请查看任务日志后重试',
redisCliTaskSubmitted: '启用任务已提交,可在任务中心查看进度',
redisCliHelper: '未检测到 redis-cli 服务,请先启用服务!', redisCliHelper: '未检测到 redis-cli 服务,请先启用服务!',
redisQuickCmd: 'Redis 快速命令', redisQuickCmd: 'Redis 快速命令',
@@ -1766,6 +1771,10 @@ const message = {
delInsecureHelper: '删除授信需要重启 docker 服务,是否删除?', delInsecureHelper: '删除授信需要重启 docker 服务,是否删除?',
pull: '拉取', pull: '拉取',
path: '路径', path: '路径',
imageImportSource: '导入方式',
imageUploadDrop: '拖拽镜像文件到此处,或点击上传',
imageUploadHelper: '不限制文件格式,由 Docker 校验内容。上传的文件会保留,可重试导入或手动删除。',
imageImportSubmitted: '镜像导入任务已提交,请查看任务日志',
importImage: '导入镜像', importImage: '导入镜像',
imageBuild: '构建镜像', imageBuild: '构建镜像',
buildArgs: '构建参数', buildArgs: '构建参数',
+13 -1
View File
@@ -1,5 +1,6 @@
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref, reactive } from 'vue'; import { ref, reactive, toRef } from 'vue';
import { MsgError } from '@/utils/message'; import { MsgError } from '@/utils/message';
import { checkStreamAuth } from '@/utils/stream-auth'; import { checkStreamAuth } from '@/utils/stream-auth';
@@ -46,6 +47,17 @@ export const ProcessStore = defineStore('ProcessStore', () => {
port: undefined, 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 pendingRequestType: 'ps' | 'net' | null = null;
let queuedRequestType: 'ps' | 'net' | null = null; let queuedRequestType: 'ps' | 'net' | null = null;
+63
View File
@@ -0,0 +1,63 @@
import { watch, type Ref } from 'vue';
type SearchValue = string | number | undefined;
type SearchFields = Record<string, Ref<SearchValue>>;
export const bindSearchPersistence = (key: () => string | undefined, fields: SearchFields) => {
const defaults = Object.fromEntries(Object.entries(fields).map(([name, field]) => [name, field.value]));
let initialized = false;
let restoring = false;
let activeKey: string | undefined;
const stopRestore = watch(
key,
(value) => {
activeKey = value ? `1panel:search:v1:${value}` : undefined;
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 {
// Invalid or unavailable storage must not prevent a page from loading.
}
restoring = true;
for (const [name, field] of Object.entries(fields)) {
const value = saved[name];
const initial = defaults[name];
const fallback = initialized ? (typeof initial === 'string' ? '' : undefined) : initial;
const valid =
(typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value))) &&
(fallback === undefined || typeof value === typeof fallback);
field.value =
value === null
? typeof initial === 'string'
? ''
: undefined
: valid
? (value as SearchValue)
: fallback;
}
restoring = false;
initialized = true;
},
{ immediate: true, flush: 'sync' },
);
const stopSave = 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 {
// Search remains usable when browser storage is disabled or full.
}
},
{ flush: 'sync' },
);
return () => {
stopRestore();
stopSave();
};
};
+17
View File
@@ -0,0 +1,17 @@
import { watch, type Ref } from 'vue';
type SearchFields = Record<string, Ref<string | number | undefined>>;
/** Search terms belong to the current tab visit, not browser storage or cached pages. */
export const bindSearchReset = (context: () => unknown, fields: SearchFields) => {
const emptyValues = Object.fromEntries(
Object.entries(fields).map(([name, field]) => [name, typeof field.value === 'string' ? '' : undefined]),
);
return watch(
() => JSON.stringify(context()),
() => {
for (const [name, field] of Object.entries(fields)) field.value = emptyValues[name];
},
{ immediate: true, flush: 'sync' },
);
};
+1
View File
@@ -24,6 +24,7 @@ const formatSkillInstallTask = () => {
}; };
const taskTextMap: Record<string, () => string> = { const taskTextMap: Record<string, () => string> = {
RedisCliEnable: () => i18n.global.t('database.redisCliEnableTask'),
BatchInstallAgent: () => formatBatchAgentTask('Install'), BatchInstallAgent: () => formatBatchAgentTask('Install'),
DispatchAgentInstallTasks: () => formatDispatchAgentTask('Install'), DispatchAgentInstallTasks: () => formatDispatchAgentTask('Install'),
BatchUpgradeAgent: () => formatBatchAgentTask('Upgrade'), BatchUpgradeAgent: () => formatBatchAgentTask('Upgrade'),
@@ -172,6 +172,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { Refresh, Search } from '@element-plus/icons-vue'; import { Refresh, Search } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus'; import { ElMessageBox } from 'element-plus';
@@ -192,6 +193,11 @@ const searching = ref(false);
const operating = ref(''); const operating = ref('');
const keyword = ref(''); const keyword = ref('');
const marketKeyword = ref(''); const marketKeyword = ref('');
useSearchPersistence(
'ai/agents/agent/config/tabs/plugins',
{ keyword, marketKeyword },
() => agentId.value || undefined,
);
const origin = ref(''); const origin = ref('');
const status = ref(''); const status = ref('');
const page = ref(1); const page = ref(1);
@@ -299,8 +305,6 @@ async function operate(plugin: AI.AgentPluginItem, action: PluginOperate) {
async function load(id: number) { async function load(id: number) {
agentId.value = id; agentId.value = id;
mode.value = 'installed'; mode.value = 'installed';
keyword.value = '';
marketKeyword.value = '';
origin.value = ''; origin.value = '';
status.value = ''; status.value = '';
page.value = 1; page.value = 1;
@@ -138,6 +138,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { Refresh, Search } from '@element-plus/icons-vue'; import { Refresh, Search } from '@element-plus/icons-vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@@ -168,6 +169,11 @@ const getDefaultMarketSource = (): HermesSkillMarketSource => {
const marketSource = ref<HermesSkillMarketSource>(getDefaultMarketSource()); const marketSource = ref<HermesSkillMarketSource>(getDefaultMarketSource());
const marketSearched = ref(false); const marketSearched = ref(false);
const agentId = ref(0); const agentId = ref(0);
useSearchPersistence(
'ai/agents/agent/config/tabs/skills/hermes',
{ installedKeyword, marketKeyword },
() => agentId.value || undefined,
);
const installedSkills = ref<AI.AgentSkillItem[]>([]); const installedSkills = ref<AI.AgentSkillItem[]>([]);
const marketResults = ref<AI.AgentSkillSearchItem[]>([]); const marketResults = ref<AI.AgentSkillSearchItem[]>([]);
const installingSkill = ref(''); const installingSkill = ref('');
@@ -315,8 +321,6 @@ const handleModeChange = async (value: SkillViewMode) => {
const load = async (id: number) => { const load = async (id: number) => {
agentId.value = id; agentId.value = id;
mode.value = 'market'; mode.value = 'market';
installedKeyword.value = '';
marketKeyword.value = '';
marketSource.value = getDefaultMarketSource(); marketSource.value = getDefaultMarketSource();
marketSearched.value = false; marketSearched.value = false;
installedSkills.value = []; installedSkills.value = [];
@@ -142,6 +142,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { Refresh, Search } from '@element-plus/icons-vue'; import { Refresh, Search } from '@element-plus/icons-vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@@ -180,6 +181,11 @@ const getDefaultMarketSource = (): SkillMarketSource => {
const marketSource = ref<SkillMarketSource>(getDefaultMarketSource()); const marketSource = ref<SkillMarketSource>(getDefaultMarketSource());
const marketSearched = ref(false); const marketSearched = ref(false);
const agentId = ref(0); const agentId = ref(0);
useSearchPersistence(
'ai/agents/agent/config/tabs/skills/openclaw',
{ installedKeyword, marketKeyword },
() => agentId.value || undefined,
);
const skills = ref<AI.AgentSkillItem[]>([]); const skills = ref<AI.AgentSkillItem[]>([]);
const marketResults = ref<AI.AgentSkillSearchItem[]>([]); const marketResults = ref<AI.AgentSkillSearchItem[]>([]);
const updatingSkill = ref(''); const updatingSkill = ref('');
@@ -272,6 +272,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { defineAsyncComponent, onMounted, reactive, ref } from 'vue'; import { defineAsyncComponent, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { deleteAgentCheck, pageAgents, resetAgentToken, unbindAgentWebsite, updateAgentRemark } from '@/api/modules/ai'; import { deleteAgentCheck, pageAgents, resetAgentToken, unbindAgentWebsite, updateAgentRemark } from '@/api/modules/ai';
@@ -340,6 +341,7 @@ const isActive = ref(false);
const isExist = ref(false); const isExist = ref(false);
const noApp = ref(false); const noApp = ref(false);
const searchName = ref(''); const searchName = ref('');
useSearchPersistence('ai/agents/agent/index', { search: searchName });
const defaultHttpsPort = ref(443); const defaultHttpsPort = ref(443);
const openrestyPortLoaded = ref(false); const openrestyPortLoaded = ref(false);
const websiteDomainsMap = ref<Record<number, Website.Domain[]>>({}); const websiteDomainsMap = ref<Record<number, Website.Domain[]>>({});
@@ -69,6 +69,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { onMounted, reactive, ref } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import { deleteAgentAccount, getAgentProviders, pageAgentAccounts } from '@/api/modules/ai'; import { deleteAgentAccount, getAgentProviders, pageAgentAccounts } from '@/api/modules/ai';
import { AI } from '@/api/interface/ai'; import { AI } from '@/api/interface/ai';
@@ -85,6 +86,7 @@ const items = ref<AI.AgentAccountItem[]>([]);
const addRef = ref(); const addRef = ref();
const modelPoolRef = ref(); const modelPoolRef = ref();
const searchName = ref(''); const searchName = ref('');
useSearchPersistence('ai/agents/model/index', { search: searchName });
const apiType = ref(''); const apiType = ref('');
const apiTypeOptions = ref<string[]>([]); const apiTypeOptions = ref<string[]>([]);
@@ -65,6 +65,8 @@
</template> </template>
<template #main> <template #main>
<ComplexTable <ComplexTable
:selection-context="() => [searchName]"
row-key="id"
:pagination-config="paginationConfig" :pagination-config="paginationConfig"
v-model:selects="selects" v-model:selects="selects"
:class="{ mask: maskShow }" :class="{ mask: maskShow }"
@@ -184,6 +186,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import AppStatus from '@/components/app-status/index.vue'; import AppStatus from '@/components/app-status/index.vue';
import AddDialog from '@/views/ai/model/ollama/add/index.vue'; import AddDialog from '@/views/ai/model/ollama/add/index.vue';
import Conn from '@/views/ai/model/ollama/conn/index.vue'; import Conn from '@/views/ai/model/ollama/conn/index.vue';
@@ -234,6 +237,7 @@ const paginationConfig = reactive({
total: 0, total: 0,
}); });
const searchName = ref(); const searchName = ref();
useSearchPersistence('ai/model/ollama/index', { search: searchName });
const appInstallID = ref(0); const appInstallID = ref(0);
const opRef = ref(); const opRef = ref();
+3 -1
View File
@@ -70,8 +70,9 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { App } from '@/api/interface/app'; 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 { searchApp, syncApp, syncCutomAppStore, syncLocalApp, getCurrentNodeCustomAppConfig } from '@/api/modules/app';
import Install from '../detail/install/index.vue'; import Install from '../detail/install/index.vue';
import router from '@/routers'; import router from '@/routers';
@@ -104,6 +105,7 @@ const req = reactive({
resource: 'all', resource: 'all',
showCurrentArch: false, showCurrentArch: false,
}); });
useSearchPersistence('app-store/apps/index', { search: toRef(req, 'name') });
const apps = ref<App.AppItem[]>([]); const apps = ref<App.AppItem[]>([]);
const loading = ref(false); const loading = ref(false);
@@ -139,6 +139,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import AppCard from '@/views/app-store/installed/app/card.vue'; import AppCard from '@/views/app-store/installed/app/card.vue';
import Backups from '@/components/backup/index.vue'; import Backups from '@/components/backup/index.vue';
import Uploads from '@/components/upload/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 TerminalDialog from '@/views/container/container/terminal/index.vue';
import { searchAppInstalled, installedOp, appInstalledDeleteCheck, updateAppInstallSort } from '@/api/modules/app'; 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 Sortable from 'sortablejs';
import i18n from '@/lang'; import i18n from '@/lang';
import { ElMessageBox } from 'element-plus'; import { ElMessageBox } from 'element-plus';
@@ -210,6 +211,7 @@ const searchReq = reactive({
update: false, update: false,
sync: false, sync: false,
}); });
useSearchPersistence('app-store/installed/index', { search: toRef(searchReq, 'name') });
const router = useRouter(); const router = useRouter();
const activeName = ref(i18n.global.t('app.installed')); const activeName = ref(i18n.global.t('app.installed'));
const mode = ref('installed'); const mode = ref('installed');
@@ -521,6 +521,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { computed, h, ref } from 'vue'; import { computed, h, ref } from 'vue';
import CodemirrorPro from '@/components/codemirror-pro/index.vue'; import CodemirrorPro from '@/components/codemirror-pro/index.vue';
import ContainerLog from '@/components/log/container/index.vue'; import ContainerLog from '@/components/log/container/index.vue';
@@ -575,6 +576,7 @@ const dialogBackupRef = ref();
const uploadRef = ref(); const uploadRef = ref();
const searchName = ref(''); const searchName = ref('');
useSearchPersistence('container/compose/index', { search: searchName });
const includeAppStore = ref(localStorage.getItem('includeAppStore') !== 'false'); const includeAppStore = ref(localStorage.getItem('includeAppStore') !== 'false');
const showType = ref('compose'); const showType = ref('compose');
const containerStats = ref<any[]>([]); const containerStats = ref<any[]>([]);
@@ -56,6 +56,7 @@
</template> </template>
<template #main> <template #main>
<ComplexTable <ComplexTable
:selection-context="() => [searchName, activeTag, includeAppStore, props.filters]"
:pagination-config="paginationConfig" :pagination-config="paginationConfig"
v-model:view-mode="viewMode" v-model:view-mode="viewMode"
v-model:selects="selects" v-model:selects="selects"
@@ -473,6 +474,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import PruneDialog from '@/views/container/container/prune/index.vue'; import PruneDialog from '@/views/container/container/prune/index.vue';
import RenameDialog from '@/views/container/container/rename/index.vue'; import RenameDialog from '@/views/container/container/rename/index.vue';
import UpgradeDialog from '@/views/container/container/upgrade/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 DockerStatus from '@/views/container/docker-status/index.vue';
import ContainerLogDialog from '@/components/log/container-drawer/index.vue'; import ContainerLogDialog from '@/components/log/container-drawer/index.vue';
import Status from '@/components/status/index.vue'; import Status from '@/components/status/index.vue';
import { computed, reactive, ref } from 'vue'; import { computed, onBeforeUnmount, reactive, ref } from 'vue';
import { import {
containerItemStats, containerItemStats,
containerListStats, containerListStats,
@@ -534,6 +536,7 @@ const paginationConfig = reactive({
order: 'ascending', order: 'ascending',
}); });
const searchName = ref(); const searchName = ref();
useSearchPersistence('container/container/index', { search: searchName });
const dialogUpgradeRef = ref(); const dialogUpgradeRef = ref();
const dialogCommitRef = ref(); const dialogCommitRef = ref();
const dialogPortJumpRef = ref(); const dialogPortJumpRef = ref();
@@ -700,47 +703,87 @@ const dialogContainerLogRef = ref();
const dialogRenameRef = ref(); const dialogRenameRef = ref();
const dialogPruneRef = ref(); const dialogPruneRef = ref();
const search = async (column?: any) => { let requestController: AbortController | undefined;
if (!isActive.value || !isExist.value) { 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; 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'); 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.orderBy = column?.order ? column.prop : paginationConfig.orderBy;
paginationConfig.order = column?.order ? column.order : paginationConfig.order; paginationConfig.order = column?.order ? column.order : paginationConfig.order;
let params = { const params = {
name: searchName.value, name: searchName.value,
state: paginationConfig.state || 'all', state: paginationConfig.state || 'all',
page: paginationConfig.currentPage, page: paginationConfig.currentPage,
pageSize: paginationConfig.pageSize, pageSize: paginationConfig.pageSize,
filters: filterItem, filters: props.filters || (router.currentRoute.value.query?.filters as string) || '',
orderBy: paginationConfig.orderBy, orderBy: paginationConfig.orderBy,
order: paginationConfig.order, order: paginationConfig.order,
excludeAppStore: !includeAppStore.value, excludeAppStore: !includeAppStore.value,
}; };
loading.value = true; // Counts and resource samples must never hold up the table itself.
const [containerResult, statsResult, statusResult] = await Promise.allSettled([ const statusRequest = loadContainerStatus(undefined, true, controller.signal)
searchContainer(params), .then((res) => {
containerListStats(), if (isCurrent()) {
loadContainerStatus(), updateTags(res.data || {});
]); }
loading.value = false; })
.catch(() => {});
if (containerResult.status === 'fulfilled') { try {
const containers = containerResult.value.data.items || []; const res = await searchContainer(params, undefined, controller.signal);
if (!isCurrent()) {
return;
}
const containers = res.data.items || [];
syncContainerRows(containers); syncContainerRows(containers);
paginationConfig.total = containerResult.value.data.total; 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 || []);
} }
if (statsResult.status === 'fulfilled') {
applyStatsToRows(statsResult.value.data || []);
} }
} catch {
if (statusResult.status === 'fulfilled') { // The HTTP layer reports failures; keep the last successful rows visible.
updateTags(statusResult.value.data || {}); } finally {
if (isCurrent()) {
loading.value = false;
}
await statusRequest;
if (isCurrent()) {
refreshPending = false;
}
} }
}; };
const search = (column?: any) => loadContainers(false, column);
const searchWithStatus = (item: string) => { const searchWithStatus = (item: string) => {
activeTag.value = item; activeTag.value = item;
paginationConfig.state = activeTag.value; paginationConfig.state = activeTag.value;
@@ -769,21 +812,7 @@ const changePinned = (row: any, isPinned: boolean) => {
}); });
}; };
const refresh = async () => { const refresh = () => loadContainers(true);
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 loadSize = async (row: any) => { const loadSize = async (row: any) => {
containerItemStats(row.containerID).then((res) => { containerItemStats(row.containerID).then((res) => {
+3 -1
View File
@@ -157,7 +157,8 @@
</template> </template>
<script lang="ts" setup> <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 { dateFormat } from '@/utils/date';
import { newUUID } from '@/utils/id'; import { newUUID } from '@/utils/id';
import { computeSize2 } from '@/utils/size'; import { computeSize2 } from '@/utils/size';
@@ -199,6 +200,7 @@ const paginationConfig = reactive({
orderBy: 'createdAt', orderBy: 'createdAt',
order: 'null', order: 'null',
}); });
useSearchPersistence('container/image/index', { search: toRef(paginationConfig, 'name') });
const columns = ref([]); const columns = ref([]);
const isActive = ref(false); const isActive = ref(false);
+258 -69
View File
@@ -1,105 +1,294 @@
<template> <template>
<DrawerPro v-model="loadVisible" :header="$t('container.importImage')" @close="handleClose" size="small"> <DrawerPro
<el-form @submit.prevent v-loading="loading" ref="formRef" :model="form" label-position="top"> v-model="loadVisible"
<el-form-item :label="$t('container.path')" :rules="Rules.requiredInput" prop="path"> :header="$t('container.importImage')"
<el-input v-model="form.path" @input="handlePathChange"> :auto-close="!busy"
<template #prepend> :confirm-before-close="true"
<el-button icon="Folder" @click="fileRef.acceptParams({ dir: false, multiple: true })" /> @before-close="beforeClose"
</template> size="small"
</el-input> >
<el-form @submit.prevent label-position="top">
<div class="flex items-center gap-3 mb-5">
<el-upload
ref="uploadRef"
:limit="1"
:auto-upload="false"
:show-file-list="false"
:disabled="busy || !!pendingTaskID"
:on-change="onFileChange"
:on-exceed="onFileExceed"
>
<el-button :disabled="busy || !!pendingTaskID" @click="source = 'local'">
{{ $t('database.localUpload') }}
</el-button>
</el-upload>
<el-button :disabled="busy" @click="onServerSelect">
{{ $t('database.hostSelect') }}
</el-button>
</div>
<el-form-item v-if="source === 'server'" :label="$t('container.path')">
<el-input v-model="form.path" :disabled="busy" @input="handlePathChange" />
</el-form-item> </el-form-item>
<template v-else>
<el-upload
v-if="!uploadedPath"
ref="dragUploadRef"
drag
:limit="1"
:auto-upload="false"
:show-file-list="false"
:disabled="busy || !!pendingTaskID"
:on-change="onFileChange"
:on-exceed="onFileExceed"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">{{ $t('container.imageUploadDrop') }}</div>
</el-upload>
<p class="input-help">{{ $t('container.imageUploadHelper') }}</p>
<template v-if="localFile">
<p class="break-all">{{ localFile.name }} ({{ computeSize(localFile.size) }})</p>
<el-progress
:percentage="uploadPercent"
:status="uploadFailed ? 'exception' : uploadedPath ? 'success' : undefined"
>
<template v-if="uploadFailed" #default>
<el-button
link
type="danger"
icon="CircleClose"
:title="$t('commons.button.delete')"
:aria-label="$t('commons.button.delete')"
:disabled="busy || !!pendingTaskID"
@click="removeUpload"
/>
</template>
</el-progress>
<p v-if="uploadedPath" class="input-help break-all">{{ uploadedPath }}</p>
<el-button v-if="uploadFailed" :disabled="busy" @click="uploadLocalFile">
{{ $t('commons.button.retry') }}
</el-button>
<el-button v-if="uploadedPath" :disabled="busy || !!pendingTaskID" @click="removeUpload">
{{ $t('commons.button.delete') }}
</el-button>
</template>
</template>
</el-form> </el-form>
<template #footer> <template #footer>
<span class="dialog-footer"> <el-button :disabled="busy" @click="loadVisible = false">{{ $t('commons.button.cancel') }}</el-button>
<el-button :disabled="loading" @click="loadVisible = false"> <el-button :disabled="!canImport" :loading="loading" type="primary" @click="onSubmit">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button
:disabled="loading || !form.path.trim() || form.paths.length === 0"
type="primary"
@click="onSubmit(formRef)"
>
{{ $t('commons.button.import') }} {{ $t('commons.button.import') }}
</el-button> </el-button>
</span>
</template> </template>
</DrawerPro> </DrawerPro>
<FileList ref="fileRef" @choose="loadLoadDir" /> <FileList v-if="loadVisible" ref="fileRef" @choose="loadLoadDir" />
<TaskLog ref="taskLogRef" width="70%" /> <TaskLog ref="taskLogRef" width="70%" @close="emit('search')" />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import FileList from '@/components/file-list/index.vue'; import FileList from '@/components/file-list/index.vue';
import { reactive, ref } from 'vue'; import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
import { Rules } from '@/global/form-rules'; import { genFileId, type UploadFile, type UploadInstance, type UploadProps, type UploadRawFile } from 'element-plus';
import TaskLog from '@/components/log/task/index.vue'; import TaskLog from '@/components/log/task/index.vue';
import i18n from '@/lang'; import i18n from '@/lang';
import { ElForm } from 'element-plus';
import { imageLoad } from '@/api/modules/container'; import { imageLoad } from '@/api/modules/container';
import { chunkUploadFileData, deleteFileByNode, stopChunkUpload } from '@/api/modules/files';
import { searchTasks } from '@/api/modules/log';
import { loadBaseDir } from '@/api/modules/setting';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { MsgSuccess } from '@/utils/message'; import { MsgSuccess } from '@/utils/message';
import { computeSize } from '@/utils/size';
import { newUUID } from '@/utils/id'; import { newUUID } from '@/utils/id';
const { currentNode } = useGlobalStore();
const loading = ref(false); const loading = ref(false);
const uploading = ref(false);
const busy = computed(() => loading.value || uploading.value);
const fileRef = ref(); const fileRef = ref();
const taskLogRef = ref(); const taskLogRef = ref();
const uploadRef = ref<UploadInstance>();
const dragUploadRef = ref<UploadInstance>();
const loadVisible = ref(false); const loadVisible = ref(false);
const form = reactive({ const source = ref<'server' | 'local'>('local');
path: '', const operateNode = ref('');
paths: [] as string[], const form = reactive({ path: '', paths: [] as string[] });
taskID: '', const localFile = ref<UploadRawFile>();
}); const uploadPercent = ref(0);
const uploadFailed = ref(false);
const uploadedPath = ref('');
const uploadDir = ref('');
const pendingTaskID = ref('');
let uploadID = '';
let uploadController: AbortController | undefined;
let taskTimer: ReturnType<typeof setTimeout> | undefined;
let disposed = false;
const emit = defineEmits<{ (e: 'search'): void }>();
const canImport = computed(
() =>
!busy.value && !pendingTaskID.value && (source.value === 'local' ? !!uploadedPath.value : !!form.paths.length),
);
const resetLocalFile = () => {
localFile.value = undefined;
uploadedPath.value = '';
uploadDir.value = '';
uploadPercent.value = 0;
uploadFailed.value = false;
uploadRef.value?.clearFiles();
dragUploadRef.value?.clearFiles();
};
const acceptParams = () => { const acceptParams = () => {
loadVisible.value = true; if (busy.value) return;
if (operateNode.value !== currentNode.value) resetLocalFile();
operateNode.value = currentNode.value;
source.value = 'local';
form.path = ''; form.path = '';
form.paths = []; form.paths = [];
loadVisible.value = true;
}; };
const handleClose = () => { const beforeClose = (done: () => void) => {
loadVisible.value = false; if (!busy.value) done();
};
const onServerSelect = () => {
if (busy.value) return;
source.value = 'server';
fileRef.value.acceptParams({ dir: false, multiple: true });
};
const onFileChange = (file: UploadFile) => {
if (!file.raw || busy.value || pendingTaskID.value) return;
source.value = 'local';
uploadedPath.value = '';
uploadDir.value = '';
localFile.value = file.raw;
void uploadLocalFile();
};
const onFileExceed: UploadProps['onExceed'] = (files) => {
if (busy.value || pendingTaskID.value || !files.length) return;
uploadRef.value?.clearFiles();
dragUploadRef.value?.clearFiles();
const file = files[0] as UploadRawFile;
file.uid = genFileId();
uploadRef.value?.handleStart(file);
}; };
const emit = defineEmits<{ (e: 'search'): void }>(); const uploadLocalFile = async () => {
const file = localFile.value;
type FormInstance = InstanceType<typeof ElForm>; if (!file || busy.value) return;
const formRef = ref<FormInstance>(); uploading.value = true;
uploadFailed.value = false;
const onSubmit = async (formEl: FormInstance | undefined) => { uploadPercent.value = 0;
if (!formEl || !form.path.trim() || form.paths.length === 0) return; const node = operateNode.value;
formEl.validate(async (valid) => { const id = newUUID();
if (!valid) return; uploadID = id;
loading.value = true; const controller = new AbortController();
form.taskID = newUUID(); uploadController = controller;
await imageLoad({ paths: form.paths, taskID: form.taskID }) try {
.then(() => { const { data: baseDir } = await loadBaseDir(node);
loading.value = false; if (controller.signal.aborted) return;
loadVisible.value = false; const directory = `${baseDir}/uploads/image/${id}`;
openTaskLog(form.taskID); const chunkSize = 1024 * 1024;
emit('search'); // Empty files also reach Docker, which validates the archive contents.
MsgSuccess(i18n.global.t('commons.msg.operationSuccess')); const count = Math.max(1, Math.ceil(file.size / chunkSize));
}) for (let index = 0; index < count; index++) {
.catch(() => { const data = new FormData();
loading.value = false; data.append('filename', file.name);
data.append('path', directory);
data.append('uploadID', id);
data.append('chunk', file.slice(index * chunkSize, (index + 1) * chunkSize));
data.append('chunkIndex', String(index));
data.append('chunkCount', String(count));
await chunkUploadFileData(data, {
headers: { CurrentNode: node },
signal: controller.signal,
onUploadProgress: (event) => {
const fraction = event.total ? Math.min(1, event.loaded / event.total) : 0;
uploadPercent.value = Math.min(99, Math.floor(((index + fraction) * 100) / count));
},
}); });
}); }
}; uploadDir.value = directory;
uploadedPath.value = `${directory}/${file.name}`;
const openTaskLog = (taskID: string) => { uploadPercent.value = 100;
taskLogRef.value.openWithTaskID(taskID); MsgSuccess(i18n.global.t('file.uploadSuccess'));
}; } catch {
if (!controller.signal.aborted) uploadFailed.value = true;
const handlePathChange = () => { await stopChunkUpload(id, node).catch(() => {});
if (!form.path.trim()) { } finally {
form.paths = []; uploading.value = false;
uploadController = undefined;
uploadID = '';
} }
}; };
const loadLoadDir = async (paths: string | string[]) => { const removeUpload = async () => {
const newPaths = Array.isArray(paths) ? paths : [paths]; if (busy.value || pendingTaskID.value || !localFile.value) return;
form.paths = [...new Set(newPaths)]; loading.value = true;
form.path = form.paths.join('; '); try {
if (uploadDir.value) {
await deleteFileByNode({ path: uploadDir.value, isDir: true, forceDelete: true }, operateNode.value);
}
resetLocalFile();
source.value = 'local';
form.path = '';
form.paths = [];
} finally {
loading.value = false;
}
}; };
defineExpose({ const pollTask = async (taskID: string, node: string) => {
acceptParams, try {
const { data } = await searchTasks({ page: 1, pageSize: 1, type: '', status: '', taskID }, node);
const status = data.items?.[0]?.status;
if (status === 'Success' || status === 'Failed' || status === 'Canceled') {
pendingTaskID.value = '';
if (currentNode.value === node) emit('search');
return;
}
} catch {
// Keep the upload protected while the task status is unknown.
}
if (!disposed) taskTimer = setTimeout(() => void pollTask(taskID, node), 3000);
};
const onSubmit = async () => {
if (!canImport.value) return;
loading.value = true;
const taskID = newUUID();
const node = operateNode.value;
try {
await imageLoad({ paths: source.value === 'local' ? [uploadedPath.value] : form.paths, taskID }, node);
pendingTaskID.value = taskID;
loadVisible.value = false;
taskLogRef.value.openWithTaskID(taskID, true, node);
MsgSuccess(i18n.global.t('container.imageImportSubmitted'));
void pollTask(taskID, node);
} finally {
loading.value = false;
}
};
const handlePathChange = () => {
form.paths = [
...new Set(
form.path
.split(';')
.map((path) => path.trim())
.filter(Boolean),
),
];
};
const loadLoadDir = (paths: string | string[]) => {
if (currentNode.value !== operateNode.value) return;
source.value = 'server';
form.paths = [...new Set(Array.isArray(paths) ? paths : [paths])];
form.path = form.paths.join('; ');
};
watch(currentNode, () => {
loadVisible.value = false;
}); });
onBeforeUnmount(() => {
disposed = true;
clearTimeout(taskTimer);
uploadController?.abort();
if (uploadID) void stopChunkUpload(uploadID, operateNode.value).catch(() => {});
});
defineExpose({ acceptParams });
</script> </script>
+8 -10
View File
@@ -26,6 +26,8 @@
</template> </template>
<template #main> <template #main>
<ComplexTable <ComplexTable
:selection-context="() => [searchName]"
row-key="id"
:pagination-config="paginationConfig" :pagination-config="paginationConfig"
v-model:selects="selects" v-model:selects="selects"
:data="data" :data="data"
@@ -93,12 +95,12 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import CreateDialog from '@/views/container/network/create/index.vue'; import CreateDialog from '@/views/container/network/create/index.vue';
import DetailDrawer from '@/views/container/network/detail/index.vue'; import DetailDrawer from '@/views/container/network/detail/index.vue';
import { reactive, ref } from 'vue'; import { reactive, ref } from 'vue';
import { dateFormat } from '@/utils/date'; import { dateFormat } from '@/utils/date';
import { newUUID } from '@/utils/id'; import { deleteNetwork, searchNetwork, inspect, cleanNetworks } from '@/api/modules/container';
import { deleteNetwork, searchNetwork, inspect, containerPrune } from '@/api/modules/container';
import { Container } from '@/api/interface/container'; import { Container } from '@/api/interface/container';
import TaskLog from '@/components/log/task/index.vue'; import TaskLog from '@/components/log/task/index.vue';
import i18n from '@/lang'; import i18n from '@/lang';
@@ -118,6 +120,7 @@ const paginationConfig = reactive({
total: 0, total: 0,
}); });
const searchName = ref(); const searchName = ref();
useSearchPersistence('container/network/index', { search: searchName });
const opRef = ref(); const opRef = ref();
const isActive = ref(false); const isActive = ref(false);
@@ -138,15 +141,10 @@ const onClean = () => {
type: 'info', type: 'info',
}).then(async () => { }).then(async () => {
loading.value = true; loading.value = true;
let params = { await cleanNetworks()
taskID: newUUID(), .then((res) => {
pruneType: 'network',
withTagAll: false,
};
await containerPrune(params)
.then(() => {
loading.value = false; loading.value = false;
openTaskLog(params.taskID); openTaskLog(res.data.taskID);
}) })
.catch(() => { .catch(() => {
loading.value = false; loading.value = false;
@@ -59,6 +59,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import OperatorDialog from '@/views/container/repo/operator/index.vue'; import OperatorDialog from '@/views/container/repo/operator/index.vue';
import { reactive, ref } from 'vue'; import { reactive, ref } from 'vue';
import { dateFormat } from '@/utils/date'; import { dateFormat } from '@/utils/date';
@@ -77,6 +78,7 @@ const paginationConfig = reactive({
total: 0, total: 0,
}); });
const searchName = ref(); const searchName = ref();
useSearchPersistence('container/repo/index', { search: searchName });
const opRef = ref(); const opRef = ref();
const confirmDialog = ref(); const confirmDialog = ref();
@@ -38,6 +38,8 @@
</template> </template>
<template #main> <template #main>
<ComplexTable <ComplexTable
:selection-context="() => [searchName]"
row-key="id"
:pagination-config="paginationConfig" :pagination-config="paginationConfig"
v-model:selects="selects" v-model:selects="selects"
:data="data" :data="data"
@@ -79,6 +81,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { reactive, ref } from 'vue'; import { reactive, ref } from 'vue';
import { dateFormat, getCurrentDateFormatted } from '@/utils/date'; import { dateFormat, getCurrentDateFormatted } from '@/utils/date';
import { downloadWithContent } from '@/utils/file'; import { downloadWithContent } from '@/utils/file';
@@ -108,6 +111,7 @@ const paginationConfig = reactive({
total: 0, total: 0,
}); });
const searchName = ref(); const searchName = ref();
useSearchPersistence('container/template/index', { search: searchName });
const search = async () => { const search = async () => {
if (!isActive.value || !isExist.value) { if (!isActive.value || !isExist.value) {
@@ -26,6 +26,8 @@
</template> </template>
<template #main> <template #main>
<ComplexTable <ComplexTable
:selection-context="() => [searchName]"
row-key="name"
:pagination-config="paginationConfig" :pagination-config="paginationConfig"
v-model:selects="selects" v-model:selects="selects"
:data="data" :data="data"
@@ -122,6 +124,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import CreateDialog from '@/views/container/volume/create/index.vue'; import CreateDialog from '@/views/container/volume/create/index.vue';
import CodemirrorDrawer from '@/components/codemirror-pro/drawer.vue'; import CodemirrorDrawer from '@/components/codemirror-pro/drawer.vue';
import DockerStatus from '@/views/container/docker-status/index.vue'; import DockerStatus from '@/views/container/docker-status/index.vue';
@@ -155,6 +158,7 @@ const paginationConfig = reactive({
total: 0, total: 0,
}); });
const searchName = ref(); const searchName = ref();
useSearchPersistence('container/volume/index', { search: searchName });
const isActive = ref(false); const isActive = ref(false);
const isExist = ref(false); const isExist = ref(false);
@@ -60,6 +60,8 @@
</template> </template>
<template #main> <template #main>
<ComplexTable <ComplexTable
:selection-context="() => [searchName, searchGroupID]"
row-key="id"
:pagination-config="paginationConfig" :pagination-config="paginationConfig"
:default-sort=" :default-sort="
paginationConfig.order !== 'null' paginationConfig.order !== 'null'
@@ -270,6 +272,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import Records from '@/views/cronjob/cronjob/record/index.vue'; import Records from '@/views/cronjob/cronjob/record/index.vue';
import Backups from '@/views/cronjob/cronjob/backup/index.vue'; import Backups from '@/views/cronjob/cronjob/backup/index.vue';
import Import from '@/views/cronjob/cronjob/import/index.vue'; import Import from '@/views/cronjob/cronjob/import/index.vue';
@@ -329,6 +332,7 @@ const pageState = usePageState(() => ({
})); }));
const paginationConfig = pageState.paginationConfig; const paginationConfig = pageState.paginationConfig;
const { defaultGroupID, searchName, searchGroupID } = toRefs(pageState); const { defaultGroupID, searchName, searchGroupID } = toRefs(pageState);
useSearchPersistence('cronjob/cronjob/index', { search: searchName });
const search = async (column?: any) => { const search = async (column?: any) => {
if (column) { if (column) {
@@ -60,6 +60,8 @@
</template> </template>
<template #main> <template #main>
<ComplexTable <ComplexTable
:selection-context="() => [searchInfo, group]"
row-key="id"
v-model:selects="selects" v-model:selects="selects"
:pagination-config="paginationConfig" :pagination-config="paginationConfig"
:data="data" :data="data"
@@ -130,6 +132,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { dateFormat, getCurrentDateFormatted } from '@/utils/date'; import { dateFormat, getCurrentDateFormatted } from '@/utils/date';
import { deepCopy } from '@/utils/misc'; import { deepCopy } from '@/utils/misc';
import { newUUID } from '@/utils/id'; import { newUUID } from '@/utils/id';
@@ -167,6 +170,7 @@ const paginationConfig = reactive({
total: 0, total: 0,
}); });
const searchInfo = ref<string>(''); const searchInfo = ref<string>('');
useSearchPersistence('cronjob/library/index', { search: searchInfo });
const group = ref<string>(''); const group = ref<string>('');
const groupOptions = ref(); const groupOptions = ref();
@@ -323,6 +323,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { nextTick, onMounted, reactive, ref } from 'vue'; import { nextTick, onMounted, reactive, ref } from 'vue';
import { dateFormat } from '@/utils/date'; import { dateFormat } from '@/utils/date';
import { getRandomStr } from '@/utils/id'; import { getRandomStr } from '@/utils/id';
@@ -365,6 +366,7 @@ const loading = ref(false);
const maskShow = ref(true); const maskShow = ref(true);
const submitLoading = ref(false); const submitLoading = ref(false);
const searchName = ref(''); const searchName = ref('');
useSearchPersistence('database/mongodb/index', { search: searchName });
const createVisible = ref(false); const createVisible = ref(false);
const appStatusRef = ref(); const appStatusRef = ref();
const bindRef = ref(); const bindRef = ref();
@@ -75,6 +75,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { dateFormat } from '@/utils/date'; import { dateFormat } from '@/utils/date';
import { onMounted, reactive, ref } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import { deleteCheckDatabase, searchDatabases } from '@/api/modules/database'; import { deleteCheckDatabase, searchDatabases } from '@/api/modules/database';
@@ -100,6 +101,7 @@ const paginationConfig = reactive({
order: 'null', order: 'null',
}); });
const searchName = ref(); const searchName = ref();
useSearchPersistence('database/mongodb/remote/index', { search: searchName });
const search = async (column?: any) => { const search = async (column?: any) => {
paginationConfig.orderBy = column?.order ? column.prop : paginationConfig.orderBy; paginationConfig.orderBy = column?.order ? column.prop : paginationConfig.orderBy;
@@ -226,6 +226,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import BindDialog from '@/views/database/mysql/bind/index.vue'; import BindDialog from '@/views/database/mysql/bind/index.vue';
import UserDialog from '@/views/database/mysql/user/index.vue'; import UserDialog from '@/views/database/mysql/user/index.vue';
import OperateDialog from '@/views/database/mysql/create/index.vue'; import OperateDialog from '@/views/database/mysql/create/index.vue';
@@ -300,6 +301,7 @@ const paginationConfig = reactive({
order: 'null', order: 'null',
}); });
const searchName = ref(); const searchName = ref();
useSearchPersistence('database/mysql/index', { search: searchName });
const mysqlContainer = ref(); const mysqlContainer = ref();
const mysqlStatus = ref(); const mysqlStatus = ref();
@@ -74,6 +74,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { dateFormat } from '@/utils/date'; import { dateFormat } from '@/utils/date';
import { onMounted, reactive, ref } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import { deleteCheckDatabase, searchDatabases } from '@/api/modules/database'; import { deleteCheckDatabase, searchDatabases } from '@/api/modules/database';
@@ -99,6 +100,7 @@ const paginationConfig = reactive({
order: 'null', order: 'null',
}); });
const searchName = ref(); const searchName = ref();
useSearchPersistence('database/mysql/remote/index', { search: searchName });
const search = async (column?: any) => { const search = async (column?: any) => {
paginationConfig.orderBy = column?.order ? column.prop : paginationConfig.orderBy; paginationConfig.orderBy = column?.order ? column.prop : paginationConfig.orderBy;
@@ -241,6 +241,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import BindDialog from '@/views/database/postgresql/bind/index.vue'; import BindDialog from '@/views/database/postgresql/bind/index.vue';
import OperateDialog from '@/views/database/postgresql/create/index.vue'; import OperateDialog from '@/views/database/postgresql/create/index.vue';
import DeleteDialog from '@/views/database/postgresql/delete/index.vue'; import DeleteDialog from '@/views/database/postgresql/delete/index.vue';
@@ -312,6 +313,7 @@ const paginationConfig = reactive({
order: 'null', order: 'null',
}); });
const searchName = ref(); const searchName = ref();
useSearchPersistence('database/postgresql/index', { search: searchName });
const postgresqlContainer = ref(); const postgresqlContainer = ref();
const postgresqlStatus = ref(); const postgresqlStatus = ref();
@@ -74,6 +74,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { dateFormat } from '@/utils/date'; import { dateFormat } from '@/utils/date';
import { onMounted, reactive, ref } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import { deleteCheckDatabase, searchDatabases } from '@/api/modules/database'; import { deleteCheckDatabase, searchDatabases } from '@/api/modules/database';
@@ -99,6 +100,7 @@ const paginationConfig = reactive({
order: 'null', order: 'null',
}); });
const searchName = ref(); const searchName = ref();
useSearchPersistence('database/postgresql/remote/index', { search: searchName });
const search = async (column?: any) => { const search = async (column?: any) => {
paginationConfig.orderBy = column?.order ? column.prop : paginationConfig.orderBy; paginationConfig.orderBy = column?.order ? column.prop : paginationConfig.orderBy;
+146 -30
View File
@@ -81,8 +81,23 @@
:style="{ height: `calc(100vh - ${loadHeight()})`, 'background-color': '#000' }" :style="{ height: `calc(100vh - ${loadHeight()})`, 'background-color': '#000' }"
:description="loadErrMsg()" :description="loadErrMsg()"
> >
<el-button v-if="currentDB.from === 'remote'" v-permission type="primary" @click="installCli"> <el-button
{{ $t('commons.button.enable') }} v-if="currentDB.from === 'remote'"
v-permission
type="primary"
:loading="cliSubmitting"
:disabled="!cliStatusLoaded || cliInstalling"
@click="installCli"
>
{{ cliInstalling ? $t('database.redisCliEnabling') : $t('commons.button.enable') }}
</el-button>
<el-button
v-if="currentDB.from === 'remote' && cliTaskID && (cliInstalling || redisCliExist)"
link
type="primary"
@click="openCliTaskLog"
>
{{ $t('commons.button.log') }}
</el-button> </el-button>
</el-empty> </el-empty>
<div> <div>
@@ -134,6 +149,7 @@
</DialogPro> </DialogPro>
<QuickCmd ref="dialogQuickCmdRef" @reload="loadQuickCmd" /> <QuickCmd ref="dialogQuickCmdRef" @reload="loadQuickCmd" />
<TaskLog ref="cliTaskLogRef" />
</div> </div>
</template> </template>
@@ -143,17 +159,21 @@ import Conn from '@/views/database/redis/conn/index.vue';
import Terminal from '@/components/terminal/index.vue'; import Terminal from '@/components/terminal/index.vue';
import AppStatus from '@/components/app-status/index.vue'; import AppStatus from '@/components/app-status/index.vue';
import QuickCmd from '@/views/database/redis/command/index.vue'; import QuickCmd from '@/views/database/redis/command/index.vue';
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { checkAppInstalled } from '@/api/modules/app'; import { checkAppInstalled } from '@/api/modules/app';
import { useGlobalStore } from '@/composables/useGlobalStore'; import { useGlobalStore } from '@/composables/useGlobalStore';
import { listDatabases, checkRedisCli, installRedisCli } from '@/api/modules/database'; import { listDatabases, loadRedisCliStatus, installRedisCli } from '@/api/modules/database';
import { Database } from '@/api/interface/database'; import { Database } from '@/api/interface/database';
import { MsgSuccess } from '@/utils/message'; import { MsgError, MsgSuccess } from '@/utils/message';
import i18n from '@/lang'; import i18n from '@/lang';
import { getCommandList } from '@/api/modules/command'; import { getCommandList } from '@/api/modules/command';
import { routerToName, routerToNameWithQuery } from '@/utils/router'; import { routerToName, routerToNameWithQuery } from '@/utils/router';
import { useOperateNodeContext } from '@/composables/useOperateNodeContext'; import { useOperateNodeContext } from '@/composables/useOperateNodeContext';
import TaskLog from '@/components/log/task/index.vue';
import bus from '@/global/bus';
import { newUUID } from '@/utils/id';
const { currentNode, currentRedisDB, openMenuTabs } = useGlobalStore(); const { currentNode, currentRedisDB, openMenuTabs } = useGlobalStore();
useOperateNodeContext(currentNode); useOperateNodeContext(currentNode);
@@ -170,7 +190,21 @@ const appStatusRef = ref();
const open = ref(false); const open = ref(false);
const redisCliExist = ref(); const redisCliExist = ref(false);
const cliSubmitting = ref(false);
const cliStatusLoaded = ref(false);
const cliTaskID = ref('');
const cliTaskStatus = ref('');
const cliTaskLogRef = ref<InstanceType<typeof TaskLog>>();
const cliInstalling = computed(() => cliTaskStatus.value === 'Executing');
const cliStatusText = computed(() => {
if (cliInstalling.value) return i18n.global.t('database.redisCliEnabling');
if (cliTaskStatus.value === 'Failed') return i18n.global.t('database.redisCliEnableFailed');
return redisCliExist.value ? i18n.global.t('database.redisCliEnabled') : i18n.global.t('database.redisCliHelper');
});
let cliPollTimer: ReturnType<typeof setTimeout>;
let cliGeneration = 0;
let disposed = false;
const appKey = ref('redis'); const appKey = ref('redis');
const appName = ref(); const appName = ref();
@@ -248,8 +282,10 @@ const changeDatabase = async () => {
}; };
const loadDBOptions = async () => { const loadDBOptions = async () => {
const generation = cliGeneration;
try { try {
const res = await listDatabases('redis,redis-cluster'); const res = await listDatabases('redis,redis-cluster');
if (disposed || generation !== cliGeneration) return;
let datas = res.data || []; let datas = res.data || [];
dbOptionsLocal.value = []; dbOptionsLocal.value = [];
dbOptionsRemote.value = []; dbOptionsRemote.value = [];
@@ -286,14 +322,14 @@ const loadDBOptions = async () => {
reOpenTerminal(); reOpenTerminal();
} }
} finally { } finally {
isLoaded.value = true; if (!disposed && generation === cliGeneration) isLoaded.value = true;
} }
}; };
const loadErrMsg = () => { const loadErrMsg = () => {
return currentDB.value.from === 'local' return currentDB.value.from === 'local'
? i18n.global.t('commons.service.serviceNotStarted', ['Redis']) ? i18n.global.t('commons.service.serviceNotStarted', ['Redis'])
: i18n.global.t('database.redisCliHelper'); : cliStatusText.value;
}; };
const reOpenTerminal = async () => { const reOpenTerminal = async () => {
closeTerminal(false); closeTerminal(false);
@@ -301,6 +337,10 @@ const reOpenTerminal = async () => {
}; };
const initTerminal = async () => { const initTerminal = async () => {
if (disposed || !currentDB.value) return;
const generation = cliGeneration;
const database = currentDBName.value;
const isCurrent = () => !disposed && generation === cliGeneration && database === currentDBName.value;
loading.value = true; loading.value = true;
if (currentDB.value.from === 'remote') { if (currentDB.value.from === 'remote') {
if (!redisCliExist.value) { if (!redisCliExist.value) {
@@ -311,9 +351,10 @@ const initTerminal = async () => {
loading.value = false; loading.value = false;
redisIsExist.value = true; redisIsExist.value = true;
nextTick(() => { nextTick(() => {
if (!isCurrent()) return;
terminalShow.value = true; terminalShow.value = true;
redisStatus.value = 'Running'; redisStatus.value = 'Running';
terminalRef.value.acceptParams({ terminalRef.value?.acceptParams({
endpoint: '/api/v2/hosts/terminal/container', endpoint: '/api/v2/hosts/terminal/container',
args: `source=redis&name=${currentDBName.value}&from=${currentDB.value.from}`, args: `source=redis&name=${currentDBName.value}&from=${currentDB.value.from}`,
error: '', error: '',
@@ -325,13 +366,15 @@ const initTerminal = async () => {
} }
await checkAppInstalled(currentDB.value.type, currentDBName.value) await checkAppInstalled(currentDB.value.type, currentDBName.value)
.then((res) => { .then((res) => {
if (!isCurrent()) return;
redisIsExist.value = res.data.isExist; redisIsExist.value = res.data.isExist;
redisStatus.value = res.data.status; redisStatus.value = res.data.status;
loading.value = false; loading.value = false;
nextTick(() => { nextTick(() => {
if (!isCurrent()) return;
if (res.data.status === 'Running') { if (res.data.status === 'Running') {
terminalShow.value = true; terminalShow.value = true;
terminalRef.value.acceptParams({ terminalRef.value?.acceptParams({
endpoint: '/api/v2/hosts/terminal/container', endpoint: '/api/v2/hosts/terminal/container',
args: `source=${currentDB.value.type}&name=${currentDBName.value}&from=${currentDB.value.from}`, args: `source=${currentDB.value.type}&name=${currentDBName.value}&from=${currentDB.value.from}`,
error: '', error: '',
@@ -342,6 +385,7 @@ const initTerminal = async () => {
isRefresh.value = !isRefresh.value; isRefresh.value = !isRefresh.value;
}) })
.catch(() => { .catch(() => {
if (!isCurrent()) return;
closeTerminal(false); closeTerminal(false);
loading.value = false; loading.value = false;
}); });
@@ -352,28 +396,78 @@ const closeTerminal = async (isKeepShow: boolean) => {
terminalShow.value = isKeepShow; terminalShow.value = isKeepShow;
}; };
const checkCliValid = async () => { const openCliTaskLog = () => {
await checkRedisCli() if (cliTaskID.value) cliTaskLogRef.value?.openWithTaskID(cliTaskID.value, cliInstalling.value, currentNode.value);
.then((res) => {
redisCliExist.value = res.data;
loadDBOptions();
})
.catch(() => {
loadDBOptions();
});
}; };
const installCli = async () => {
loading.value = true; const applyCliStatus = (status: Database.RedisCliStatus) => {
await installRedisCli() const wasInstalling = cliInstalling.value;
.then(() => { const wasInstalled = redisCliExist.value;
loading.value = false; redisCliExist.value = status.installed;
redisCliExist.value = true; cliTaskID.value = status.taskID;
MsgSuccess(i18n.global.t('commons.msg.operationSuccess')); cliTaskStatus.value = status.status;
cliStatusLoaded.value = true;
if (!wasInstalled && status.installed && !cliInstalling.value && currentDB.value?.from === 'remote') {
reOpenTerminal(); reOpenTerminal();
}) }
.catch(() => { if (wasInstalling && !cliInstalling.value) {
loading.value = false; bus.emit('refreshTask', true);
}); if (status.status === 'Success' && status.installed) {
MsgSuccess(i18n.global.t('database.redisCliEnabled'));
} else if (status.status === 'Failed') {
MsgError(i18n.global.t('database.redisCliEnableFailed'));
}
}
};
const scheduleCliPoll = (generation: number) => {
clearTimeout(cliPollTimer);
if (disposed || generation !== cliGeneration) return;
cliPollTimer = setTimeout(() => refreshCliStatus(generation), 2000);
};
const refreshCliStatus = async (generation: number) => {
try {
const res = await loadRedisCliStatus(currentNode.value);
if (disposed || generation !== cliGeneration) return;
applyCliStatus(res.data);
if (cliInstalling.value) scheduleCliPoll(generation);
} catch {
// Keep tracking a running task after a transient request failure.
if (!disposed && generation === cliGeneration) scheduleCliPoll(generation);
}
};
const checkCliValid = async () => {
const generation = cliGeneration;
await refreshCliStatus(generation);
if (!disposed && generation === cliGeneration) await loadDBOptions();
};
const installCli = async () => {
if (cliSubmitting.value || cliInstalling.value) return;
const generation = cliGeneration;
const node = currentNode.value;
cliSubmitting.value = true;
clearTimeout(cliPollTimer);
try {
const res = await installRedisCli(newUUID(), node);
if (disposed || generation !== cliGeneration) return;
applyCliStatus(res.data);
bus.emit('refreshTask', true);
if (cliInstalling.value) {
MsgSuccess(i18n.global.t('database.redisCliTaskSubmitted'));
openCliTaskLog();
scheduleCliPoll(generation);
} else if (res.data.installed && currentDB.value?.from === 'remote') {
reOpenTerminal();
}
} catch {
// The server may have accepted the task even if the response was lost.
if (!disposed && generation === cliGeneration) await refreshCliStatus(generation);
} finally {
if (!disposed && generation === cliGeneration) cliSubmitting.value = false;
}
}; };
const loadQuickCmd = async () => { const loadQuickCmd = async () => {
@@ -392,6 +486,25 @@ const onSetQuickCmd = () => {
dialogQuickCmdRef.value.acceptParams(); dialogQuickCmdRef.value.acceptParams();
}; };
watch(currentNode, () => {
cliGeneration++;
clearTimeout(cliPollTimer);
cliTaskLogRef.value?.handleClose();
closeTerminal(false);
redisCliExist.value = false;
cliSubmitting.value = false;
cliStatusLoaded.value = false;
cliTaskID.value = '';
cliTaskStatus.value = '';
currentDB.value = undefined;
redisStatus.value = undefined;
dbOptionsLocal.value = [];
dbOptionsRemote.value = [];
isLoaded.value = false;
loading.value = false;
checkCliValid();
});
onMounted(() => { onMounted(() => {
loadQuickCmd(); loadQuickCmd();
checkCliValid(); checkCliValid();
@@ -403,6 +516,9 @@ const onAfter = () => {
initTerminal(); initTerminal();
}; };
onBeforeUnmount(() => { onBeforeUnmount(() => {
disposed = true;
cliGeneration++;
clearTimeout(cliPollTimer);
closeTerminal(false); closeTerminal(false);
}); });
</script> </script>
@@ -73,6 +73,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { dateFormat } from '@/utils/date'; import { dateFormat } from '@/utils/date';
import { onMounted, reactive, ref } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import { deleteCheckDatabase, searchDatabases } from '@/api/modules/database'; import { deleteCheckDatabase, searchDatabases } from '@/api/modules/database';
@@ -98,6 +99,7 @@ const paginationConfig = reactive({
order: 'null', order: 'null',
}); });
const searchName = ref(); const searchName = ref();
useSearchPersistence('database/redis/remote/index', { search: searchName });
const search = async (column?: any) => { const search = async (column?: any) => {
paginationConfig.orderBy = column?.order ? column.prop : paginationConfig.orderBy; paginationConfig.orderBy = column?.order ? column.prop : paginationConfig.orderBy;
@@ -44,6 +44,7 @@
</div> </div>
</template> </template>
<table style="width: 100%" class="tab-table"> <table style="width: 100%" class="tab-table">
<tbody>
<tr v-for="(row, index) in form.saves" :key="index"> <tr v-for="(row, index) in form.saves" :key="index">
<td width="32%"> <td width="32%">
<el-input type="number" v-model="row.second"></el-input> <el-input type="number" v-model="row.second"></el-input>
@@ -76,6 +77,7 @@
</el-button> </el-button>
</td> </td>
</tr> </tr>
</tbody>
</table> </table>
<div> <div>
<span style="margin-left: 2px; margin-top: 5px">{{ $t('database.rdbHelper3') }}</span> <span style="margin-left: 2px; margin-top: 5px">{{ $t('database.rdbHelper3') }}</span>
@@ -739,7 +739,8 @@
</template> </template>
<script setup lang="ts"> <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 { import {
addFavorite, addFavorite,
batchGetFileRemarks, batchGetFileRemarks,
@@ -852,6 +853,7 @@ const initData = () => ({
sortOrder: 'ascending', sortOrder: 'ascending',
}); });
let req = reactive(initData()); let req = reactive(initData());
useSearchPersistence('host/file-management/index', { search: toRef(req, 'search') });
let loading = ref(false); let loading = ref(false);
const paths = ref<FilePaths[]>([]); const paths = ref<FilePaths[]>([]);
const breadcrumbVisiblePaths = ref<FilePaths[]>([]); const breadcrumbVisiblePaths = ref<FilePaths[]>([]);
@@ -232,6 +232,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { computed, reactive, ref, watch } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import FireRouter from '@/views/host/firewall/index.vue'; import FireRouter from '@/views/host/firewall/index.vue';
import DockerGuardStatus from '@/views/host/firewall/docker/status/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 openRuleTask = (taskID: string) => taskLogRef.value?.openWithTaskID(taskID, true);
const orphanDrawerVisible = ref(false); const orphanDrawerVisible = ref(false);
const searchName = ref(''); const searchName = ref('');
useSearchPersistence('host/firewall/docker/index', { search: searchName });
const selects = ref<Firewall.DockerGuardContainer[]>([]); const selects = ref<Firewall.DockerGuardContainer[]>([]);
const orphanSelects = ref<Firewall.DockerGuardEndpoint[]>([]); const orphanSelects = ref<Firewall.DockerGuardEndpoint[]>([]);
const data = reactive<Firewall.DockerGuardList>({ const data = reactive<Firewall.DockerGuardList>({
@@ -57,6 +57,18 @@
</template> </template>
<template #main> <template #main>
<ComplexTable <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" :pagination-config="paginationConfig"
v-model:selects="selects" v-model:selects="selects"
@search="search" @search="search"
@@ -133,6 +145,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import OperateDialog from './operate/index.vue'; import OperateDialog from './operate/index.vue';
import ImportDialog from './import/index.vue'; import ImportDialog from './import/index.vue';
import RuleSync from '@/views/host/firewall/sync/index.vue'; import RuleSync from '@/views/host/firewall/sync/index.vue';
@@ -153,6 +166,7 @@ import { ElMessageBox } from 'element-plus';
const loading = ref(); const loading = ref();
const selects = ref<any>([]); const selects = ref<any>([]);
const searchName = ref(); const searchName = ref();
useSearchPersistence('host/firewall/forward/index', { search: searchName });
const isInit = ref(false); const isInit = ref(false);
const isBind = ref(false); const isBind = ref(false);
@@ -185,6 +185,7 @@
<template #main> <template #main>
<div v-loading="loading"> <div v-loading="loading">
<ComplexTable <ComplexTable
:selection-context="() => [searchName, selectedRuleFilters, visibleIptablesChains]"
v-model:selects="selects" v-model:selects="selects"
:pagination-config="paginationConfig" :pagination-config="paginationConfig"
:data="allRows" :data="allRows"
@@ -397,6 +398,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { Firewall } from '@/api/interface/firewall'; import { Firewall } from '@/api/interface/firewall';
import { Process } from '@/api/interface/process'; import { Process } from '@/api/interface/process';
import { import {
@@ -516,6 +518,7 @@ const visibleIptablesChains = ref<string[]>(
loadCachedFilterValues(chainFilterStorageKey, iptablesChains, ['1PANEL_BASIC']), loadCachedFilterValues(chainFilterStorageKey, iptablesChains, ['1PANEL_BASIC']),
); );
const searchName = ref(''); const searchName = ref('');
useSearchPersistence('host/firewall/rule/index', { search: searchName });
const inventoryItems = ref<Firewall.InventoryItem[]>([]); const inventoryItems = ref<Firewall.InventoryItem[]>([]);
const positionRanges = ref<Partial<Record<Firewall.Family, Firewall.PositionRange>>>({}); const positionRanges = ref<Partial<Record<Firewall.Family, Firewall.PositionRange>>>({});
const inventoryTotal = ref(0); const inventoryTotal = ref(0);
@@ -659,7 +662,6 @@ const loadRules = async (refreshUsage: boolean) => {
paginationConfig.total = total; paginationConfig.total = total;
inventoryTotal.value = response.data.allTotal || 0; inventoryTotal.value = response.data.allTotal || 0;
managedTotal.value = response.data.managedTotal || 0; managedTotal.value = response.data.managedTotal || 0;
selects.value = [];
} finally { } finally {
if (requestID === searchRequestID) loading.value = false; if (requestID === searchRequestID) loading.value = false;
} }
+2
View File
@@ -93,6 +93,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import ConfirmDialog from '@/components/confirm-dialog/index.vue'; import ConfirmDialog from '@/components/confirm-dialog/index.vue';
import { dateFormatServerTimezone } from '@/utils/date'; import { dateFormatServerTimezone } from '@/utils/date';
import { downloadFile } from '@/utils/file'; import { downloadFile } from '@/utils/file';
@@ -119,6 +120,7 @@ const exportConfig = reactive({
status: 'All', status: 'All',
}); });
const searchInfo = ref(); const searchInfo = ref();
useSearchPersistence('host/ssh/log/log', { search: searchInfo });
const searchStatus = ref('All'); const searchStatus = ref('All');
const search = async () => { const search = async () => {
@@ -24,8 +24,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import FireRouter from '@/views/host/ssh/index.vue'; 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 i18n from '@/lang';
import { stopProcess } from '@/api/modules/process'; import { stopProcess } from '@/api/modules/process';
import { MsgError, MsgSuccess } from '@/utils/message'; import { MsgError, MsgSuccess } from '@/utils/message';
@@ -37,6 +38,7 @@ const sshSearch = reactive({
type: 'ssh', type: 'ssh',
loginUser: '', loginUser: '',
}); });
useSearchPersistence('host/ssh/session/index', { search: toRef(sshSearch, 'loginUser') });
const buttons = [ const buttons = [
{ {
@@ -101,6 +101,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { onMounted, onUnmounted, reactive, ref, watch } from 'vue'; import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { Log } from '@/api/interface/log'; import { Log } from '@/api/interface/log';
import { getSystemLogStatus, listRunningServices, readSystemLogs } from '@/api/modules/log'; import { getSystemLogStatus, listRunningServices, readSystemLogs } from '@/api/modules/log';
@@ -113,6 +114,7 @@ const logs = ref<Log.SystemLogItem[]>([]);
const loading = ref(false); const loading = ref(false);
const watching = ref(false); const watching = ref(false);
const keyword = ref(''); const keyword = ref('');
useSearchPersistence('log/host-system/index', { keyword });
const priority = ref(''); const priority = ref('');
const service = ref(''); const service = ref('');
const services = ref<string[]>([]); const services = ref<string[]>([]);
+2
View File
@@ -45,6 +45,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import ConfirmDialog from '@/components/confirm-dialog/index.vue'; import ConfirmDialog from '@/components/confirm-dialog/index.vue';
import LogRouter from '@/views/log/router/index.vue'; import LogRouter from '@/views/log/router/index.vue';
import { dateFormat } from '@/utils/date'; import { dateFormat } from '@/utils/date';
@@ -66,6 +67,7 @@ const paginationConfig = reactive({
total: 0, total: 0,
}); });
const searchInfo = ref<string>(''); const searchInfo = ref<string>('');
useSearchPersistence('log/login/index', { search: searchInfo });
const searchStatus = ref<string>(''); const searchStatus = ref<string>('');
const search = async () => { const search = async () => {
@@ -125,6 +125,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import ConfirmDialog from '@/components/confirm-dialog/index.vue'; import ConfirmDialog from '@/components/confirm-dialog/index.vue';
import LogRouter from '@/views/log/router/index.vue'; import LogRouter from '@/views/log/router/index.vue';
import { dateFormat } from '@/utils/date'; import { dateFormat } from '@/utils/date';
@@ -145,6 +146,7 @@ const paginationConfig = reactive({
total: 0, total: 0,
}); });
const searchName = ref<string>(''); const searchName = ref<string>('');
useSearchPersistence('log/operation/index', { search: searchName });
const searchGroup = ref<string>(''); const searchGroup = ref<string>('');
const searchStatus = ref<string>(''); const searchStatus = ref<string>('');
const searchNode = ref<string>(''); const searchNode = ref<string>('');
@@ -120,8 +120,9 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { dateFormat } from '@/utils/date'; 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 { searchBackup, deleteBackup, refreshToken } from '@/api/modules/backup';
import Operate from '@/views/setting/backup-account/operate/index.vue'; import Operate from '@/views/setting/backup-account/operate/index.vue';
import DetailShow from '@/components/detail-show/index.vue'; import DetailShow from '@/components/detail-show/index.vue';
@@ -142,6 +143,7 @@ const paginationConfig = reactive({
type: '', type: '',
name: '', name: '',
}); });
useSearchPersistence('setting/backup-account/index', { search: toRef(paginationConfig, 'name') });
const opRef = ref(); const opRef = ref();
const dialogRef = ref(); const dialogRef = ref();
const detailRef = ref(); const detailRef = ref();
@@ -19,6 +19,8 @@
</template> </template>
<template #main> <template #main>
<ComplexTable <ComplexTable
:selection-context="() => [searchName]"
row-key="id"
:pagination-config="paginationConfig" :pagination-config="paginationConfig"
v-model:selects="selects" v-model:selects="selects"
:data="data" :data="data"
@@ -223,6 +225,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { import {
searchSnapshotPage, searchSnapshotPage,
snapshotDelete, snapshotDelete,
@@ -258,6 +261,7 @@ const paginationConfig = reactive({
order: 'null', order: 'null',
}); });
const searchName = ref(); const searchName = ref();
useSearchPersistence('setting/snapshot/index', { search: searchName });
const opRef = ref(); const opRef = ref();
@@ -100,6 +100,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { Command } from '@/api/interface/command'; import { Command } from '@/api/interface/command';
import GroupDialog from '@/components/group/index.vue'; import GroupDialog from '@/components/group/index.vue';
import OperateDialog from '@/views/terminal/command/operate/index.vue'; import OperateDialog from '@/views/terminal/command/operate/index.vue';
@@ -123,6 +124,7 @@ const paginationConfig = reactive({
order: 'ascending', order: 'ascending',
}); });
const info = ref(); const info = ref();
useSearchPersistence('terminal/command/index', { search: info });
const group = ref<string>(''); const group = ref<string>('');
const dialogRef = ref(); const dialogRef = ref();
const opRef = ref(); const opRef = ref();
@@ -79,6 +79,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import GroupDialog from '@/components/agent-group/index.vue'; import GroupDialog from '@/components/agent-group/index.vue';
import OperateDialog from '@/views/terminal/host/operate/index.vue'; import OperateDialog from '@/views/terminal/host/operate/index.vue';
import { deleteHost, editHostGroup, searchHosts } from '@/api/modules/terminal'; import { deleteHost, editHostGroup, searchHosts } from '@/api/modules/terminal';
@@ -99,6 +100,7 @@ const paginationConfig = reactive({
total: 0, total: 0,
}); });
const info = ref(); const info = ref();
useSearchPersistence('terminal/host/index', { search: info });
const group = ref<string>(''); const group = ref<string>('');
const opRef = ref(); const opRef = ref();
@@ -49,6 +49,8 @@
</el-card> </el-card>
<template #main v-if="clamStatus.isExist"> <template #main v-if="clamStatus.isExist">
<ComplexTable <ComplexTable
:selection-context="() => [searchName]"
row-key="id"
:class="{ mask: !clamStatus.isRunning }" :class="{ mask: !clamStatus.isRunning }"
v-if="!isSettingShow" v-if="!isSettingShow"
:pagination-config="paginationConfig" :pagination-config="paginationConfig"
@@ -188,6 +190,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { onMounted, reactive, ref } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import i18n from '@/lang'; import i18n from '@/lang';
import { MsgSuccess } from '@/utils/message'; import { MsgSuccess } from '@/utils/message';
@@ -215,6 +218,7 @@ const paginationConfig = reactive({
order: 'null', order: 'null',
}); });
const searchName = ref(); const searchName = ref();
useSearchPersistence('toolbox/clam/index', { search: searchName });
const opRef = ref(); const opRef = ref();
const dialogRef = ref(); const dialogRef = ref();
+2
View File
@@ -174,6 +174,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { onMounted, reactive, ref } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import i18n from '@/lang'; import i18n from '@/lang';
import { MsgError, MsgSuccess } from '@/utils/message'; import { MsgError, MsgSuccess } from '@/utils/message';
@@ -200,6 +201,7 @@ const paginationConfig = reactive({
order: 'null', order: 'null',
}); });
const searchName = ref(); const searchName = ref();
useSearchPersistence('toolbox/ftp/index', { search: searchName });
const form = reactive({ const form = reactive({
isActive: true, isActive: true,
@@ -33,6 +33,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { Runtime } from '@/api/interface/runtime'; import { Runtime } from '@/api/interface/runtime';
import { GetPHPExtensions, InstallPHPExtension, UnInstallPHPExtension } from '@/api/modules/runtime'; import { GetPHPExtensions, InstallPHPExtension, UnInstallPHPExtension } from '@/api/modules/runtime';
import i18n from '@/lang'; import i18n from '@/lang';
@@ -48,6 +49,7 @@ const supportExtensions = ref([]);
const loading = ref(false); const loading = ref(false);
const taskLogRef = ref(); const taskLogRef = ref();
const searchName = ref(''); const searchName = ref('');
useSearchPersistence('website/runtime/php/extension-management/index', { search: searchName });
const data = ref([]); const data = ref([]);
const handleClose = () => { const handleClose = () => {
@@ -137,7 +139,6 @@ const searchByName = () => {
}; };
const acceptParams = (req: Runtime.Runtime): void => { const acceptParams = (req: Runtime.Runtime): void => {
searchName.value = '';
open.value = true; open.value = true;
runtime.value = req; runtime.value = req;
search(); search();
+3 -1
View File
@@ -221,7 +221,8 @@
</template> </template>
<script lang="ts" setup> <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 { deleteSSL, downloadFile, pushSSLToNode, searchSSL, updateSSL } from '@/api/modules/website';
import DnsAccount from './dns-account/index.vue'; import DnsAccount from './dns-account/index.vue';
import AcmeAccount from './acme-account/index.vue'; import AcmeAccount from './acme-account/index.vue';
@@ -286,6 +287,7 @@ const req = reactive({
orderBy: 'updated_at', orderBy: 'updated_at',
order: 'descending', order: 'descending',
}); });
useSearchPersistence('website/ssl/index', { search: toRef(req, 'domain') });
const pushForm = ref({ const pushForm = ref({
id: 0, id: 0,
pushNode: true, pushNode: true,
@@ -59,6 +59,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import { Website } from '@/api/interface/website'; import { Website } from '@/api/interface/website';
import { deleteTemplate, searchTemplates } from '@/api/modules/website'; import { deleteTemplate, searchTemplates } from '@/api/modules/website';
import TemplateOperate from '@/views/website/template/operate/index.vue'; import TemplateOperate from '@/views/website/template/operate/index.vue';
@@ -71,6 +72,7 @@ import { reactive, ref, onMounted } from 'vue';
const loading = ref(false); const loading = ref(false);
const data = ref<Website.Template[]>([]); const data = ref<Website.Template[]>([]);
const searchName = ref(''); const searchName = ref('');
useSearchPersistence('website/template/index', { search: searchName });
const opRef = ref(); const opRef = ref();
const operateRef = ref(); const operateRef = ref();
const outputCreateRef = ref(); const outputCreateRef = ref();
+5 -2
View File
@@ -66,6 +66,8 @@
</template> </template>
<template v-if="!openNginxConfig" #main> <template v-if="!openNginxConfig" #main>
<ComplexTable <ComplexTable
:selection-context="() => [req.name, req.type, req.websiteGroupId]"
row-key="id"
:pagination-config="paginationConfig" :pagination-config="paginationConfig"
:default-sort="tableSort.order ? tableSort : undefined" :default-sort="tableSort.order ? tableSort : undefined"
v-model:view-mode="viewMode" v-model:view-mode="viewMode"
@@ -344,6 +346,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useSearchPersistence } from '@/composables/useSearchPersistence';
import Backups from '@/components/backup/index.vue'; import Backups from '@/components/backup/index.vue';
import UploadDialog from '@/components/upload/index.vue'; import UploadDialog from '@/components/upload/index.vue';
import DefaultServer from '@/views/website/website/default/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 BatchSetHttps from '@/views/website/website/batch-op/https.vue';
import i18n from '@/lang'; 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 { batchOperate, opWebsite, searchWebsites, updateWebsite } from '@/api/modules/website';
import { Website } from '@/api/interface/website'; import { Website } from '@/api/interface/website';
import { App } from '@/api/interface/app'; import { App } from '@/api/interface/app';
@@ -448,6 +451,7 @@ const pageState = usePageState(() => ({
})); }));
const paginationConfig = pageState.paginationConfig; const paginationConfig = pageState.paginationConfig;
const req = pageState.req; const req = pageState.req;
useSearchPersistence('website/website/index', { search: toRef(req, 'name') });
const tableSort = pageState.tableSort; const tableSort = pageState.tableSort;
const goRouter = async (key: string) => { const goRouter = async (key: string) => {
@@ -496,7 +500,6 @@ const search = async () => {
req.pageSize = paginationConfig.pageSize; req.pageSize = paginationConfig.pageSize;
loading.value = true; loading.value = true;
data.value = [];
await searchWebsites(req) await searchWebsites(req)
.then((res) => { .then((res) => {
data.value = res.data.items; data.value = res.data.items;