mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-10-10 00:00:30 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac1a474fae |
@@ -86,23 +86,17 @@ func (b *BaseApi) CheckHasCli(c *gin.Context) {
|
||||
|
||||
// @Tags Database Redis
|
||||
// @Summary Install redis-cli
|
||||
// @Accept json
|
||||
// @Param request body dto.RedisCliInstall true "request"
|
||||
// @Success 200 {string} string
|
||||
// @Success 200
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /databases/redis/install/cli [post]
|
||||
func (b *BaseApi) InstallCli(c *gin.Context) {
|
||||
var req dto.RedisCliInstall
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
data, err := redisService.InstallCli(req)
|
||||
if err != nil {
|
||||
if err := redisService.InstallCli(); err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.SuccessWithData(c, data)
|
||||
|
||||
helper.Success(c)
|
||||
}
|
||||
|
||||
// @Tags Database Redis
|
||||
|
||||
@@ -22,10 +22,6 @@ type DBBaseInfo struct {
|
||||
Port int64 `json:"port"`
|
||||
}
|
||||
|
||||
type RedisCliInstall struct {
|
||||
TaskID string `json:"taskID" validate:"omitempty,uuid"`
|
||||
}
|
||||
|
||||
// mysql
|
||||
type MysqlDBSearch struct {
|
||||
PageInfo
|
||||
|
||||
@@ -2,6 +2,7 @@ package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
@@ -110,7 +111,9 @@ func (t TaskRepo) Update(ctx context.Context, task *model.Task) error {
|
||||
}
|
||||
|
||||
func (t TaskRepo) UpdateRunningTaskToFailed() error {
|
||||
return getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Updates(map[string]interface{}{"status": constant.StatusFailed, "error_msg": "1Panel restart causes failure"}).Error
|
||||
return getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Updates(map[string]interface{}{
|
||||
"status": constant.StatusFailed, "error_msg": constant.InterruptedMsg, "end_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (t TaskRepo) CountExecutingTask() (int64, error) {
|
||||
|
||||
@@ -550,6 +550,11 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
|
||||
|
||||
installTask, err := task.NewTaskWithOps(appInstall.Name, task.TaskInstall, task.TaskScopeApp, req.TaskID, appInstall.ID)
|
||||
if err != nil {
|
||||
appInstall.Status = constant.StatusInstallErr
|
||||
appInstall.Message = err.Error()
|
||||
if saveErr := appInstallRepo.Save(context.Background(), appInstall); saveErr != nil {
|
||||
err = fmt.Errorf("%w; save failed install status: %v", err, saveErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -557,7 +562,7 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
|
||||
return
|
||||
}
|
||||
|
||||
installApp := func(t *task.Task) error {
|
||||
installApp := func(t *task.Task) (err error) {
|
||||
if err = copyData(t, app, appDetail, appInstall, req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -339,6 +339,11 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
|
||||
|
||||
operationTask, err := task.NewTaskWithOps(install.Name, taskType, task.TaskScopeApp, req.TaskID, install.ID)
|
||||
if err != nil {
|
||||
install.Status = constant.StatusUpErr
|
||||
install.Message = err.Error()
|
||||
if saveErr := appInstallRepo.Save(context.Background(), &install); saveErr != nil {
|
||||
return fmt.Errorf("%w; save failed operation status: %v", err, saveErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
operation := string(req.Operate)
|
||||
@@ -567,11 +572,15 @@ func (a *AppInstallService) SyncAll(systemInit bool) error {
|
||||
return err
|
||||
}
|
||||
for _, i := range allList {
|
||||
if i.Status == constant.StatusInstalling || i.Status == constant.StatusUpgrading || i.Status == constant.StatusRebuilding || i.Status == constant.StatusUninstalling {
|
||||
if appInstallOperationInterruptedOnRestart(i.Status) {
|
||||
if systemInit {
|
||||
i.Status = constant.StatusError
|
||||
i.Message = "1Panel restart causes the task to terminate"
|
||||
_ = appInstallRepo.Save(context.Background(), &i)
|
||||
i.Status = appInstallOperationFailureStatus(i.Status)
|
||||
i.Message = constant.InterruptedMsg
|
||||
if err := appInstallRepo.Save(context.Background(), &i); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := syncAppInstallStatus(&i, false); err != nil {
|
||||
global.LOG.Errorf("sync install app[%s] error,mgs: %s", i.Name, err.Error())
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -931,10 +940,34 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
|
||||
}
|
||||
|
||||
func syncAppInstallStatus(appInstall *model.AppInstall, force bool) error {
|
||||
operation := ""
|
||||
switch appInstall.Status {
|
||||
case constant.StatusInstalling:
|
||||
operation = task.TaskInstall
|
||||
case constant.StatusUpgrading:
|
||||
operation = task.TaskUpgrade
|
||||
case constant.StatusRebuilding:
|
||||
operation = task.TaskBuild
|
||||
case constant.StatusUninstalling:
|
||||
operation = task.TaskUninstall
|
||||
case constant.StatusStarting, constant.StatusWaiting:
|
||||
operation = task.TaskUpdate
|
||||
case constant.StatusRestarting:
|
||||
operation = task.TaskRestart
|
||||
}
|
||||
if operation != "" {
|
||||
if err := reconcileAppInstallTaskFailure(appInstall, operation, global.DB, global.TaskDB); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
switch appInstall.Status {
|
||||
case constant.StatusInstalling, constant.StatusRebuilding, constant.StatusUpgrading, constant.StatusUninstalling,
|
||||
constant.StatusStarting, constant.StatusRestarting, constant.StatusWaiting:
|
||||
return nil
|
||||
case constant.StatusInstallErr, constant.StatusUpgradeErr, constant.StatusUpErr:
|
||||
if !force {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
cli, err := docker.NewClient()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func appInstallOperationFailureStatus(status string) string {
|
||||
switch status {
|
||||
case constant.StatusInstalling:
|
||||
return constant.StatusInstallErr
|
||||
case constant.StatusUpgrading:
|
||||
return constant.StatusUpgradeErr
|
||||
case constant.StatusUninstalling:
|
||||
return constant.StatusError
|
||||
default:
|
||||
return constant.StatusUpErr
|
||||
}
|
||||
}
|
||||
|
||||
func appInstallOperationPending(status string) bool {
|
||||
switch status {
|
||||
case constant.StatusInstalling, constant.StatusRebuilding, constant.StatusUpgrading, constant.StatusUninstalling,
|
||||
constant.StatusStarting, constant.StatusRestarting, constant.StatusWaiting:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func appInstallOperationInterruptedOnRestart(status string) bool {
|
||||
// ReStarting can also describe a container's Docker restart policy.
|
||||
return appInstallOperationPending(status) && status != constant.StatusRestarting
|
||||
}
|
||||
|
||||
func reconcileAppInstallTaskFailure(install *model.AppInstall, operation string, appDB, taskDB *gorm.DB) error {
|
||||
if !appInstallOperationPending(install.Status) {
|
||||
return nil
|
||||
}
|
||||
var operationTask model.Task
|
||||
err := taskDB.Where("type = ? AND resource_id = ? AND operate = ?", "App", install.ID, operation).
|
||||
Order("created_at DESC").First(&operationTask).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if operationTask.Status != constant.StatusFailed && operationTask.Status != constant.StatusCanceled {
|
||||
return nil
|
||||
}
|
||||
// A previous operation must not turn a newly queued retry into a failure.
|
||||
if operationTask.CreatedAt.Before(install.UpdatedAt) &&
|
||||
(operationTask.EndAt.IsZero() || operationTask.EndAt.Before(install.UpdatedAt)) {
|
||||
return nil
|
||||
}
|
||||
message := operationTask.ErrorMsg
|
||||
if message == "" {
|
||||
message = "the application operation task failed"
|
||||
}
|
||||
status := appInstallOperationFailureStatus(install.Status)
|
||||
result := appDB.Model(&model.AppInstall{}).
|
||||
Where("id = ? AND status = ? AND updated_at = ?", install.ID, install.Status, install.UpdatedAt).
|
||||
Updates(map[string]interface{}{"status": status, "message": message})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
install.Status = status
|
||||
install.Message = message
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func operationStatusTestDB(t *testing.T) (*gorm.DB, *gorm.DB, model.AppInstall) {
|
||||
t.Helper()
|
||||
open := func(name string, schema interface{}) *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), name)), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
return db
|
||||
}
|
||||
appDB := open("apps.db", &model.AppInstall{})
|
||||
taskDB := open("tasks.db", &model.Task{})
|
||||
install := model.AppInstall{
|
||||
Name: "gb10-ds-vision", AppId: 1, AppDetailId: 1,
|
||||
Version: "nvidia-gb10-dspark-0.1.1", ContainerName: "vllm-dspark-1", ServiceName: "vllm-dspark",
|
||||
Status: constant.StatusStarting, Env: `{"MODEL_DIR":"/models/vision"}`,
|
||||
}
|
||||
if err := appDB.Omit(clause.Associations).Create(&install).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return appDB, taskDB, install
|
||||
}
|
||||
|
||||
func TestAppInstallFailedTaskRecoversPendingState(t *testing.T) {
|
||||
for _, state := range []string{constant.StatusStarting, constant.StatusWaiting, constant.StatusRestarting, constant.StatusInstalling} {
|
||||
t.Run(state, func(t *testing.T) {
|
||||
appDB, taskDB, install := operationStatusTestDB(t)
|
||||
install.Status = state
|
||||
if err := appDB.Omit(clause.Associations).Save(&install).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := model.Task{ID: "failed", Type: "App", Operate: "TaskUpdate", ResourceID: install.ID,
|
||||
Status: constant.StatusFailed, ErrorMsg: "model startup script failed", CreatedAt: install.UpdatedAt.Add(time.Second), EndAt: install.UpdatedAt.Add(2 * time.Second)}
|
||||
if err := taskDB.Create(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := reconcileAppInstallTaskFailure(&install, task.Operate, appDB, taskDB); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := constant.StatusUpErr
|
||||
if state == constant.StatusInstalling {
|
||||
want = constant.StatusInstallErr
|
||||
}
|
||||
var stored model.AppInstall
|
||||
if err := appDB.First(&stored, install.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if install.Status != want || stored.Status != want || stored.Message != task.ErrorMsg || stored.Env != install.Env {
|
||||
t.Fatalf("recovered state = %s/%s, message %q, env %q", install.Status, stored.Status, stored.Message, stored.Env)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppInstallPendingStateKeepsActiveAndUnrelatedTasks(t *testing.T) {
|
||||
for _, taskStatus := range []string{constant.StatusExecuting, constant.StatusSuccess, "missing", "previous-failure", "other-resource"} {
|
||||
t.Run(taskStatus, func(t *testing.T) {
|
||||
appDB, taskDB, install := operationStatusTestDB(t)
|
||||
task := model.Task{ID: "task", Type: "App", Operate: "TaskUpdate", ResourceID: install.ID,
|
||||
Status: taskStatus, CreatedAt: install.UpdatedAt.Add(time.Second), EndAt: install.UpdatedAt.Add(2 * time.Second)}
|
||||
if taskStatus == "previous-failure" {
|
||||
task.Status = constant.StatusFailed
|
||||
task.CreatedAt = install.UpdatedAt.Add(-2 * time.Hour)
|
||||
task.EndAt = install.UpdatedAt.Add(-time.Hour)
|
||||
}
|
||||
if taskStatus == "other-resource" {
|
||||
task.Status = constant.StatusFailed
|
||||
task.ResourceID++
|
||||
}
|
||||
if taskStatus != "missing" {
|
||||
if err := taskDB.Create(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := reconcileAppInstallTaskFailure(&install, task.Operate, appDB, taskDB); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if install.Status != constant.StatusStarting {
|
||||
t.Fatalf("unrelated or active task changed pending state to %s", install.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppInstallRecoveryDoesNotOverwriteConcurrentRetry(t *testing.T) {
|
||||
appDB, taskDB, install := operationStatusTestDB(t)
|
||||
task := model.Task{ID: "failed", Type: "App", Operate: "TaskUpdate", ResourceID: install.ID,
|
||||
Status: constant.StatusCanceled, ErrorMsg: "interrupted", CreatedAt: install.UpdatedAt.Add(time.Second), EndAt: install.UpdatedAt.Add(2 * time.Second)}
|
||||
if err := taskDB.Create(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := appDB.Callback().Update().Before("gorm:update").Register("new-operation", func(db *gorm.DB) {
|
||||
if err := db.Exec("UPDATE app_installs SET updated_at = ?, message = ? WHERE id = ?", install.UpdatedAt.Add(time.Hour), "new attempt", install.ID).Error; err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := reconcileAppInstallTaskFailure(&install, task.Operate, appDB, taskDB); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stored model.AppInstall
|
||||
if err := appDB.First(&stored, install.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Status != constant.StatusStarting || stored.Message != "new attempt" {
|
||||
t.Fatalf("recovery overwrote a newer operation: %s, %s", stored.Status, stored.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppInstallRestartRecoveryCoversLifecycleStates(t *testing.T) {
|
||||
for _, state := range []string{constant.StatusStarting, constant.StatusWaiting, constant.StatusInstalling} {
|
||||
if !appInstallOperationInterruptedOnRestart(state) {
|
||||
t.Errorf("restart recovery omitted %s", state)
|
||||
}
|
||||
}
|
||||
for _, state := range []string{constant.StatusRunning, constant.StatusStopped, constant.StatusRestarting} {
|
||||
if appInstallOperationInterruptedOnRestart(state) {
|
||||
t.Errorf("restart recovery changes a Docker runtime state: %s", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1406,9 +1406,8 @@ func handleErr(install model.AppInstall, err error, out string) error {
|
||||
}
|
||||
|
||||
func doNotNeedSync(installed model.AppInstall) bool {
|
||||
return installed.Status == constant.StatusInstalling || installed.Status == constant.StatusRebuilding || installed.Status == constant.StatusUpgrading ||
|
||||
installed.Status == constant.StatusSyncing || installed.Status == constant.StatusUninstalling || installed.Status == constant.StatusInstallErr ||
|
||||
installed.Status == constant.StatusStarting || installed.Status == constant.StatusRestarting || installed.Status == constant.StatusWaiting
|
||||
return appInstallOperationPending(installed.Status) || installed.Status == constant.StatusSyncing ||
|
||||
installed.Status == constant.StatusInstallErr || installed.Status == constant.StatusUpgradeErr || installed.Status == constant.StatusUpErr
|
||||
}
|
||||
|
||||
func synAppInstall(containers map[string]container.Summary, appInstall *model.AppInstall, force bool) {
|
||||
@@ -1502,6 +1501,11 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
|
||||
}
|
||||
|
||||
for _, installed := range appInstallList {
|
||||
if sync && appInstallOperationPending(installed.Status) {
|
||||
if err := syncAppInstallStatus(&installed, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if updated && ignoreUpdate(installed) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -8,12 +8,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"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/global"
|
||||
@@ -23,15 +17,12 @@ import (
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/compose"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/docker"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
type RedisService struct{}
|
||||
|
||||
// The CLI container is shared by all remote Redis databases on this node.
|
||||
var redisCliInstallMutex sync.Mutex
|
||||
|
||||
type IRedisService interface {
|
||||
UpdateConf(req dto.RedisConfUpdate) error
|
||||
UpdatePersistenceConf(req dto.RedisConfPersistenceUpdate) error
|
||||
@@ -42,7 +33,7 @@ type IRedisService interface {
|
||||
LoadPersistenceConf(req dto.LoadRedisStatus) (*dto.RedisPersistence, error)
|
||||
|
||||
CheckHasCli() bool
|
||||
InstallCli(req dto.RedisCliInstall) (string, error)
|
||||
InstallCli() error
|
||||
}
|
||||
|
||||
func NewIRedisService() IRedisService {
|
||||
@@ -70,65 +61,30 @@ func (u *RedisService) UpdateConf(req dto.RedisConfUpdate) error {
|
||||
}
|
||||
|
||||
func (u *RedisService) CheckHasCli() bool {
|
||||
installed, _ := u.checkCliInstalled()
|
||||
return installed
|
||||
}
|
||||
|
||||
func (u *RedisService) checkCliInstalled() (bool, error) {
|
||||
client, err := docker.NewDockerClient()
|
||||
if err != nil {
|
||||
return false, err
|
||||
return false
|
||||
}
|
||||
defer client.Close()
|
||||
info, err := client.ContainerInspect(context.Background(), "1Panel-redis-cli-tools")
|
||||
if dockerclient.IsErrNotFound(err) {
|
||||
return false, nil
|
||||
}
|
||||
containerLists, err := client.ContainerList(context.Background(), container.ListOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
return false
|
||||
}
|
||||
return info.ContainerJSONBase != nil && info.State != nil && info.State.Running, nil
|
||||
for _, item := range containerLists {
|
||||
if strings.ReplaceAll(item.Names[0], "/", "") == "1Panel-redis-cli-tools" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (u *RedisService) InstallCli(req dto.RedisCliInstall) (string, error) {
|
||||
if !redisCliInstallMutex.TryLock() {
|
||||
return "", buserr.New("TaskIsExecuting")
|
||||
}
|
||||
defer redisCliInstallMutex.Unlock()
|
||||
func (u *RedisService) InstallCli() error {
|
||||
item := dto.ContainerOperate{
|
||||
TaskID: req.TaskID,
|
||||
Name: "1Panel-redis-cli-tools",
|
||||
Image: "redis:7.4.4",
|
||||
Networks: []dto.ContainerNetwork{{Network: "1panel-network"}},
|
||||
}
|
||||
running, err := taskRepo.GetFirst(
|
||||
repo.WithByName(task.GetTaskName(item.Name, task.TaskCreate, task.TaskScopeContainer)),
|
||||
repo.WithByType(task.TaskScopeContainer),
|
||||
taskRepo.WithByStatus(constant.StatusExecuting),
|
||||
)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", err
|
||||
}
|
||||
if running.ID != "" {
|
||||
return running.ID, nil
|
||||
}
|
||||
installed, err := u.checkCliInstalled()
|
||||
if err != nil || installed {
|
||||
return "", err
|
||||
}
|
||||
if item.TaskID == "" {
|
||||
item.TaskID = uuid.NewString()
|
||||
}
|
||||
if _, err := taskRepo.GetFirst(taskRepo.WithByID(item.TaskID)); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", buserr.New("TaskIsExecuting")
|
||||
}
|
||||
if err := NewIContainerService().ContainerCreate(item, true); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return item.TaskID, nil
|
||||
return NewIContainerService().ContainerCreate(item, false)
|
||||
}
|
||||
|
||||
func (u *RedisService) ChangePassword(req dto.ChangeRedisPass) error {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -324,16 +325,18 @@ func (u *ImageService) ImageLoad(req dto.ImageLoad) error {
|
||||
}
|
||||
|
||||
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 {
|
||||
currentPath := itemPath
|
||||
itemName := path.Base(currentPath)
|
||||
taskItem.AddSubTask(i18n.GetWithName("TaskImport", itemName), func(t *task.Task) error {
|
||||
taskItem.Logf("----------------- %s -----------------", itemName)
|
||||
client, err := docker.NewDockerClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
file, err := os.Open(currentPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -344,7 +347,14 @@ func (u *ImageService) ImageLoad(req dto.ImageLoad) error {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
return consumeImageLoadResponse(res.Body, taskItem.Log)
|
||||
content, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(string(content), "Error") {
|
||||
return errors.New(string(content))
|
||||
}
|
||||
return nil
|
||||
}, nil)
|
||||
}
|
||||
_ = taskItem.Execute()
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/pkg/jsonmessage"
|
||||
)
|
||||
|
||||
// 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 jsonmessage.JSONMessage
|
||||
if err := decoder.Decode(&message); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if message.Error != nil && message.Error.Message != "" {
|
||||
return message.Error
|
||||
}
|
||||
if message.ErrorMessage != "" {
|
||||
return errors.New(message.ErrorMessage)
|
||||
}
|
||||
if text := strings.TrimSpace(message.Stream); text != "" {
|
||||
log(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,10 @@ func (u *TaskLogService) ReadByLine(req request.TaskLogReadReq) (*response.FileL
|
||||
if req.TaskID != "" {
|
||||
opts = append(opts, taskRepo.WithByID(req.TaskID))
|
||||
} else {
|
||||
opts = append(opts, repo.WithOrderRuleBy("created_at", "desc"), repo.WithByType(req.TaskType), taskRepo.WithOperate(req.TaskOperate), taskRepo.WithResourceID(req.ResourceID))
|
||||
opts = append(opts, repo.WithOrderRuleBy("created_at", "desc"), repo.WithByType(req.TaskType), taskRepo.WithResourceID(req.ResourceID))
|
||||
if req.TaskOperate != "" {
|
||||
opts = append(opts, taskRepo.WithOperate(req.TaskOperate))
|
||||
}
|
||||
}
|
||||
taskModel, err := taskRepo.GetFirst(opts...)
|
||||
if err != nil {
|
||||
|
||||
@@ -15092,26 +15092,9 @@ const docTemplate = `{
|
||||
},
|
||||
"/databases/redis/install/cli": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"description": "request",
|
||||
"in": "body",
|
||||
"name": "request",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/dto.RedisCliInstall"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
"description": "OK"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
@@ -40166,14 +40149,6 @@ const docTemplate = `{
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"dto.RedisCliInstall": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"taskID": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dto.RedisConf": {
|
||||
"properties": {
|
||||
"containerName": {
|
||||
|
||||
@@ -15088,26 +15088,9 @@
|
||||
},
|
||||
"/databases/redis/install/cli": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"description": "request",
|
||||
"in": "body",
|
||||
"name": "request",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/dto.RedisCliInstall"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
"description": "OK"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
@@ -40162,14 +40145,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"dto.RedisCliInstall": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"taskID": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dto.RedisConf": {
|
||||
"properties": {
|
||||
"containerName": {
|
||||
|
||||
@@ -144,13 +144,8 @@ export const imagePull = (params: Container.ImagePull) => {
|
||||
export const imagePush = (params: Container.ImagePush) => {
|
||||
return http.post<string>(`/containers/image/push`, params);
|
||||
};
|
||||
export const imageLoad = (params: Container.ImageLoad, currentNode?: string) => {
|
||||
return http.post(
|
||||
`/containers/image/load`,
|
||||
params,
|
||||
TimeoutEnum.T_10M,
|
||||
currentNode ? { CurrentNode: currentNode } : undefined,
|
||||
);
|
||||
export const imageLoad = (params: Container.ImageLoad) => {
|
||||
return http.post(`/containers/image/load`, params, TimeoutEnum.T_10M);
|
||||
};
|
||||
export const imageSave = (params: Container.ImageSave) => {
|
||||
return http.post(`/containers/image/save`, params, TimeoutEnum.T_10M);
|
||||
|
||||
@@ -193,10 +193,8 @@ export const redisPersistenceConf = (type: string, database: string) => {
|
||||
export const checkRedisCli = () => {
|
||||
return http.get<boolean>(`/databases/redis/check`);
|
||||
};
|
||||
export const installRedisCli = (taskID: string, node: string) => {
|
||||
return http.post<string>(`/databases/redis/install/cli?operateNode=${encodeURIComponent(node)}`, {
|
||||
taskID,
|
||||
});
|
||||
export const installRedisCli = () => {
|
||||
return http.post(`/databases/redis/install/cli`, {}, TimeoutEnum.T_5M);
|
||||
};
|
||||
export const changeRedisPassword = (database: string, password: string) => {
|
||||
if (password) {
|
||||
|
||||
@@ -1065,6 +1065,13 @@ const message = {
|
||||
whiteListHelper: 'Restrict access to only IPs in the whitelist',
|
||||
},
|
||||
aiProxy: {
|
||||
deploymentNoticeTitle: 'AI Gateway supports standalone deployment',
|
||||
deploymentNoticeDescription:
|
||||
'AI Gateway now supports standalone deployment. The built-in AI Gateway in 1Panel will no longer receive updates. We recommend installing the standalone version from the App Store. You can also continue using the current version.',
|
||||
deploymentNoticeContinue: 'Understood, continue using',
|
||||
deploymentNoticeAppStore: 'Go to App Store',
|
||||
deploymentNoticeNoAppPermission:
|
||||
'You do not have access to the App Store. Contact your administrator to install the standalone version.',
|
||||
title: 'AI Gateway',
|
||||
apiReference: 'API Reference',
|
||||
notInstalled: 'Not installed',
|
||||
@@ -1869,11 +1876,6 @@ const message = {
|
||||
imageName: 'Image name',
|
||||
pull: 'Pull',
|
||||
path: 'Path',
|
||||
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.',
|
||||
imageUploadEmpty: 'The file is empty. Select a valid image archive.',
|
||||
imageImportSubmitted: 'Image import task submitted. Check the task log for the result.',
|
||||
importImage: 'Import',
|
||||
buildArgs: 'Build Arguments',
|
||||
imageBuild: 'Build',
|
||||
|
||||
@@ -1069,6 +1069,13 @@ const message = {
|
||||
whiteListHelper: 'Restringir el acceso solo a las IP incluidas en la lista blanca',
|
||||
},
|
||||
aiProxy: {
|
||||
deploymentNoticeTitle: 'AI Gateway admite la implementación independiente',
|
||||
deploymentNoticeDescription:
|
||||
'AI Gateway ahora admite la implementación independiente. AI Gateway integrado en 1Panel dejará de recibir actualizaciones. Recomendamos instalar la versión independiente desde la tienda de aplicaciones. También puede seguir usando la versión actual.',
|
||||
deploymentNoticeContinue: 'Entendido, seguir usando',
|
||||
deploymentNoticeAppStore: 'Ir a la tienda de aplicaciones',
|
||||
deploymentNoticeNoAppPermission:
|
||||
'No tiene acceso a la tienda de aplicaciones. Contacte con su administrador para instalar la versión independiente.',
|
||||
title: 'Gateway de IA',
|
||||
apiReference: 'Referencia de API',
|
||||
notInstalled: 'No instalado',
|
||||
@@ -1893,12 +1900,6 @@ const message = {
|
||||
imageName: 'Nombre de la imagen',
|
||||
pull: 'Descargar',
|
||||
path: 'Ruta',
|
||||
imageUploadDrop: 'Arrastre un archivo de imagen aquí o haga clic para subirlo',
|
||||
imageUploadHelper:
|
||||
'Se permite cualquier formato de archivo. Docker valida el contenido. Los archivos subidos se conservan para reintentar la importación o eliminarlos manualmente.',
|
||||
imageUploadEmpty: 'El archivo está vacío. Seleccione un archivo de imagen válido.',
|
||||
imageImportSubmitted:
|
||||
'Tarea de importación de imagen enviada. Consulte el registro de la tarea para ver el resultado.',
|
||||
importImage: 'Importar',
|
||||
buildArgs: 'Argumentos de Construcción',
|
||||
imageBuild: 'Construir',
|
||||
|
||||
@@ -1051,6 +1051,13 @@ const message = {
|
||||
whiteListHelper: 'دسترسی را فقط به IPهای موجود در لیست سفید محدود کنید',
|
||||
},
|
||||
aiProxy: {
|
||||
deploymentNoticeTitle: 'درگاه هوش مصنوعی از استقرار مستقل پشتیبانی میکند',
|
||||
deploymentNoticeDescription:
|
||||
'درگاه هوش مصنوعی اکنون از استقرار مستقل پشتیبانی میکند. درگاه هوش مصنوعی داخلی 1Panel دیگر بهروزرسانی دریافت نخواهد کرد. توصیه میکنیم نسخه مستقل را از فروشگاه برنامه نصب کنید. همچنین میتوانید به استفاده از نسخه فعلی ادامه دهید.',
|
||||
deploymentNoticeContinue: 'متوجه شدم، ادامه استفاده',
|
||||
deploymentNoticeAppStore: 'رفتن به فروشگاه برنامه',
|
||||
deploymentNoticeNoAppPermission:
|
||||
'شما به فروشگاه برنامه دسترسی ندارید. برای نصب نسخه مستقل با مدیر تماس بگیرید.',
|
||||
title: 'دروازه AI',
|
||||
apiReference: 'راهنمای API',
|
||||
notInstalled: 'نصب نشده',
|
||||
@@ -1853,11 +1860,6 @@ const message = {
|
||||
imageName: 'نام تصویر',
|
||||
pull: 'دریافت',
|
||||
path: 'مسیر',
|
||||
imageUploadDrop: 'فایل ایمیج را اینجا بکشید یا برای بارگذاری کلیک کنید',
|
||||
imageUploadHelper:
|
||||
'همه قالبهای فایل مجاز هستند. Docker محتوای فایل را اعتبارسنجی میکند. فایلهای بارگذاریشده برای تلاش مجدد جهت وارد کردن یا حذف دستی نگه داشته میشوند.',
|
||||
imageUploadEmpty: 'فایل خالی است. یک فایل ایمیج معتبر انتخاب کنید.',
|
||||
imageImportSubmitted: 'وظیفه وارد کردن ایمیج ثبت شد. برای مشاهده نتیجه، گزارش وظیفه را بررسی کنید.',
|
||||
importImage: 'واردات',
|
||||
buildArgs: 'آرگومانهای ساخت',
|
||||
imageBuild: 'ساخت',
|
||||
|
||||
@@ -1054,6 +1054,13 @@ const message = {
|
||||
whiteListHelper: 'ホワイトリスト内のIPのみアクセスを許可する',
|
||||
},
|
||||
aiProxy: {
|
||||
deploymentNoticeTitle: 'AI ゲートウェイの独立したデプロイに対応',
|
||||
deploymentNoticeDescription:
|
||||
'AI ゲートウェイは独立したデプロイに対応しました。1Panel 内蔵の AI ゲートウェイは今後更新されません。アプリストアから独立版をインストールすることをお勧めします。現在のバージョンを引き続き使用することもできます。',
|
||||
deploymentNoticeContinue: '了解して使用を継続',
|
||||
deploymentNoticeAppStore: 'アプリストアへ',
|
||||
deploymentNoticeNoAppPermission:
|
||||
'アプリストアへのアクセス権限がありません。管理者に独立版のインストールを依頼してください。',
|
||||
title: 'AI ゲートウェイ',
|
||||
apiReference: 'API リファレンス',
|
||||
notInstalled: '未インストール',
|
||||
@@ -1850,11 +1857,6 @@ const message = {
|
||||
imageName: '画像名',
|
||||
pull: '引く',
|
||||
path: 'パス',
|
||||
imageUploadDrop: 'イメージファイルをここにドラッグするか、クリックしてアップロードしてください',
|
||||
imageUploadHelper:
|
||||
'ファイル形式に制限はありません。Docker が内容を検証します。アップロードしたファイルは保持されるため、インポートを再試行したり、手動で削除したりできます。',
|
||||
imageUploadEmpty: 'ファイルが空です。有効なイメージファイルを選択してください。',
|
||||
imageImportSubmitted: 'イメージのインポートタスクを送信しました。結果はタスクログで確認してください。',
|
||||
importImage: '輸入',
|
||||
buildArgs: 'ビルド引数',
|
||||
imageBuild: '建てる',
|
||||
|
||||
@@ -1043,6 +1043,12 @@ const message = {
|
||||
whiteListHelper: '화이트리스트에 있는 IP만 접근 허용',
|
||||
},
|
||||
aiProxy: {
|
||||
deploymentNoticeTitle: 'AI 게이트웨이 독립 배포 지원',
|
||||
deploymentNoticeDescription:
|
||||
'AI 게이트웨이가 독립 배포를 지원합니다. 1Panel 내장 AI 게이트웨이는 더 이상 업데이트되지 않습니다. 앱 스토어에서 독립 버전을 설치하는 것을 권장합니다. 현재 버전을 계속 사용할 수도 있습니다.',
|
||||
deploymentNoticeContinue: '확인, 계속 사용',
|
||||
deploymentNoticeAppStore: '앱 스토어로 이동',
|
||||
deploymentNoticeNoAppPermission: '앱 스토어 접근 권한이 없습니다. 관리자에게 독립 버전 설치를 요청하세요.',
|
||||
title: 'AI 게이트웨이',
|
||||
apiReference: 'API 안내',
|
||||
notInstalled: '설치되지 않음',
|
||||
@@ -1833,11 +1839,6 @@ const message = {
|
||||
imageName: '이미지 이름',
|
||||
pull: '풀',
|
||||
path: '경로',
|
||||
imageUploadDrop: '이미지 파일을 여기로 끌어 놓거나 클릭하여 업로드하세요',
|
||||
imageUploadHelper:
|
||||
'파일 형식에는 제한이 없습니다. Docker가 내용을 검증합니다. 업로드한 파일은 보관되므로 가져오기를 다시 시도하거나 수동으로 삭제할 수 있습니다.',
|
||||
imageUploadEmpty: '빈 파일입니다. 유효한 이미지 파일을 선택하세요.',
|
||||
imageImportSubmitted: '이미지 가져오기 작업이 제출되었습니다. 작업 로그에서 결과를 확인하세요.',
|
||||
importImage: '가져오기',
|
||||
buildArgs: '빌드 인수',
|
||||
imageBuild: '이미지 빌드',
|
||||
|
||||
@@ -1040,6 +1040,13 @@ const message = {
|
||||
whiteListHelper: 'ຈຳກັດການເຂົ້າເຖິງສະເພາະ IP ໃນລາຍຊື່ທີ່ອະນຸຍາດ',
|
||||
},
|
||||
aiProxy: {
|
||||
deploymentNoticeTitle: 'AI Gateway ຮອງຮັບການຕິດຕັ້ງແບບແຍກຕ່າງຫາກ',
|
||||
deploymentNoticeDescription:
|
||||
'AI Gateway ຮອງຮັບການຕິດຕັ້ງແບບແຍກຕ່າງຫາກແລ້ວ. AI Gateway ທີ່ມາພ້ອມກັບ 1Panel ຈະບໍ່ໄດ້ຮັບການອັບເດດອີກ. ແນະນຳໃຫ້ຕິດຕັ້ງລຸ້ນແຍກຕ່າງຫາກຈາກຮ້ານແອັບ. ທ່ານຍັງສາມາດໃຊ້ລຸ້ນປັດຈຸບັນຕໍ່ໄດ້.',
|
||||
deploymentNoticeContinue: 'ຮັບຊາບ, ໃຊ້ຕໍ່',
|
||||
deploymentNoticeAppStore: 'ໄປທີ່ຮ້ານແອັບ',
|
||||
deploymentNoticeNoAppPermission:
|
||||
'ທ່ານບໍ່ມີສິດເຂົ້າເຖິງຮ້ານແອັບ. ກະລຸນາຕິດຕໍ່ຜູ້ດູແລເພື່ອຕິດຕັ້ງລຸ້ນແຍກຕ່າງຫາກ.',
|
||||
title: 'AI Gateway',
|
||||
apiReference: 'ຄູ່ມື API',
|
||||
notInstalled: 'ຍັງບໍ່ໄດ້ຕິດຕັ້ງ',
|
||||
@@ -1822,11 +1829,6 @@ const message = {
|
||||
imageName: 'ຊື່ Image',
|
||||
pull: 'ດຶງຂໍ້ມູນ (Pull)',
|
||||
path: 'ເສັ້ນທາງ',
|
||||
imageUploadDrop: 'ລາກໄຟລ໌ອິມເມຈມາວາງບ່ອນນີ້ ຫຼື ຄລິກເພື່ອອັບໂຫຼດ',
|
||||
imageUploadHelper:
|
||||
'ບໍ່ຈຳກັດຮູບແບບໄຟລ໌. Docker ຈະກວດສອບເນື້ອຫາ. ໄຟລ໌ທີ່ອັບໂຫຼດຈະຖືກເກັບໄວ້ ເພື່ອໃຫ້ສາມາດລອງນຳເຂົ້າອີກຄັ້ງ ຫຼື ລຶບດ້ວຍຕົນເອງ.',
|
||||
imageUploadEmpty: 'ໄຟລ໌ຫວ່າງເປົ່າ. ກະລຸນາເລືອກໄຟລ໌ອິມເມຈທີ່ຖືກຕ້ອງ.',
|
||||
imageImportSubmitted: 'ສົ່ງວຽກນຳເຂົ້າອິມເມຈແລ້ວ. ກະລຸນາເບິ່ງບັນທຶກວຽກເພື່ອກວດສອບຜົນ.',
|
||||
importImage: 'ນຳເຂົ້າ',
|
||||
buildArgs: 'Build Arguments',
|
||||
imageBuild: 'ສ້າງ (Build)',
|
||||
|
||||
@@ -1077,6 +1077,13 @@ const message = {
|
||||
whiteListHelper: 'Hadkan akses kepada hanya IP dalam senarai putih',
|
||||
},
|
||||
aiProxy: {
|
||||
deploymentNoticeTitle: 'AI Gateway menyokong penggunaan kendiri',
|
||||
deploymentNoticeDescription:
|
||||
'AI Gateway kini menyokong penggunaan kendiri. AI Gateway terbina dalam 1Panel tidak lagi akan menerima kemas kini. Kami mengesyorkan pemasangan versi kendiri daripada gedung aplikasi. Anda juga boleh terus menggunakan versi semasa.',
|
||||
deploymentNoticeContinue: 'Faham, terus gunakan',
|
||||
deploymentNoticeAppStore: 'Pergi ke gedung aplikasi',
|
||||
deploymentNoticeNoAppPermission:
|
||||
'Anda tidak mempunyai akses ke gedung aplikasi. Hubungi pentadbir untuk memasang versi kendiri.',
|
||||
title: 'Gateway AI',
|
||||
apiReference: 'Rujukan API',
|
||||
notInstalled: 'Belum dipasang',
|
||||
@@ -1882,11 +1889,6 @@ const message = {
|
||||
imageName: 'Nama imej',
|
||||
pull: 'Tarik',
|
||||
path: 'Laluan',
|
||||
imageUploadDrop: 'Seret fail imej ke sini atau klik untuk memuat naik',
|
||||
imageUploadHelper:
|
||||
'Semua format fail dibenarkan. Docker mengesahkan kandungannya. Fail yang dimuat naik disimpan untuk mencuba semula import atau dipadam secara manual.',
|
||||
imageUploadEmpty: 'Fail kosong. Pilih arkib imej yang sah.',
|
||||
imageImportSubmitted: 'Tugas import imej telah dihantar. Semak log tugas untuk melihat hasilnya.',
|
||||
importImage: 'Import',
|
||||
buildArgs: 'Argumen Binaan',
|
||||
imageBuild: 'Bina',
|
||||
|
||||
@@ -1072,6 +1072,13 @@ const message = {
|
||||
'Após ativar, você pode desativar o acesso externo à porta em Loja de Apps - Instalado - Ollama - Parâmetros para melhorar a segurança.',
|
||||
},
|
||||
aiProxy: {
|
||||
deploymentNoticeTitle: 'AI Gateway permite implantação independente',
|
||||
deploymentNoticeDescription:
|
||||
'O AI Gateway agora permite implantação independente. O AI Gateway integrado ao 1Panel não receberá mais atualizações. Recomendamos instalar a versão independente pela loja de aplicativos. Você também pode continuar usando a versão atual.',
|
||||
deploymentNoticeContinue: 'Entendido, continuar usando',
|
||||
deploymentNoticeAppStore: 'Ir à loja de aplicativos',
|
||||
deploymentNoticeNoAppPermission:
|
||||
'Você não tem acesso à loja de aplicativos. Contate o administrador para instalar a versão independente.',
|
||||
title: 'Gateway de IA',
|
||||
apiReference: 'Referência da API',
|
||||
notInstalled: 'Não instalado',
|
||||
@@ -1884,11 +1891,6 @@ const message = {
|
||||
imageName: 'Nome da imagem',
|
||||
pull: 'Puxar',
|
||||
path: 'Caminho',
|
||||
imageUploadDrop: 'Arraste um arquivo de imagem para cá ou clique para enviar',
|
||||
imageUploadHelper:
|
||||
'Qualquer formato de arquivo é permitido. O Docker valida o conteúdo. Os arquivos enviados são mantidos para novas tentativas de importação ou exclusão manual.',
|
||||
imageUploadEmpty: 'O arquivo está vazio. Selecione um arquivo de imagem válido.',
|
||||
imageImportSubmitted: 'Tarefa de importação de imagem enviada. Consulte o log da tarefa para ver o resultado.',
|
||||
importImage: 'Importar',
|
||||
buildArgs: 'Argumentos de Build',
|
||||
imageBuild: 'Construção de imagem',
|
||||
|
||||
@@ -1065,6 +1065,13 @@ const message = {
|
||||
whiteListHelper: 'Ограничить доступ только для IP-адресов из белого списка',
|
||||
},
|
||||
aiProxy: {
|
||||
deploymentNoticeTitle: 'AI Gateway поддерживает отдельное развёртывание',
|
||||
deploymentNoticeDescription:
|
||||
'AI Gateway теперь поддерживает отдельное развёртывание. Встроенный AI Gateway в 1Panel больше не будет получать обновления. Рекомендуем установить отдельную версию из магазина приложений. Вы также можете продолжить использовать текущую версию.',
|
||||
deploymentNoticeContinue: 'Понятно, продолжить использование',
|
||||
deploymentNoticeAppStore: 'Перейти в магазин приложений',
|
||||
deploymentNoticeNoAppPermission:
|
||||
'У вас нет доступа к магазину приложений. Обратитесь к администратору для установки отдельной версии.',
|
||||
title: 'AI-шлюз',
|
||||
apiReference: 'Справочник API',
|
||||
notInstalled: 'Не установлен',
|
||||
@@ -1874,11 +1881,6 @@ const message = {
|
||||
imageName: 'Имя образа',
|
||||
pull: 'Загрузить',
|
||||
path: 'Путь',
|
||||
imageUploadDrop: 'Перетащите файл образа сюда или нажмите для загрузки',
|
||||
imageUploadHelper:
|
||||
'Допускаются файлы любого формата. Docker проверяет содержимое. Загруженные файлы сохраняются для повторного импорта или удаления вручную.',
|
||||
imageUploadEmpty: 'Файл пуст. Выберите корректный архив образа.',
|
||||
imageImportSubmitted: 'Задача импорта образа отправлена. Результат можно посмотреть в журнале задачи.',
|
||||
importImage: 'Импорт',
|
||||
buildArgs: 'Аргументы Сборки',
|
||||
imageBuild: 'Сборка',
|
||||
|
||||
@@ -1076,6 +1076,13 @@ const message = {
|
||||
whiteListHelper: 'Erişimi yalnızca beyaz listedeki IPlerle sınırlayın',
|
||||
},
|
||||
aiProxy: {
|
||||
deploymentNoticeTitle: 'AI Gateway bağımsız dağıtımı destekliyor',
|
||||
deploymentNoticeDescription:
|
||||
'AI Gateway artık bağımsız dağıtımı destekliyor. 1Panel içindeki AI Gateway artık güncelleme almayacak. Uygulama mağazasından bağımsız sürümü yüklemenizi öneririz. Mevcut sürümü kullanmaya da devam edebilirsiniz.',
|
||||
deploymentNoticeContinue: 'Anladım, kullanmaya devam et',
|
||||
deploymentNoticeAppStore: 'Uygulama mağazasına git',
|
||||
deploymentNoticeNoAppPermission:
|
||||
'Uygulama mağazasına erişim izniniz yok. Bağımsız sürümü yüklemek için yöneticinizle iletişime geçin.',
|
||||
title: 'AI Ağ Geçidi',
|
||||
apiReference: 'API Referansı',
|
||||
notInstalled: 'Kurulu değil',
|
||||
@@ -1883,11 +1890,6 @@ const message = {
|
||||
imageName: 'İmaj adı',
|
||||
pull: 'Çek',
|
||||
path: 'Yol',
|
||||
imageUploadDrop: 'Bir imaj dosyasını buraya sürükleyin veya yüklemek için tıklayın',
|
||||
imageUploadHelper:
|
||||
'Tüm dosya biçimlerine izin verilir. Docker içeriği doğrular. Yüklenen dosyalar, içe aktarmayı yeniden denemek veya elle silmek için saklanır.',
|
||||
imageUploadEmpty: 'Dosya boş. Geçerli bir imaj arşivi seçin.',
|
||||
imageImportSubmitted: 'İmajı içe aktarma görevi gönderildi. Sonuç için görev günlüğünü kontrol edin.',
|
||||
importImage: 'İçe aktar',
|
||||
buildArgs: 'Derleme Argümanları',
|
||||
imageBuild: 'Oluştur',
|
||||
|
||||
@@ -1006,6 +1006,12 @@ const message = {
|
||||
whiteListHelper: '限制僅白名單中的 IP 可瀏覽',
|
||||
},
|
||||
aiProxy: {
|
||||
deploymentNoticeTitle: 'AI 網關已支援獨立部署',
|
||||
deploymentNoticeDescription:
|
||||
'AI 網關現已支援獨立部署,1Panel 內建 AI 網關後續將不再更新。建議前往應用程式商店安裝獨立版本,您也可以繼續使用目前版本。',
|
||||
deploymentNoticeContinue: '已知悉,繼續使用',
|
||||
deploymentNoticeAppStore: '前往應用程式商店',
|
||||
deploymentNoticeNoAppPermission: '您沒有應用程式商店存取權限,請聯絡管理員安裝獨立版本。',
|
||||
title: 'AI 閘道',
|
||||
apiReference: '介面說明',
|
||||
notInstalled: '未安裝',
|
||||
@@ -1773,10 +1779,6 @@ const message = {
|
||||
delInsecureHelper: '刪除授信需要重新啟動 docker 服務,是否刪除?',
|
||||
pull: '拉取',
|
||||
path: '路徑',
|
||||
imageUploadDrop: '拖曳映像檔案至此處,或點擊上傳',
|
||||
imageUploadHelper: '不限制檔案格式,由 Docker 驗證內容。上傳的檔案會保留,可重試匯入或手動刪除。',
|
||||
imageUploadEmpty: '不能上傳空檔案,請選擇有效的映像檔案。',
|
||||
imageImportSubmitted: '映像匯入任務已提交,請查看任務日誌',
|
||||
importImage: '匯入映像',
|
||||
imageBuild: '構建映像',
|
||||
buildArgs: '構建參數',
|
||||
|
||||
@@ -1019,6 +1019,12 @@ const message = {
|
||||
whiteListHelper: '限制仅白名单中的 IP 可访问',
|
||||
},
|
||||
aiProxy: {
|
||||
deploymentNoticeTitle: 'AI 网关已支持独立部署',
|
||||
deploymentNoticeDescription:
|
||||
'AI 网关现已支持独立部署,1Panel 内置 AI 网关后续将不再更新。建议前往应用商店安装独立版本,您也可以继续使用当前版本。',
|
||||
deploymentNoticeContinue: '已知晓,继续使用',
|
||||
deploymentNoticeAppStore: '前往应用商店',
|
||||
deploymentNoticeNoAppPermission: '您没有应用商店访问权限,请联系管理员安装独立版本。',
|
||||
title: 'AI 网关',
|
||||
apiReference: '接口说明',
|
||||
notInstalled: '未安装',
|
||||
@@ -1793,10 +1799,6 @@ const message = {
|
||||
delInsecureHelper: '删除授信需要重启 docker 服务,是否删除?',
|
||||
pull: '拉取',
|
||||
path: '路径',
|
||||
imageUploadDrop: '拖拽镜像文件到此处,或点击上传',
|
||||
imageUploadHelper: '不限制文件格式,由 Docker 校验内容。上传的文件会保留,可重试导入或手动删除。',
|
||||
imageUploadEmpty: '不能上传空文件,请选择有效的镜像文件。',
|
||||
imageImportSubmitted: '镜像导入任务已提交,请查看任务日志',
|
||||
importImage: '导入镜像',
|
||||
imageBuild: '构建镜像',
|
||||
buildArgs: '构建参数',
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { chunkUploadFileData, type FileUploadRequestConfig } from '@/api/modules/files';
|
||||
|
||||
export const CHUNK_SIZE = 5 * 1024 * 1024;
|
||||
const MAX_CHUNK_RETRIES = 3;
|
||||
|
||||
const shouldRetryChunkUpload = (error: unknown) => {
|
||||
const item = error as {
|
||||
code?: string | number;
|
||||
data?: { retryable?: boolean };
|
||||
response?: { status?: number };
|
||||
};
|
||||
if (item?.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
}
|
||||
if (typeof item?.data?.retryable === 'boolean') {
|
||||
return item.data.retryable;
|
||||
}
|
||||
const status = item?.response?.status ?? (typeof item?.code === 'number' ? item.code : undefined);
|
||||
return !status || status === 408 || status === 429 || status >= 500;
|
||||
};
|
||||
|
||||
const waitForChunkRetry = (attempt: number, signal: AbortSignal) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (signal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(done, 500 * 2 ** attempt);
|
||||
function done() {
|
||||
window.clearTimeout(timer);
|
||||
signal.removeEventListener('abort', done);
|
||||
resolve();
|
||||
}
|
||||
signal.addEventListener('abort', done, { once: true });
|
||||
});
|
||||
};
|
||||
|
||||
export const uploadChunkWithRetry = async (
|
||||
formData: FormData,
|
||||
config: FileUploadRequestConfig,
|
||||
signal: AbortSignal,
|
||||
) => {
|
||||
let retryCount = 0;
|
||||
while (true) {
|
||||
signal.throwIfAborted();
|
||||
try {
|
||||
await chunkUploadFileData(formData, config);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (signal.aborted || retryCount >= MAX_CHUNK_RETRIES || !shouldRetryChunkUpload(error)) {
|
||||
throw error;
|
||||
}
|
||||
await waitForChunkRetry(retryCount, signal);
|
||||
retryCount++;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,254 +1,105 @@
|
||||
<template>
|
||||
<DrawerPro
|
||||
v-model="loadVisible"
|
||||
:header="$t('container.importImage')"
|
||||
:auto-close="!busy"
|
||||
:confirm-before-close="true"
|
||||
@before-close="beforeClose"
|
||||
size="small"
|
||||
>
|
||||
<el-form @submit.prevent label-position="top">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<el-button :disabled="busy" @click="source = 'local'">
|
||||
{{ $t('database.localUpload') }}
|
||||
</el-button>
|
||||
<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" />
|
||||
<DrawerPro v-model="loadVisible" :header="$t('container.importImage')" @close="handleClose" size="small">
|
||||
<el-form @submit.prevent v-loading="loading" ref="formRef" :model="form" label-position="top">
|
||||
<el-form-item :label="$t('container.path')" :rules="Rules.requiredInput" prop="path">
|
||||
<el-input v-model="form.path" @input="handlePathChange">
|
||||
<template #prepend>
|
||||
<el-button icon="Folder" @click="fileRef.acceptParams({ dir: false, multiple: true })" />
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<template v-else>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
drag
|
||||
:limit="1"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:disabled="busy"
|
||||
: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"
|
||||
/>
|
||||
<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>
|
||||
</template>
|
||||
</template>
|
||||
<el-button v-if="submittedTaskID" link type="primary" @click="openTaskLog">
|
||||
{{ $t('commons.button.log') }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="busy" @click="loadVisible = false">{{ $t('commons.button.cancel') }}</el-button>
|
||||
<el-button
|
||||
v-if="source === 'server'"
|
||||
:disabled="busy || !!submittedTaskID || !form.paths.length"
|
||||
:loading="loading"
|
||||
type="primary"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ $t('commons.button.import') }}
|
||||
</el-button>
|
||||
<span class="dialog-footer">
|
||||
<el-button :disabled="loading" @click="loadVisible = false">
|
||||
{{ $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') }}
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</DrawerPro>
|
||||
<FileList v-if="loadVisible" ref="fileRef" @choose="loadLoadDir" />
|
||||
<TaskLog ref="taskLogRef" width="70%" @close="emit('search')" />
|
||||
<FileList ref="fileRef" @choose="loadLoadDir" />
|
||||
<TaskLog ref="taskLogRef" width="70%" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import FileList from '@/components/file-list/index.vue';
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
|
||||
import { genFileId, type UploadFile, type UploadInstance, type UploadProps, type UploadRawFile } from 'element-plus';
|
||||
import { reactive, ref } from 'vue';
|
||||
import { Rules } from '@/global/form-rules';
|
||||
import TaskLog from '@/components/log/task/index.vue';
|
||||
import i18n from '@/lang';
|
||||
import { ElForm } from 'element-plus';
|
||||
import { imageLoad } from '@/api/modules/container';
|
||||
import { stopChunkUpload } from '@/api/modules/files';
|
||||
import { loadBaseDir } from '@/api/modules/setting';
|
||||
import { useGlobalStore } from '@/composables/useGlobalStore';
|
||||
import { MsgError, MsgSuccess } from '@/utils/message';
|
||||
import { computeSize } from '@/utils/size';
|
||||
import { MsgSuccess } from '@/utils/message';
|
||||
import { newUUID } from '@/utils/id';
|
||||
import { CHUNK_SIZE, uploadChunkWithRetry } from '@/utils/chunk-upload';
|
||||
|
||||
const { currentNode } = useGlobalStore();
|
||||
const loading = ref(false);
|
||||
const uploading = ref(false);
|
||||
const busy = computed(() => loading.value || uploading.value);
|
||||
const fileRef = ref();
|
||||
const taskLogRef = ref();
|
||||
const uploadRef = ref<UploadInstance>();
|
||||
|
||||
const loadVisible = ref(false);
|
||||
const source = ref<'server' | 'local'>('local');
|
||||
const operateNode = ref('');
|
||||
const form = reactive({ path: '', paths: [] as string[] });
|
||||
const localFile = ref<UploadRawFile>();
|
||||
const uploadPercent = ref(0);
|
||||
const uploadFailed = ref(false);
|
||||
const uploadedPath = ref('');
|
||||
const submittedTaskID = ref('');
|
||||
let uploadID = '';
|
||||
let uploadController: AbortController | undefined;
|
||||
let disposed = false;
|
||||
const emit = defineEmits<{ (e: 'search'): void }>();
|
||||
const form = reactive({
|
||||
path: '',
|
||||
paths: [] as string[],
|
||||
taskID: '',
|
||||
});
|
||||
|
||||
const acceptParams = () => {
|
||||
if (busy.value) return;
|
||||
operateNode.value = currentNode.value;
|
||||
source.value = 'local';
|
||||
loadVisible.value = true;
|
||||
form.path = '';
|
||||
form.paths = [];
|
||||
localFile.value = undefined;
|
||||
uploadedPath.value = '';
|
||||
uploadPercent.value = 0;
|
||||
uploadFailed.value = false;
|
||||
submittedTaskID.value = '';
|
||||
uploadRef.value?.clearFiles();
|
||||
loadVisible.value = true;
|
||||
};
|
||||
const beforeClose = (done: () => void) => {
|
||||
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) return;
|
||||
if (file.raw.size === 0) {
|
||||
MsgError(i18n.global.t('container.imageUploadEmpty'));
|
||||
uploadRef.value?.clearFiles();
|
||||
return;
|
||||
}
|
||||
localFile.value = file.raw;
|
||||
uploadedPath.value = '';
|
||||
submittedTaskID.value = '';
|
||||
void uploadLocalFile();
|
||||
};
|
||||
const onFileExceed: UploadProps['onExceed'] = (files) => {
|
||||
if (busy.value || !files.length) return;
|
||||
uploadRef.value?.clearFiles();
|
||||
const file = files[0] as UploadRawFile;
|
||||
file.uid = genFileId();
|
||||
uploadRef.value?.handleStart(file);
|
||||
const handleClose = () => {
|
||||
loadVisible.value = false;
|
||||
};
|
||||
|
||||
const uploadLocalFile = async () => {
|
||||
const file = localFile.value;
|
||||
if (!file || busy.value) return;
|
||||
uploading.value = true;
|
||||
uploadFailed.value = false;
|
||||
uploadPercent.value = 0;
|
||||
const node = operateNode.value;
|
||||
const id = newUUID();
|
||||
uploadID = id;
|
||||
const controller = new AbortController();
|
||||
uploadController = controller;
|
||||
try {
|
||||
const { data: baseDir } = await loadBaseDir(node);
|
||||
controller.signal.throwIfAborted();
|
||||
const directory = `${baseDir}/uploads/image/${id}`;
|
||||
const count = Math.ceil(file.size / CHUNK_SIZE);
|
||||
for (let index = 0; index < count; index++) {
|
||||
const data = new FormData();
|
||||
data.append('filename', file.name);
|
||||
data.append('path', directory);
|
||||
data.append('uploadID', id);
|
||||
data.append('chunk', file.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE));
|
||||
data.append('chunkIndex', String(index));
|
||||
data.append('chunkCount', String(count));
|
||||
data.append('offset', String(index * CHUNK_SIZE));
|
||||
data.append('fileSize', String(file.size));
|
||||
await uploadChunkWithRetry(
|
||||
data,
|
||||
{
|
||||
headers: { CurrentNode: node },
|
||||
signal: controller.signal,
|
||||
skipErrorMessage: true,
|
||||
timeout: 0,
|
||||
onUploadProgress: (event: { total?: number; loaded: number }) => {
|
||||
const fraction = event.total ? Math.min(1, event.loaded / event.total) : 0;
|
||||
uploadPercent.value = Math.min(99, Math.floor(((index + fraction) * 100) / count));
|
||||
},
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
}
|
||||
controller.signal.throwIfAborted();
|
||||
uploadedPath.value = `${directory}/${file.name}`;
|
||||
uploadPercent.value = 100;
|
||||
} catch {
|
||||
if (!controller.signal.aborted) uploadFailed.value = true;
|
||||
await stopChunkUpload(id, node).catch(() => {});
|
||||
return;
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
uploadController = undefined;
|
||||
uploadID = '';
|
||||
}
|
||||
if (!disposed && currentNode.value === node) await onSubmit();
|
||||
const emit = defineEmits<{ (e: 'search'): void }>();
|
||||
|
||||
type FormInstance = InstanceType<typeof ElForm>;
|
||||
const formRef = ref<FormInstance>();
|
||||
|
||||
const onSubmit = async (formEl: FormInstance | undefined) => {
|
||||
if (!formEl || !form.path.trim() || form.paths.length === 0) return;
|
||||
formEl.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
loading.value = true;
|
||||
form.taskID = newUUID();
|
||||
await imageLoad({ paths: form.paths, taskID: form.taskID })
|
||||
.then(() => {
|
||||
loading.value = false;
|
||||
loadVisible.value = false;
|
||||
openTaskLog(form.taskID);
|
||||
emit('search');
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
})
|
||||
.catch(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const openTaskLog = () => {
|
||||
taskLogRef.value.openWithTaskID(submittedTaskID.value, true, operateNode.value);
|
||||
};
|
||||
const onSubmit = async () => {
|
||||
const paths = source.value === 'local' ? [uploadedPath.value].filter(Boolean) : [...form.paths];
|
||||
if (busy.value || submittedTaskID.value || !paths.length) return;
|
||||
loading.value = true;
|
||||
const taskID = newUUID();
|
||||
const node = operateNode.value;
|
||||
submittedTaskID.value = taskID;
|
||||
try {
|
||||
await imageLoad({ paths, taskID }, node);
|
||||
if (disposed || currentNode.value !== node) return;
|
||||
loadVisible.value = false;
|
||||
openTaskLog();
|
||||
MsgSuccess(i18n.global.t('container.imageImportSubmitted'));
|
||||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
const openTaskLog = (taskID: string) => {
|
||||
taskLogRef.value.openWithTaskID(taskID);
|
||||
};
|
||||
|
||||
const handlePathChange = () => {
|
||||
submittedTaskID.value = '';
|
||||
form.paths = [
|
||||
...new Set(
|
||||
form.path
|
||||
.split(';')
|
||||
.map((path) => path.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
if (!form.path.trim()) {
|
||||
form.paths = [];
|
||||
}
|
||||
};
|
||||
const loadLoadDir = (paths: string | string[]) => {
|
||||
if (currentNode.value !== operateNode.value) return;
|
||||
source.value = 'server';
|
||||
submittedTaskID.value = '';
|
||||
form.paths = [...new Set(Array.isArray(paths) ? paths : [paths])];
|
||||
|
||||
const loadLoadDir = async (paths: string | string[]) => {
|
||||
const newPaths = Array.isArray(paths) ? paths : [paths];
|
||||
form.paths = [...new Set(newPaths)];
|
||||
form.path = form.paths.join('; ');
|
||||
};
|
||||
watch(currentNode, () => {
|
||||
loadVisible.value = false;
|
||||
uploadController?.abort();
|
||||
|
||||
defineExpose({
|
||||
acceptParams,
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
uploadController?.abort();
|
||||
if (uploadID) void stopChunkUpload(uploadID, operateNode.value).catch(() => {});
|
||||
});
|
||||
defineExpose({ acceptParams });
|
||||
</script>
|
||||
|
||||
@@ -81,13 +81,7 @@
|
||||
:style="{ height: `calc(100vh - ${loadHeight()})`, 'background-color': '#000' }"
|
||||
:description="loadErrMsg()"
|
||||
>
|
||||
<el-button
|
||||
v-if="currentDB.from === 'remote'"
|
||||
v-permission
|
||||
type="primary"
|
||||
:loading="cliSubmitting"
|
||||
@click="installCli"
|
||||
>
|
||||
<el-button v-if="currentDB.from === 'remote'" v-permission type="primary" @click="installCli">
|
||||
{{ $t('commons.button.enable') }}
|
||||
</el-button>
|
||||
</el-empty>
|
||||
@@ -140,7 +134,6 @@
|
||||
</DialogPro>
|
||||
|
||||
<QuickCmd ref="dialogQuickCmdRef" @reload="loadQuickCmd" />
|
||||
<TaskLog ref="cliTaskLogRef" @close="checkCliValid" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -155,14 +148,12 @@ import { checkAppInstalled } from '@/api/modules/app';
|
||||
import { useGlobalStore } from '@/composables/useGlobalStore';
|
||||
import { listDatabases, checkRedisCli, installRedisCli } from '@/api/modules/database';
|
||||
import { Database } from '@/api/interface/database';
|
||||
import { MsgSuccess } from '@/utils/message';
|
||||
import i18n from '@/lang';
|
||||
import { getCommandList } from '@/api/modules/command';
|
||||
import { routerToName, routerToNameWithQuery } from '@/utils/router';
|
||||
import { useOperateNodeContext } from '@/composables/useOperateNodeContext';
|
||||
|
||||
import TaskLog from '@/components/log/task/index.vue';
|
||||
import { newUUID } from '@/utils/id';
|
||||
|
||||
const { currentNode, currentRedisDB, openMenuTabs } = useGlobalStore();
|
||||
useOperateNodeContext(currentNode);
|
||||
|
||||
@@ -179,11 +170,7 @@ const appStatusRef = ref();
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
const redisCliExist = ref(false);
|
||||
const cliSubmitting = ref(false);
|
||||
const cliTaskLogRef = ref<InstanceType<typeof TaskLog>>();
|
||||
const operateNode = currentNode.value;
|
||||
let disposed = false;
|
||||
const redisCliExist = ref();
|
||||
|
||||
const appKey = ref('redis');
|
||||
const appName = ref();
|
||||
@@ -263,7 +250,6 @@ const changeDatabase = async () => {
|
||||
const loadDBOptions = async () => {
|
||||
try {
|
||||
const res = await listDatabases('redis,redis-cluster');
|
||||
if (disposed || operateNode !== currentNode.value) return;
|
||||
let datas = res.data || [];
|
||||
dbOptionsLocal.value = [];
|
||||
dbOptionsRemote.value = [];
|
||||
@@ -300,7 +286,7 @@ const loadDBOptions = async () => {
|
||||
reOpenTerminal();
|
||||
}
|
||||
} finally {
|
||||
if (!disposed && operateNode === currentNode.value) isLoaded.value = true;
|
||||
isLoaded.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -315,9 +301,6 @@ const reOpenTerminal = async () => {
|
||||
};
|
||||
|
||||
const initTerminal = async () => {
|
||||
if (disposed || !currentDB.value) return;
|
||||
const database = currentDBName.value;
|
||||
const isCurrent = () => !disposed && operateNode === currentNode.value && database === currentDBName.value;
|
||||
loading.value = true;
|
||||
if (currentDB.value.from === 'remote') {
|
||||
if (!redisCliExist.value) {
|
||||
@@ -328,10 +311,9 @@ const initTerminal = async () => {
|
||||
loading.value = false;
|
||||
redisIsExist.value = true;
|
||||
nextTick(() => {
|
||||
if (!isCurrent()) return;
|
||||
terminalShow.value = true;
|
||||
redisStatus.value = 'Running';
|
||||
terminalRef.value?.acceptParams({
|
||||
terminalRef.value.acceptParams({
|
||||
endpoint: '/api/v2/hosts/terminal/container',
|
||||
args: `source=redis&name=${currentDBName.value}&from=${currentDB.value.from}`,
|
||||
error: '',
|
||||
@@ -343,15 +325,13 @@ const initTerminal = async () => {
|
||||
}
|
||||
await checkAppInstalled(currentDB.value.type, currentDBName.value)
|
||||
.then((res) => {
|
||||
if (!isCurrent()) return;
|
||||
redisIsExist.value = res.data.isExist;
|
||||
redisStatus.value = res.data.status;
|
||||
loading.value = false;
|
||||
nextTick(() => {
|
||||
if (!isCurrent()) return;
|
||||
if (res.data.status === 'Running') {
|
||||
terminalShow.value = true;
|
||||
terminalRef.value?.acceptParams({
|
||||
terminalRef.value.acceptParams({
|
||||
endpoint: '/api/v2/hosts/terminal/container',
|
||||
args: `source=${currentDB.value.type}&name=${currentDBName.value}&from=${currentDB.value.from}`,
|
||||
error: '',
|
||||
@@ -362,7 +342,6 @@ const initTerminal = async () => {
|
||||
isRefresh.value = !isRefresh.value;
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCurrent()) return;
|
||||
closeTerminal(false);
|
||||
loading.value = false;
|
||||
});
|
||||
@@ -374,30 +353,27 @@ const closeTerminal = async (isKeepShow: boolean) => {
|
||||
};
|
||||
|
||||
const checkCliValid = async () => {
|
||||
if (disposed || operateNode !== currentNode.value) return;
|
||||
try {
|
||||
const res = await checkRedisCli();
|
||||
if (disposed || operateNode !== currentNode.value) return;
|
||||
redisCliExist.value = res.data;
|
||||
} finally {
|
||||
if (!disposed && operateNode === currentNode.value) await loadDBOptions();
|
||||
}
|
||||
await checkRedisCli()
|
||||
.then((res) => {
|
||||
redisCliExist.value = res.data;
|
||||
loadDBOptions();
|
||||
})
|
||||
.catch(() => {
|
||||
loadDBOptions();
|
||||
});
|
||||
};
|
||||
|
||||
const installCli = async () => {
|
||||
if (cliSubmitting.value) return;
|
||||
cliSubmitting.value = true;
|
||||
try {
|
||||
const res = await installRedisCli(newUUID(), operateNode);
|
||||
if (disposed || operateNode !== currentNode.value) return;
|
||||
if (res.data) {
|
||||
cliTaskLogRef.value?.openWithTaskID(res.data, true, operateNode);
|
||||
} else {
|
||||
await checkCliValid();
|
||||
}
|
||||
} finally {
|
||||
cliSubmitting.value = false;
|
||||
}
|
||||
loading.value = true;
|
||||
await installRedisCli()
|
||||
.then(() => {
|
||||
loading.value = false;
|
||||
redisCliExist.value = true;
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
reOpenTerminal();
|
||||
})
|
||||
.catch(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const loadQuickCmd = async () => {
|
||||
@@ -427,7 +403,6 @@ const onAfter = () => {
|
||||
initTerminal();
|
||||
};
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
closeTerminal(false);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -121,8 +121,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref } from 'vue';
|
||||
import { UploadFile, UploadFiles, UploadInstance, UploadProps, UploadRawFile } from 'element-plus';
|
||||
import { batchCheckFiles, stopChunkUpload, uploadFileData } from '@/api/modules/files';
|
||||
import { CHUNK_SIZE, uploadChunkWithRetry } from '@/utils/chunk-upload';
|
||||
import {
|
||||
batchCheckFiles,
|
||||
chunkUploadFileData,
|
||||
stopChunkUpload,
|
||||
uploadFileData,
|
||||
type FileUploadRequestConfig,
|
||||
} from '@/api/modules/files';
|
||||
import i18n from '@/lang';
|
||||
import { MsgError, MsgSuccess, MsgWarning } from '@/utils/message';
|
||||
import { Close, Document, UploadFilled } from '@element-plus/icons-vue';
|
||||
@@ -220,6 +225,8 @@ const uploaderFiles = ref<UploadFiles>([]);
|
||||
const hoverIndex = ref<number | null>(null);
|
||||
const tmpFiles = ref<UploadFiles>([]);
|
||||
const breakFlag = ref(false);
|
||||
const CHUNK_SIZE = 1024 * 1024 * 5;
|
||||
const MAX_CHUNK_RETRIES = 3;
|
||||
|
||||
const upload = (command: string) => {
|
||||
if (uploadLocked.value) {
|
||||
@@ -497,6 +504,54 @@ const cleanupActiveChunkUpload = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const shouldRetryChunkUpload = (error: unknown) => {
|
||||
const item = error as {
|
||||
code?: string | number;
|
||||
data?: { retryable?: boolean };
|
||||
response?: { status?: number };
|
||||
};
|
||||
if (item?.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
}
|
||||
if (typeof item?.data?.retryable === 'boolean') {
|
||||
return item.data.retryable;
|
||||
}
|
||||
const status = item?.response?.status ?? (typeof item?.code === 'number' ? item.code : undefined);
|
||||
return !status || status === 408 || status === 429 || status >= 500;
|
||||
};
|
||||
|
||||
const waitForChunkRetry = (attempt: number, signal: AbortSignal) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (signal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(done, 500 * 2 ** attempt);
|
||||
function done() {
|
||||
window.clearTimeout(timer);
|
||||
signal.removeEventListener('abort', done);
|
||||
resolve();
|
||||
}
|
||||
signal.addEventListener('abort', done, { once: true });
|
||||
});
|
||||
};
|
||||
|
||||
const uploadChunkWithRetry = async (formData: FormData, config: FileUploadRequestConfig, signal: AbortSignal) => {
|
||||
let retryCount = 0;
|
||||
while (true) {
|
||||
try {
|
||||
await chunkUploadFileData(formData, config);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (signal.aborted || retryCount >= MAX_CHUNK_RETRIES || !shouldRetryChunkUpload(error)) {
|
||||
throw error;
|
||||
}
|
||||
await waitForChunkRetry(retryCount, signal);
|
||||
retryCount++;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const uploadChunkedFile = async (
|
||||
file: { size: number; raw: Blob; name: string },
|
||||
controller: AbortController,
|
||||
|
||||
Reference in New Issue
Block a user