mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-10-30 08:05:43 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b6328fd04 | ||
|
|
e9c2162921 |
@@ -0,0 +1,43 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/app/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (b *BaseApi) GetContainerImageCheckUpdateStatus(c *gin.Context) {
|
||||||
|
status, err := service.GetContainerImageCheckUpdateStatus()
|
||||||
|
if err != nil {
|
||||||
|
helper.InternalServer(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
helper.SuccessWithData(c, status)
|
||||||
|
}
|
||||||
|
func (b *BaseApi) ContainerImageCheckUpdate(c *gin.Context) {
|
||||||
|
taskID, err := service.StartContainerImageCheckUpdate()
|
||||||
|
if err != nil {
|
||||||
|
helper.InternalServer(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status, err := service.GetContainerImageCheckUpdateStatus()
|
||||||
|
if err != nil {
|
||||||
|
helper.InternalServer(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status.TaskID = taskID
|
||||||
|
helper.SuccessWithData(c, status)
|
||||||
|
}
|
||||||
|
func (b *BaseApi) SetContainerImageCheckUpdateSchedule(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Enabled *bool `json:"enabled" validate:"required"`
|
||||||
|
}
|
||||||
|
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := service.SetContainerImageCheckUpdateSchedule(*req.Enabled); err != nil {
|
||||||
|
helper.InternalServer(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.GetContainerImageCheckUpdateStatus(c)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/app/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (b *BaseApi) GetImageRemoteCheckStatus(c *gin.Context) {
|
||||||
|
status, err := service.GetImageRemoteCheckStatus()
|
||||||
|
if err != nil {
|
||||||
|
helper.InternalServer(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
helper.SuccessWithData(c, status)
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@ type InspectReq struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ContainerInfo struct {
|
type ContainerInfo struct {
|
||||||
|
LatestVersion string `json:"latestVersion"`
|
||||||
|
TargetImage string `json:"targetImage"`
|
||||||
|
HasUpdate bool `json:"hasUpdate"`
|
||||||
ContainerID string `json:"containerID"`
|
ContainerID string `json:"containerID"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ImageId string `json:"imageID"`
|
ImageId string `json:"imageID"`
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ContainerImageCheckUpdate is a cache tied to the exact image used by a container.
|
||||||
|
type ContainerImageCheckUpdate struct {
|
||||||
|
ContainerID string `gorm:"primaryKey" json:"containerID"`
|
||||||
|
ImageID string `json:"imageID"`
|
||||||
|
ImageName string `json:"imageName"`
|
||||||
|
LatestVersion string `json:"latestVersion"`
|
||||||
|
TargetImage string `json:"targetImage"`
|
||||||
|
TargetImageID string `json:"-"`
|
||||||
|
HasUpdate bool `gorm:"not null;default:false" json:"hasUpdate"`
|
||||||
|
CheckedAt time.Time `json:"checkedAt"`
|
||||||
|
CheckStatus string `json:"checkStatus"`
|
||||||
|
CheckError string `json:"checkError"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName preserves the table created by the original migration.
|
||||||
|
func (ContainerImageCheckUpdate) TableName() string { return "container_image_updates" }
|
||||||
@@ -1394,10 +1394,19 @@ func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) error {
|
|||||||
func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error {
|
func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error {
|
||||||
agentAccountMutationMu.Lock()
|
agentAccountMutationMu.Lock()
|
||||||
defer agentAccountMutationMu.Unlock()
|
defer agentAccountMutationMu.Unlock()
|
||||||
_, err := agentAccountRepo.GetFirst(repo.WithByID(req.ID))
|
account, err := agentAccountRepo.GetFirst(repo.WithByID(req.ID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if account.AppInstallID != 0 && account.Provider != "ollama" {
|
||||||
|
_, err := appInstallRepo.GetFirst(repo.WithByID(account.AppInstallID))
|
||||||
|
if err == nil {
|
||||||
|
return buserr.WithDetail("ErrInvalidParams", "Delete the local inference engine instead", nil)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := global.DB.Transaction(func(tx *gorm.DB) error {
|
if err := global.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
var agentCount int64
|
var agentCount int64
|
||||||
|
|||||||
@@ -259,15 +259,6 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if install.Status == constant.StatusRestarting && (req.Operate == constant.Stop || req.Operate == constant.Delete) && !req.ForceDelete {
|
|
||||||
count, err := taskRepo.CountExecutingTask(repo.WithByType(task.TaskScopeApp), taskRepo.WithResourceID(install.ID), taskRepo.WithOperate(task.TaskRestart))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
return buserr.New("TaskIsExecuting")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !req.ForceDelete && !files.NewFileOp().Stat(install.GetPath()) {
|
if !req.ForceDelete && !files.NewFileOp().Stat(install.GetPath()) {
|
||||||
return buserr.New("ErrInstallDirNotFound")
|
return buserr.New("ErrInstallDirNotFound")
|
||||||
}
|
}
|
||||||
@@ -351,6 +342,9 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
|
|||||||
return errors.New("lifecycle script operation not supported")
|
return errors.New("lifecycle script operation not supported")
|
||||||
}
|
}
|
||||||
install.Message = ""
|
install.Message = ""
|
||||||
|
if err := appInstallRepo.Save(context.Background(), &install); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
operationTask, err := task.NewTaskWithOps(install.Name, taskType, task.TaskScopeApp, req.TaskID, install.ID)
|
operationTask, err := task.NewTaskWithOps(install.Name, taskType, task.TaskScopeApp, req.TaskID, install.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -361,17 +355,10 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ready := make(chan error, 1)
|
|
||||||
started := make(chan struct{})
|
|
||||||
operation := string(req.Operate)
|
operation := string(req.Operate)
|
||||||
operationTask.AddSubTaskWithOps(
|
operationTask.AddSubTaskWithOps(
|
||||||
task.GetTaskName(install.Name, taskType, task.TaskScopeApp),
|
task.GetTaskName(install.Name, taskType, task.TaskScopeApp),
|
||||||
func(t *task.Task) error {
|
func(t *task.Task) error {
|
||||||
if err := appInstallRepo.Save(context.Background(), &install); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
close(started)
|
|
||||||
ready <- nil
|
|
||||||
if err := runScript(t, &install, operation); err != nil {
|
if err := runScript(t, &install, operation); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -398,12 +385,6 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
|
|||||||
)
|
)
|
||||||
go func() {
|
go func() {
|
||||||
if taskErr := operationTask.Execute(); taskErr != nil {
|
if taskErr := operationTask.Execute(); taskErr != nil {
|
||||||
select {
|
|
||||||
case <-started:
|
|
||||||
default:
|
|
||||||
ready <- taskErr
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if onFailure != nil {
|
if onFailure != nil {
|
||||||
onFailure(taskErr)
|
onFailure(taskErr)
|
||||||
return
|
return
|
||||||
@@ -413,7 +394,7 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
|
|||||||
_ = appInstallRepo.Save(context.Background(), &install)
|
_ = appInstallRepo.Save(context.Background(), &install)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
return <-ready
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AppInstallService) UpdateAppConfig(req request.AppConfigUpdate) error {
|
func (a *AppInstallService) UpdateAppConfig(req request.AppConfigUpdate) error {
|
||||||
@@ -1055,16 +1036,8 @@ func syncAppInstallStatus(appInstall *model.AppInstall, force bool) error {
|
|||||||
}
|
}
|
||||||
switch appInstall.Status {
|
switch appInstall.Status {
|
||||||
case constant.StatusInstalling, constant.StatusRebuilding, constant.StatusUpgrading, constant.StatusUninstalling,
|
case constant.StatusInstalling, constant.StatusRebuilding, constant.StatusUpgrading, constant.StatusUninstalling,
|
||||||
constant.StatusStarting, constant.StatusWaiting:
|
constant.StatusStarting, constant.StatusRestarting, constant.StatusWaiting:
|
||||||
return nil
|
return nil
|
||||||
case constant.StatusRestarting:
|
|
||||||
count, err := taskRepo.CountExecutingTask(repo.WithByType(task.TaskScopeApp), taskRepo.WithResourceID(appInstall.ID), taskRepo.WithOperate(task.TaskRestart))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
case constant.StatusInstallErr, constant.StatusUpgradeErr, constant.StatusUpErr:
|
case constant.StatusInstallErr, constant.StatusUpgradeErr, constant.StatusUpErr:
|
||||||
if !force {
|
if !force {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -140,6 +140,29 @@ func (u *ContainerService) Page(ctx context.Context, req dto.PageContainer) (int
|
|||||||
backData = records[start:end]
|
backData = records[start:end]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Batch-load persisted checks; never contact Docker Hub while rendering the list.
|
||||||
|
ids := make([]string, 0, len(backData))
|
||||||
|
for _, row := range backData {
|
||||||
|
ids = append(ids, row.ContainerID)
|
||||||
|
}
|
||||||
|
if len(ids) > 0 {
|
||||||
|
var checks []model.ContainerImageCheckUpdate
|
||||||
|
if err := global.DB.Where("container_id IN ?", ids).Find(&checks).Error; err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
byID := make(map[string]model.ContainerImageCheckUpdate, len(checks))
|
||||||
|
for _, check := range checks {
|
||||||
|
byID[check.ContainerID] = check
|
||||||
|
}
|
||||||
|
for i := range backData {
|
||||||
|
check, ok := byID[backData[i].ContainerID]
|
||||||
|
if ok && sameContainerImage(check.ImageID, backData[i].ImageId) && check.ImageName == backData[i].ImageName {
|
||||||
|
backData[i].LatestVersion = check.LatestVersion
|
||||||
|
backData[i].TargetImage = check.TargetImage
|
||||||
|
backData[i].HasUpdate = check.HasUpdate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for i := 0; i < len(backData); i++ {
|
for i := 0; i < len(backData); i++ {
|
||||||
install, _ := appInstallRepo.GetFirst(appInstallRepo.WithContainerName(backData[i].Name))
|
install, _ := appInstallRepo.GetFirst(appInstallRepo.WithContainerName(backData[i].Name))
|
||||||
if install.ID > 0 {
|
if install.ID > 0 {
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
|
"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"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/utils/dockerhub"
|
||||||
|
"github.com/docker/cli/cli/config"
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/docker/api/types/image"
|
||||||
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/docker/docker/errdefs"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
const imageCheckUpdateSetting = "ContainerImageUpdateEnabled"
|
||||||
|
|
||||||
|
type ImageCheckUpdateStatus struct {
|
||||||
|
TaskID string `json:"taskID"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
Checked int `json:"checked"`
|
||||||
|
Updates int `json:"updates"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
FinishedAt int64 `json:"finishedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageCheckUpdateState struct {
|
||||||
|
sync.Mutex
|
||||||
|
status ImageCheckUpdateStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetContainerImageCheckUpdateStatus() (ImageCheckUpdateStatus, error) {
|
||||||
|
var setting model.Setting
|
||||||
|
if err := global.DB.Where("key = ?", imageCheckUpdateSetting).First(&setting).Error; err != nil {
|
||||||
|
return ImageCheckUpdateStatus{}, err
|
||||||
|
}
|
||||||
|
imageCheckUpdateState.Lock()
|
||||||
|
defer imageCheckUpdateState.Unlock()
|
||||||
|
status := imageCheckUpdateState.status
|
||||||
|
status.Enabled = setting.Value == "Enable"
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
func SetContainerImageCheckUpdateSchedule(enabled bool) error {
|
||||||
|
value := "Disable"
|
||||||
|
if enabled {
|
||||||
|
httpClient, err := dockerhub.NewHTTPClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if httpClient.Transport != nil {
|
||||||
|
httpClient.CloseIdleConnections()
|
||||||
|
}
|
||||||
|
value = "Enable"
|
||||||
|
}
|
||||||
|
return global.DB.Model(&model.Setting{}).Where("key = ?", imageCheckUpdateSetting).Update("value", value).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartContainerImageCheckUpdate coalesces manual and scheduled runs on this node.
|
||||||
|
func StartContainerImageCheckUpdate() (string, error) {
|
||||||
|
imageCheckUpdateState.Lock()
|
||||||
|
defer imageCheckUpdateState.Unlock()
|
||||||
|
if imageCheckUpdateState.status.Running {
|
||||||
|
return imageCheckUpdateState.status.TaskID, nil
|
||||||
|
}
|
||||||
|
taskItem, err := task.NewTask(i18n.GetMsgByKey("ContainerImageCheckUpdate"), task.TaskScan, task.TaskScopeImage, "", 0)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Persist before returning the ID so the log drawer can immediately find it.
|
||||||
|
if err := global.TaskDB.Save(taskItem.Task).Error; err != nil {
|
||||||
|
_ = taskItem.Logger.Out.(io.Closer).Close()
|
||||||
|
taskItem.DeleteLogFile()
|
||||||
|
if cancel, ok := global.LoadTaskCancel(taskItem.TaskID); ok {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
global.RemoveTaskCancel(taskItem.TaskID)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
imageCheckUpdateState.status = ImageCheckUpdateStatus{Running: true, TaskID: taskItem.TaskID}
|
||||||
|
go func() {
|
||||||
|
result := ImageCheckUpdateStatus{TaskID: taskItem.TaskID}
|
||||||
|
defer func() {
|
||||||
|
result.FinishedAt = time.Now().UnixMilli()
|
||||||
|
imageCheckUpdateState.Lock()
|
||||||
|
imageCheckUpdateState.status = result
|
||||||
|
imageCheckUpdateState.Unlock()
|
||||||
|
}()
|
||||||
|
// The action owns its deadline so the task runner waits for cleanup and logs.
|
||||||
|
taskItem.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerImageCheckUpdate"), func(t *task.Task) (err error) {
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
err = fmt.Errorf("image update check failed: %v", recovered)
|
||||||
|
}
|
||||||
|
t.Log(i18n.GetMsgWithMap("ContainerImageCheckSummary", map[string]interface{}{
|
||||||
|
"checked": result.Checked, "updates": result.Updates, "failed": result.Failed, "skipped": result.Skipped,
|
||||||
|
}))
|
||||||
|
}()
|
||||||
|
ctx, cancel := context.WithTimeout(t.TaskCtx, 30*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
if err = runContainerImageCheckUpdate(ctx, &result, t.Log); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result.Failed > 0 {
|
||||||
|
return fmt.Errorf("%s", i18n.GetMsgWithMap("ContainerImageCheckFailures", map[string]interface{}{"count": result.Failed}))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, nil, 0, 0)
|
||||||
|
if err := taskItem.Execute(); err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
global.LOG.Errorf("container image check: %s", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return taskItem.TaskID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// These caches live only for one check. Remote results do not depend on a
|
||||||
|
// container's local image ID, which is compared separately for every baseline.
|
||||||
|
type imageCheckUpdateRepository struct {
|
||||||
|
client imageCheckUpdateHubClient
|
||||||
|
err error
|
||||||
|
tags []string
|
||||||
|
tagsLoaded bool
|
||||||
|
tagsErr error
|
||||||
|
}
|
||||||
|
type imageCheckUpdateTargetKey struct {
|
||||||
|
repository, tag string
|
||||||
|
platform dockerhub.Platform
|
||||||
|
}
|
||||||
|
type imageCheckUpdateTargetResult struct {
|
||||||
|
target dockerhub.Target
|
||||||
|
compatible bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
type imageCheckUpdateResult struct {
|
||||||
|
record model.ContainerImageCheckUpdate
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
type imageCheckUpdateCache struct {
|
||||||
|
username, password string
|
||||||
|
httpClient *http.Client
|
||||||
|
repositories map[string]*imageCheckUpdateRepository
|
||||||
|
targets map[imageCheckUpdateTargetKey]imageCheckUpdateTargetResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func runContainerImageCheckUpdate(ctx context.Context, result *ImageCheckUpdateStatus, logger func(string)) error {
|
||||||
|
httpClient, err := dockerhub.NewHTTPClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if httpClient.Transport != nil {
|
||||||
|
defer httpClient.CloseIdleConnections()
|
||||||
|
}
|
||||||
|
log := func(key string, values map[string]interface{}) {
|
||||||
|
if logger != nil {
|
||||||
|
logger(i18n.GetMsgWithMap(key, values))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cli, err := newImageCheckUpdateDockerClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
containers, err := cli.ContainerList(ctx, container.ListOptions{All: true})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log("ContainerImageCheckScope", map[string]interface{}{"count": len(containers)})
|
||||||
|
// Load old results once, rather than issuing one SELECT for every failed check.
|
||||||
|
var previousRows []model.ContainerImageCheckUpdate
|
||||||
|
if err := global.DB.WithContext(ctx).Find(&previousRows).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
previous := make(map[string]model.ContainerImageCheckUpdate, len(previousRows))
|
||||||
|
for _, row := range previousRows {
|
||||||
|
previous[row.ContainerID] = row
|
||||||
|
}
|
||||||
|
cache := imageCheckUpdateCache{httpClient: httpClient, repositories: map[string]*imageCheckUpdateRepository{}, targets: map[imageCheckUpdateTargetKey]imageCheckUpdateTargetResult{}}
|
||||||
|
cfg := config.LoadDefaultConfigFile(io.Discard)
|
||||||
|
if cfg != nil {
|
||||||
|
for _, host := range []string{"https://index.docker.io/v1/", "docker.io", "registry-1.docker.io"} {
|
||||||
|
auth, e := cfg.GetAuthConfig(host)
|
||||||
|
if e == nil && auth.Username != "" {
|
||||||
|
cache.username, cache.password = auth.Username, auth.Password
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checks := map[string]imageCheckUpdateResult{}
|
||||||
|
ids := make([]string, 0, len(containers))
|
||||||
|
names := make(map[string]string, len(containers))
|
||||||
|
pending := make([]model.ContainerImageCheckUpdate, 0, 100)
|
||||||
|
for _, item := range containers {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ids = append(ids, item.ID)
|
||||||
|
name := item.ID
|
||||||
|
if len(item.Names) > 0 {
|
||||||
|
name = strings.TrimPrefix(item.Names[0], "/")
|
||||||
|
}
|
||||||
|
names[item.ID] = name
|
||||||
|
log("ContainerImageCheckItem", map[string]interface{}{"name": name, "image": item.Image})
|
||||||
|
repository, tag, supported := dockerhub.Reference(item.Image)
|
||||||
|
key := repository + ":" + tag + "@" + item.ImageID
|
||||||
|
entry, found := checks[key]
|
||||||
|
if !found {
|
||||||
|
entry.record, entry.err = checkContainerImageUpdate(ctx, cli, item, repository, tag, supported, &cache)
|
||||||
|
checks[key] = entry
|
||||||
|
}
|
||||||
|
record := entry.record
|
||||||
|
record.ContainerID, record.ImageName = item.ID, item.Image
|
||||||
|
if entry.err != nil {
|
||||||
|
record.CheckStatus, record.CheckError = "failed", entry.err.Error()
|
||||||
|
old := previous[item.ID]
|
||||||
|
if old.ImageID == item.ImageID && old.ImageName == item.Image {
|
||||||
|
record.LatestVersion, record.TargetImage, record.TargetImageID, record.HasUpdate = old.LatestVersion, old.TargetImage, old.TargetImageID, old.HasUpdate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending = append(pending, record)
|
||||||
|
if len(pending) == 100 {
|
||||||
|
if err := saveContainerImageCheckUpdates(ctx, cli, pending, names, result, log); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pending = pending[:0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := saveContainerImageCheckUpdates(ctx, cli, pending, names, result, log); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
query := global.DB.WithContext(ctx).Where("1 = 1")
|
||||||
|
if len(ids) > 0 {
|
||||||
|
query = query.Where("container_id NOT IN ?", ids)
|
||||||
|
}
|
||||||
|
return query.Delete(&model.ContainerImageCheckUpdate{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkContainerImageUpdate(ctx context.Context, cli imageCheckUpdateDockerClient, item container.Summary, repository, tag string, supported bool, cache *imageCheckUpdateCache) (model.ContainerImageCheckUpdate, error) {
|
||||||
|
record := model.ContainerImageCheckUpdate{ImageID: item.ImageID, ImageName: item.Image, CheckStatus: "success", CheckedAt: time.Now()}
|
||||||
|
if !supported {
|
||||||
|
record.CheckStatus = "skipped"
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
inspected, err := cli.ImageInspect(ctx, item.ImageID)
|
||||||
|
if err != nil {
|
||||||
|
return record, err
|
||||||
|
}
|
||||||
|
repo, loaded := cache.repositories[repository]
|
||||||
|
if !loaded {
|
||||||
|
repo = &imageCheckUpdateRepository{}
|
||||||
|
repo.client, repo.err = newImageCheckUpdateHubClient(ctx, repository, cache.username, cache.password, cache.httpClient)
|
||||||
|
cache.repositories[repository] = repo
|
||||||
|
}
|
||||||
|
if repo.err != nil {
|
||||||
|
return record, repo.err
|
||||||
|
}
|
||||||
|
if dockerhub.NeedsTags(tag) {
|
||||||
|
if !repo.tagsLoaded {
|
||||||
|
repo.tags, repo.tagsErr = repo.client.Tags(ctx)
|
||||||
|
repo.tagsLoaded = true
|
||||||
|
}
|
||||||
|
if repo.tagsErr != nil {
|
||||||
|
return record, repo.tagsErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
platform := dockerhub.Platform{OS: inspected.Os, Architecture: inspected.Architecture, Variant: inspected.Variant}
|
||||||
|
for _, candidate := range dockerhub.Candidates(tag, repo.tags) {
|
||||||
|
key := imageCheckUpdateTargetKey{repository: repository, tag: candidate, platform: platform}
|
||||||
|
remote, loaded := cache.targets[key]
|
||||||
|
if !loaded {
|
||||||
|
remote.target, remote.compatible, remote.err = repo.client.Resolve(ctx, candidate, platform)
|
||||||
|
cache.targets[key] = remote
|
||||||
|
}
|
||||||
|
if remote.err != nil {
|
||||||
|
return record, remote.err
|
||||||
|
}
|
||||||
|
if !remote.compatible {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
record.LatestVersion = remote.target.Tag
|
||||||
|
record.TargetImage = "docker.io/" + repository + ":" + remote.target.Tag
|
||||||
|
record.TargetImageID = remote.target.ConfigDigest
|
||||||
|
// Both versioned and floating tags can be republished with new image content.
|
||||||
|
record.HasUpdate = remote.target.Tag != tag || remote.target.ConfigDigest != inspected.ID
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
return record, fmt.Errorf("no compatible image for %s/%s", platform.OS, platform.Architecture)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revalidate immediately before each small write batch. Never hold a database
|
||||||
|
// transaction open while waiting for Docker or Docker Hub.
|
||||||
|
func saveContainerImageCheckUpdates(ctx context.Context, cli imageCheckUpdateDockerClient, pending []model.ContainerImageCheckUpdate, names map[string]string, result *ImageCheckUpdateStatus, log func(string, map[string]interface{})) error {
|
||||||
|
records := make([]model.ContainerImageCheckUpdate, 0, len(pending))
|
||||||
|
for _, record := range pending {
|
||||||
|
current, err := cli.ContainerInspect(ctx, record.ContainerID)
|
||||||
|
if err != nil && !errdefs.IsNotFound(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err != nil || current.Image != record.ImageID {
|
||||||
|
result.Skipped++
|
||||||
|
log("ContainerImageCheckChanged", map[string]interface{}{"name": names[record.ContainerID]})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
records = append(records, record)
|
||||||
|
}
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := global.DB.WithContext(ctx).Clauses(clause.OnConflict{UpdateAll: true}).CreateInBatches(&records, 100).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Count and announce only results that were successfully persisted.
|
||||||
|
for _, record := range records {
|
||||||
|
values := map[string]interface{}{"name": names[record.ContainerID], "image": record.ImageName}
|
||||||
|
switch record.CheckStatus {
|
||||||
|
case "failed":
|
||||||
|
result.Failed++
|
||||||
|
values["error"] = record.CheckError
|
||||||
|
log("ContainerImageCheckFailed", values)
|
||||||
|
case "skipped":
|
||||||
|
result.Skipped++
|
||||||
|
log("ContainerImageCheckSkipped", values)
|
||||||
|
default:
|
||||||
|
result.Checked++
|
||||||
|
if record.HasUpdate {
|
||||||
|
result.Updates++
|
||||||
|
values["target"] = record.TargetImage
|
||||||
|
log("ContainerImageCheckAvailable", values)
|
||||||
|
} else {
|
||||||
|
log("ContainerImageCheckCurrent", values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameContainerImage guards cached results after external image changes.
|
||||||
|
func sameContainerImage(stored, displayed string) bool {
|
||||||
|
return strings.TrimPrefix(stored, "sha256:") == strings.TrimPrefix(displayed, "sha256:")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile only after Docker confirms the replacement container uses the target.
|
||||||
|
func reconcileContainerImageCheckUpdate(oldID, newID, imageID, imageName string) error {
|
||||||
|
return global.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var previous model.ContainerImageCheckUpdate
|
||||||
|
if err := tx.Where("container_id = ?", oldID).Find(&previous).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if previous.ContainerID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := tx.Where("container_id = ?", oldID).Delete(&model.ContainerImageCheckUpdate{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if previous.TargetImageID != imageID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
previous.ContainerID = newID
|
||||||
|
previous.ImageID = imageID
|
||||||
|
previous.ImageName = imageName
|
||||||
|
previous.HasUpdate = false
|
||||||
|
previous.CheckStatus = "success"
|
||||||
|
previous.CheckError = ""
|
||||||
|
previous.CheckedAt = time.Now()
|
||||||
|
return tx.Clauses(clause.OnConflict{UpdateAll: true}).Create(&previous).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type imageCheckUpdateDockerClient interface {
|
||||||
|
ContainerList(context.Context, container.ListOptions) ([]container.Summary, error)
|
||||||
|
ContainerInspect(context.Context, string) (container.InspectResponse, error)
|
||||||
|
ImageInspect(context.Context, string, ...client.ImageInspectOption) (image.InspectResponse, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
type imageCheckUpdateHubClient interface {
|
||||||
|
Tags(context.Context) ([]string, error)
|
||||||
|
Resolve(context.Context, string, dockerhub.Platform) (dockerhub.Target, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var newImageCheckUpdateDockerClient = func() (imageCheckUpdateDockerClient, error) { return docker.NewDockerClient() }
|
||||||
|
var newImageCheckUpdateHubClient = func(ctx context.Context, repository, username, password string, httpClient *http.Client) (imageCheckUpdateHubClient, error) {
|
||||||
|
return dockerhub.New(ctx, repository, username, password, httpClient)
|
||||||
|
}
|
||||||
@@ -163,6 +163,11 @@ func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error {
|
|||||||
upgradeErrors = append(upgradeErrors, upgradeErr)
|
upgradeErrors = append(upgradeErrors, upgradeErr)
|
||||||
return upgradeErr
|
return upgradeErr
|
||||||
}
|
}
|
||||||
|
if updated, inspectErr := client.ContainerInspect(ctx, item); inspectErr == nil {
|
||||||
|
if cacheErr := reconcileContainerImageCheckUpdate(oldContainer.ID, updated.ID, updated.Image, updated.Config.Image); cacheErr != nil {
|
||||||
|
global.LOG.Warnf("refresh container image update state: %v", cacheErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
if cleanupErr != nil {
|
if cleanupErr != nil {
|
||||||
t.Log(i18n.GetWithNameAndErr("ContainerCleanupWarning", containerSwitchBackupName(oldContainer.ID), cleanupErr))
|
t.Log(i18n.GetWithNameAndErr("ContainerCleanupWarning", containerSwitchBackupName(oldContainer.ID), cleanupErr))
|
||||||
}
|
}
|
||||||
@@ -307,7 +312,6 @@ func switchContainer(
|
|||||||
return switchContainerWithRecovery(ctx, cli, name, oldContainer, createNew, tty, logger, false)
|
return switchContainerWithRecovery(ctx, cli, name, oldContainer, createNew, tty, logger, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
containerUpgradePullTimeout = 30 * time.Minute
|
containerUpgradePullTimeout = 30 * time.Minute
|
||||||
containerUpgradeReplaceTimeout = 15 * time.Minute
|
containerUpgradeReplaceTimeout = 15 * time.Minute
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/utils/dockerhub"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ImageRemoteCheckStatus struct {
|
||||||
|
ProxyRequired bool `json:"proxyRequired"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetImageRemoteCheckStatus validates local settings without contacting Docker Hub.
|
||||||
|
// Both image searches and update checks use the same proxy policy.
|
||||||
|
func GetImageRemoteCheckStatus() (ImageRemoteCheckStatus, error) {
|
||||||
|
client, err := dockerhub.NewHTTPClient()
|
||||||
|
if err != nil {
|
||||||
|
var business buserr.BusinessError
|
||||||
|
if errors.As(err, &business) && business.Msg == "ErrImageCheckProxyRequired" {
|
||||||
|
return ImageRemoteCheckStatus{ProxyRequired: true}, nil
|
||||||
|
}
|
||||||
|
return ImageRemoteCheckStatus{}, err
|
||||||
|
}
|
||||||
|
if client.Transport != nil {
|
||||||
|
client.CloseIdleConnections()
|
||||||
|
}
|
||||||
|
return ImageRemoteCheckStatus{}, nil
|
||||||
|
}
|
||||||
@@ -316,8 +316,6 @@ func (t *Task) ExecuteQueued(slots chan struct{}, waitingStep string, onFinish f
|
|||||||
|
|
||||||
func (t *Task) Execute() error {
|
func (t *Task) Execute() 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 err
|
||||||
}
|
}
|
||||||
return t.executeRegistered()
|
return t.executeRegistered()
|
||||||
|
|||||||
+5
-1
@@ -77,7 +77,11 @@ func Run() {
|
|||||||
global.LOG.Errorf("generate random hour failed: %v", err)
|
global.LOG.Errorf("generate random hour failed: %v", err)
|
||||||
hourRand = big.NewInt(0)
|
hourRand = big.NewInt(0)
|
||||||
}
|
}
|
||||||
if _, err := global.Cron.AddJob(fmt.Sprintf("%v %v * * *", minuteRand.Int64(), hourRand.Int64()), job.NewAppStoreJob()); err != nil {
|
appSyncSchedule := fmt.Sprintf("%v %v * * *", minuteRand.Int64(), hourRand.Int64())
|
||||||
|
if _, err := global.Cron.AddJob(appSyncSchedule, job.NewContainerImageCheckUpdateJob()); err != nil {
|
||||||
|
global.LOG.Errorf("cannot add container image update cron job: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := global.Cron.AddJob(appSyncSchedule, job.NewAppStoreJob()); err != nil {
|
||||||
global.LOG.Errorf("can not add appstore corn job: %s", err.Error())
|
global.LOG.Errorf("can not add appstore corn job: %s", err.Error())
|
||||||
}
|
}
|
||||||
if _, err := global.Cron.AddJob("0 3 */31 * *", job.NewBackupJob()); err != nil {
|
if _, err := global.Cron.AddJob("0 3 */31 * *", job.NewBackupJob()); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/app/service"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
|
)
|
||||||
|
|
||||||
|
type containerImageCheckUpdate struct{}
|
||||||
|
|
||||||
|
func NewContainerImageCheckUpdateJob() *containerImageCheckUpdate {
|
||||||
|
return &containerImageCheckUpdate{}
|
||||||
|
}
|
||||||
|
func (j *containerImageCheckUpdate) Run() {
|
||||||
|
status, err := service.GetContainerImageCheckUpdateStatus()
|
||||||
|
if err != nil {
|
||||||
|
global.LOG.Errorf("load container image update schedule: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status.Enabled {
|
||||||
|
if _, err := service.StartContainerImageCheckUpdate(); err != nil {
|
||||||
|
global.LOG.Errorf("start container image update check: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -802,3 +802,15 @@ ErrFeishuUpgradeMounts: "These mounts cannot be fully backed up or restored. Use
|
|||||||
AgentFeishuRecoveryDisabled: "Gateway recovered with Feishu disabled; the original configuration backup is retained."
|
AgentFeishuRecoveryDisabled: "Gateway recovered with Feishu disabled; the original configuration backup is retained."
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
||||||
|
ContainerImageCheckUpdate: "Check for image updates"
|
||||||
|
ContainerImageCheckScope: "Checking all {{ .count }} containers on this node, regardless of pagination or filters"
|
||||||
|
ContainerImageCheckItem: "Checking container {{ .name }}, image {{ .image }}"
|
||||||
|
ContainerImageCheckFailed: "Container {{ .name }} check failed: {{ .error }}; retaining previous results matching the current image"
|
||||||
|
ContainerImageCheckSkipped: "Skipping {{ .name }}: not a tagged Docker Hub image, a local image, or pinned digest"
|
||||||
|
ContainerImageCheckAvailable: "Container {{ .name }} has an update: {{ .image }} -> {{ .target }}"
|
||||||
|
ContainerImageCheckCurrent: "No update found for container {{ .name }}"
|
||||||
|
ContainerImageCheckChanged: "Container {{ .name }} was removed or its image changed; result was not saved"
|
||||||
|
ContainerImageCheckSummary: "Check finished: {{ .checked }} checked, {{ .updates }} upgradable, {{ .failed }} failed, {{ .skipped }} skipped"
|
||||||
|
ContainerImageCheckFailures: "{{ .count }} container checks failed; see the task log"
|
||||||
|
|
||||||
|
ErrImageCheckProxyRequired: "To check for image updates in the current runtime environment, please configure a proxy server first."
|
||||||
|
|||||||
@@ -802,3 +802,15 @@ ErrFeishuUpgradeMounts: "Estos montajes no permiten una copia y restauración co
|
|||||||
AgentFeishuRecoveryDisabled: "Puerta de enlace recuperada con Feishu desactivado; se conserva la copia de configuración original."
|
AgentFeishuRecoveryDisabled: "Puerta de enlace recuperada con Feishu desactivado; se conserva la copia de configuración original."
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
||||||
|
ContainerImageCheckUpdate: "Buscar actualizaciones de imágenes"
|
||||||
|
ContainerImageCheckScope: "Comprobando los {{ .count }} contenedores de este nodo, sin aplicar paginación ni filtros"
|
||||||
|
ContainerImageCheckItem: "Comprobando el contenedor {{ .name }}, imagen {{ .image }}"
|
||||||
|
ContainerImageCheckFailed: "Error al comprobar el contenedor {{ .name }}: {{ .error }}; se conservan los resultados anteriores correspondientes a la imagen actual"
|
||||||
|
ContainerImageCheckSkipped: "Se omite {{ .name }}: no es una imagen de Docker Hub con etiqueta, es una imagen local o tiene un digest fijo"
|
||||||
|
ContainerImageCheckAvailable: "El contenedor {{ .name }} tiene una actualización: {{ .image }} → {{ .target }}"
|
||||||
|
ContainerImageCheckCurrent: "No se encontraron actualizaciones para el contenedor {{ .name }}"
|
||||||
|
ContainerImageCheckChanged: "El contenedor {{ .name }} se eliminó o su imagen cambió; no se guardó el resultado"
|
||||||
|
ContainerImageCheckSummary: "Comprobación finalizada: comprobados {{ .checked }}, actualizables {{ .updates }}, fallidos {{ .failed }}, omitidos {{ .skipped }}"
|
||||||
|
ContainerImageCheckFailures: "Falló la comprobación de {{ .count }} contenedores; consulte el registro de la tarea"
|
||||||
|
|
||||||
|
ErrImageCheckProxyRequired: "Para comprobar actualizaciones de imágenes en el entorno actual, configura primero un servidor proxy."
|
||||||
|
|||||||
@@ -803,3 +803,15 @@ ErrFeishuUpgradeMounts: "این اتصالهای ذخیرهسازی کام
|
|||||||
AgentFeishuRecoveryDisabled: "دروازه بازیابی شد و Feishu غیرفعال است؛ پشتیبان تنظیمات اصلی حفظ شد."
|
AgentFeishuRecoveryDisabled: "دروازه بازیابی شد و Feishu غیرفعال است؛ پشتیبان تنظیمات اصلی حفظ شد."
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
||||||
|
ContainerImageCheckUpdate: "بررسی بهروزرسانی ایمیجها"
|
||||||
|
ContainerImageCheckScope: "در حال بررسی همه {{ .count }} کانتینر این گره، بدون توجه به صفحهبندی و فیلترها"
|
||||||
|
ContainerImageCheckItem: "در حال بررسی کانتینر {{ .name }}، ایمیج {{ .image }}"
|
||||||
|
ContainerImageCheckFailed: "بررسی کانتینر {{ .name }} ناموفق بود: {{ .error }}؛ نتایج قبلیِ منطبق با ایمیج فعلی حفظ میشوند"
|
||||||
|
ContainerImageCheckSkipped: "کانتینر {{ .name }} رد شد: ایمیج تگدار Docker Hub نیست، ایمیج محلی است یا digest ثابت دارد"
|
||||||
|
ContainerImageCheckAvailable: "برای کانتینر {{ .name }} بهروزرسانی موجود است: {{ .image }} → {{ .target }}"
|
||||||
|
ContainerImageCheckCurrent: "بهروزرسانی برای کانتینر {{ .name }} یافت نشد"
|
||||||
|
ContainerImageCheckChanged: "کانتینر {{ .name }} حذف شده یا ایمیج آن تغییر کرده است؛ نتیجه ذخیره نشد"
|
||||||
|
ContainerImageCheckSummary: "بررسی پایان یافت: بررسیشده {{ .checked }}، قابل ارتقا {{ .updates }}، ناموفق {{ .failed }}، ردشده {{ .skipped }}"
|
||||||
|
ContainerImageCheckFailures: "بررسی {{ .count }} کانتینر ناموفق بود؛ گزارش وظیفه را مشاهده کنید"
|
||||||
|
|
||||||
|
ErrImageCheckProxyRequired: "برای بررسی بهروزرسانی تصاویر در محیط فعلی، ابتدا یک سرور پروکسی پیکربندی کنید."
|
||||||
|
|||||||
@@ -802,3 +802,15 @@ ErrFeishuUpgradeMounts: "現在のマウントは完全なバックアップ・
|
|||||||
AgentFeishuRecoveryDisabled: "ゲートウェイを復旧しました。Feishu は無効のままで、元の設定のバックアップは保持しています。"
|
AgentFeishuRecoveryDisabled: "ゲートウェイを復旧しました。Feishu は無効のままで、元の設定のバックアップは保持しています。"
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
||||||
|
ContainerImageCheckUpdate: "イメージの更新を確認"
|
||||||
|
ContainerImageCheckScope: "ページ分割やフィルターに関係なく、このノードの全 {{ .count }} コンテナを確認します"
|
||||||
|
ContainerImageCheckItem: "コンテナ {{ .name }}、イメージ {{ .image }} を確認しています"
|
||||||
|
ContainerImageCheckFailed: "コンテナ {{ .name }} の確認に失敗しました:{{ .error }}。現在のイメージに対応する前回の結果を保持します"
|
||||||
|
ContainerImageCheckSkipped: "{{ .name }} をスキップ:タグ付きの Docker Hub イメージではないか、ローカルイメージまたは digest 固定のイメージです"
|
||||||
|
ContainerImageCheckAvailable: "コンテナ {{ .name }} は更新可能です:{{ .image }} → {{ .target }}"
|
||||||
|
ContainerImageCheckCurrent: "コンテナ {{ .name }} の更新は見つかりませんでした"
|
||||||
|
ContainerImageCheckChanged: "コンテナ {{ .name }} が削除されたか、イメージが変更されたため、結果は保存されませんでした"
|
||||||
|
ContainerImageCheckSummary: "確認終了:成功 {{ .checked }}、更新可能 {{ .updates }}、失敗 {{ .failed }}、スキップ {{ .skipped }}"
|
||||||
|
ContainerImageCheckFailures: "{{ .count }} コンテナの確認に失敗しました。タスクログを確認してください"
|
||||||
|
|
||||||
|
ErrImageCheckProxyRequired: "現在の実行環境でイメージの更新を確認するには、先にプロキシサーバーを設定してください。"
|
||||||
|
|||||||
@@ -802,3 +802,15 @@ ErrFeishuUpgradeMounts: "현재 마운트는 전체 백업 및 복원을 지원
|
|||||||
AgentFeishuRecoveryDisabled: "게이트웨이가 복구되었습니다. Feishu는 비활성 상태이며 원본 설정 백업은 유지됩니다."
|
AgentFeishuRecoveryDisabled: "게이트웨이가 복구되었습니다. Feishu는 비활성 상태이며 원본 설정 백업은 유지됩니다."
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
||||||
|
ContainerImageCheckUpdate: "이미지 업데이트 확인"
|
||||||
|
ContainerImageCheckScope: "페이지 및 필터와 관계없이 현재 노드의 모든 컨테이너 {{ .count }}개를 확인합니다"
|
||||||
|
ContainerImageCheckItem: "컨테이너 {{ .name }}, 이미지 {{ .image }} 확인 중"
|
||||||
|
ContainerImageCheckFailed: "컨테이너 {{ .name }} 확인 실패: {{ .error }}. 현재 이미지와 일치하는 이전 결과를 유지합니다"
|
||||||
|
ContainerImageCheckSkipped: "{{ .name }} 건너뜀: 태그가 있는 Docker Hub 이미지가 아니거나 로컬 이미지 또는 digest가 고정된 이미지입니다"
|
||||||
|
ContainerImageCheckAvailable: "컨테이너 {{ .name }} 업데이트 가능: {{ .image }} → {{ .target }}"
|
||||||
|
ContainerImageCheckCurrent: "컨테이너 {{ .name }}의 업데이트를 찾지 못했습니다"
|
||||||
|
ContainerImageCheckChanged: "컨테이너 {{ .name }}이 삭제되었거나 이미지가 변경되어 결과를 저장하지 않았습니다"
|
||||||
|
ContainerImageCheckSummary: "확인 종료: 성공 {{ .checked }}, 업데이트 가능 {{ .updates }}, 실패 {{ .failed }}, 건너뜀 {{ .skipped }}"
|
||||||
|
ContainerImageCheckFailures: "컨테이너 {{ .count }}개 확인에 실패했습니다. 작업 로그를 확인하세요"
|
||||||
|
|
||||||
|
ErrImageCheckProxyRequired: "현재 실행 환경에서 이미지 업데이트를 확인하려면 먼저 프록시 서버를 설정하세요."
|
||||||
|
|||||||
@@ -793,3 +793,15 @@ ErrFeishuUpgradeMounts: "ຈຸດເຊື່ອມຂໍ້ມູນນີ້
|
|||||||
AgentFeishuRecoveryDisabled: "ເກດເວກູ້ຄືນແລ້ວ ແຕ່ Feishu ຍັງປິດ; ສຳຮອງການຕັ້ງຄ່າເດີມຍັງຖືກເກັບໄວ້."
|
AgentFeishuRecoveryDisabled: "ເກດເວກູ້ຄືນແລ້ວ ແຕ່ Feishu ຍັງປິດ; ສຳຮອງການຕັ້ງຄ່າເດີມຍັງຖືກເກັບໄວ້."
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
||||||
|
ContainerImageCheckUpdate: "ກວດສອບການອັບເດດອິມເມຈ"
|
||||||
|
ContainerImageCheckScope: "ກຳລັງກວດສອບທັງໝົດ {{ .count }} ຄອນເທນເນີໃນໂນດນີ້ ໂດຍບໍ່ຂຶ້ນກັບການແບ່ງໜ້າ ຫຼື ຕົວກອງ"
|
||||||
|
ContainerImageCheckItem: "ກຳລັງກວດສອບຄອນເທນເນີ {{ .name }}, ອິມເມຈ {{ .image }}"
|
||||||
|
ContainerImageCheckFailed: "ກວດສອບຄອນເທນເນີ {{ .name }} ລົ້ມເຫຼວ: {{ .error }}; ເກັບຜົນກ່ອນໜ້າທີ່ກົງກັບອິມເມຈປັດຈຸບັນໄວ້"
|
||||||
|
ContainerImageCheckSkipped: "ຂ້າມ {{ .name }}: ບໍ່ແມ່ນອິມເມຈ Docker Hub ທີ່ມີແທັກ, ເປັນອິມເມຈພາຍໃນ ຫຼື ກຳນົດ digest ຄົງທີ່"
|
||||||
|
ContainerImageCheckAvailable: "ຄອນເທນເນີ {{ .name }} ມີອັບເດດ: {{ .image }} → {{ .target }}"
|
||||||
|
ContainerImageCheckCurrent: "ບໍ່ພົບອັບເດດສຳລັບຄອນເທນເນີ {{ .name }}"
|
||||||
|
ContainerImageCheckChanged: "ຄອນເທນເນີ {{ .name }} ຖືກລຶບ ຫຼື ອິມເມຈປ່ຽນແປງ; ບໍ່ໄດ້ບັນທຶກຜົນ"
|
||||||
|
ContainerImageCheckSummary: "ກວດສອບສິ້ນສຸດ: ກວດແລ້ວ {{ .checked }}, ອັບເກຣດໄດ້ {{ .updates }}, ລົ້ມເຫຼວ {{ .failed }}, ຂ້າມ {{ .skipped }}"
|
||||||
|
ContainerImageCheckFailures: "ກວດສອບ {{ .count }} ຄອນເທນເນີລົ້ມເຫຼວ; ກະລຸນາເບິ່ງບັນທຶກວຽກ"
|
||||||
|
|
||||||
|
ErrImageCheckProxyRequired: "ເພື່ອກວດສອບການອັບເດດອິມເມຈໃນສະພາບແວດລ້ອມປັດຈຸບັນ ກະລຸນາຕັ້ງຄ່າພຣັອກຊີເຊີບເວີກ່ອນ."
|
||||||
|
|||||||
@@ -802,3 +802,15 @@ ErrFeishuUpgradeMounts: "Lekapan ini tidak boleh disandar dan dipulihkan sepenuh
|
|||||||
AgentFeishuRecoveryDisabled: "Gerbang dipulihkan dengan Feishu dinyahaktifkan; sandaran konfigurasi asal dikekalkan."
|
AgentFeishuRecoveryDisabled: "Gerbang dipulihkan dengan Feishu dinyahaktifkan; sandaran konfigurasi asal dikekalkan."
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
||||||
|
ContainerImageCheckUpdate: "Semak kemas kini imej"
|
||||||
|
ContainerImageCheckScope: "Menyemak kesemua {{ .count }} kontena pada nod ini tanpa mengira pembahagian halaman atau penapis"
|
||||||
|
ContainerImageCheckItem: "Menyemak kontena {{ .name }}, imej {{ .image }}"
|
||||||
|
ContainerImageCheckFailed: "Semakan kontena {{ .name }} gagal: {{ .error }}; keputusan terdahulu yang sepadan dengan imej semasa dikekalkan"
|
||||||
|
ContainerImageCheckSkipped: "Melangkau {{ .name }}: bukan imej Docker Hub bertag, imej setempat atau digest ditetapkan"
|
||||||
|
ContainerImageCheckAvailable: "Kontena {{ .name }} mempunyai kemas kini: {{ .image }} → {{ .target }}"
|
||||||
|
ContainerImageCheckCurrent: "Tiada kemas kini ditemui untuk kontena {{ .name }}"
|
||||||
|
ContainerImageCheckChanged: "Kontena {{ .name }} telah dipadam atau imejnya berubah; keputusan tidak disimpan"
|
||||||
|
ContainerImageCheckSummary: "Semakan selesai: disemak {{ .checked }}, boleh dikemas kini {{ .updates }}, gagal {{ .failed }}, dilangkau {{ .skipped }}"
|
||||||
|
ContainerImageCheckFailures: "Semakan {{ .count }} kontena gagal; lihat log tugas"
|
||||||
|
|
||||||
|
ErrImageCheckProxyRequired: "Untuk menyemak kemas kini imej dalam persekitaran semasa, sila konfigurasikan pelayan proksi terlebih dahulu."
|
||||||
|
|||||||
@@ -802,3 +802,15 @@ ErrFeishuUpgradeMounts: "Estas montagens não permitem backup e restauração co
|
|||||||
AgentFeishuRecoveryDisabled: "Gateway recuperado com Feishu desativado; o backup da configuração original foi mantido."
|
AgentFeishuRecoveryDisabled: "Gateway recuperado com Feishu desativado; o backup da configuração original foi mantido."
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
||||||
|
ContainerImageCheckUpdate: "Verificar atualizações de imagens"
|
||||||
|
ContainerImageCheckScope: "Verificando todos os {{ .count }} contêineres deste nó, independentemente da paginação e dos filtros"
|
||||||
|
ContainerImageCheckItem: "Verificando o contêiner {{ .name }}, imagem {{ .image }}"
|
||||||
|
ContainerImageCheckFailed: "Falha ao verificar o contêiner {{ .name }}: {{ .error }}; os resultados anteriores correspondentes à imagem atual serão mantidos"
|
||||||
|
ContainerImageCheckSkipped: "Ignorando {{ .name }}: não é uma imagem do Docker Hub com tag, é uma imagem local ou tem digest fixo"
|
||||||
|
ContainerImageCheckAvailable: "O contêiner {{ .name }} tem uma atualização: {{ .image }} → {{ .target }}"
|
||||||
|
ContainerImageCheckCurrent: "Nenhuma atualização encontrada para o contêiner {{ .name }}"
|
||||||
|
ContainerImageCheckChanged: "O contêiner {{ .name }} foi removido ou sua imagem mudou; o resultado não foi salvo"
|
||||||
|
ContainerImageCheckSummary: "Verificação concluída: verificados {{ .checked }}, atualizáveis {{ .updates }}, falhas {{ .failed }}, ignorados {{ .skipped }}"
|
||||||
|
ContainerImageCheckFailures: "Falha na verificação de {{ .count }} contêineres; consulte o log da tarefa"
|
||||||
|
|
||||||
|
ErrImageCheckProxyRequired: "Para verificar atualizações de imagens no ambiente atual, configure primeiro um servidor proxy."
|
||||||
|
|||||||
@@ -802,3 +802,15 @@ ErrFeishuUpgradeMounts: "Эти монтирования не поддержив
|
|||||||
AgentFeishuRecoveryDisabled: "Шлюз восстановлен, Feishu отключён; исходная резервная копия конфигурации сохранена."
|
AgentFeishuRecoveryDisabled: "Шлюз восстановлен, Feishu отключён; исходная резервная копия конфигурации сохранена."
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
||||||
|
ContainerImageCheckUpdate: "Проверить обновления образов"
|
||||||
|
ContainerImageCheckScope: "Проверка всех контейнеров на текущем узле ({{ .count }}) независимо от страниц и фильтров"
|
||||||
|
ContainerImageCheckItem: "Проверка контейнера {{ .name }}, образ {{ .image }}"
|
||||||
|
ContainerImageCheckFailed: "Ошибка проверки контейнера {{ .name }}: {{ .error }}; сохранены предыдущие результаты, соответствующие текущему образу"
|
||||||
|
ContainerImageCheckSkipped: "Пропуск {{ .name }}: это не образ Docker Hub с тегом, локальный образ или образ с фиксированным digest"
|
||||||
|
ContainerImageCheckAvailable: "Доступно обновление контейнера {{ .name }}: {{ .image }} → {{ .target }}"
|
||||||
|
ContainerImageCheckCurrent: "Обновлений для контейнера {{ .name }} не найдено"
|
||||||
|
ContainerImageCheckChanged: "Контейнер {{ .name }} удалён или его образ изменился; результат не сохранён"
|
||||||
|
ContainerImageCheckSummary: "Проверка завершена: проверено {{ .checked }}, доступны обновления {{ .updates }}, ошибок {{ .failed }}, пропущено {{ .skipped }}"
|
||||||
|
ContainerImageCheckFailures: "Не удалось проверить контейнеры: {{ .count }}. Подробности в журнале задачи"
|
||||||
|
|
||||||
|
ErrImageCheckProxyRequired: "Для проверки обновлений образов в текущей среде сначала настройте прокси-сервер."
|
||||||
|
|||||||
@@ -802,3 +802,15 @@ ErrFeishuUpgradeMounts: "Bu bağlamalar tam yedeklenip geri yüklenemiyor. Uygul
|
|||||||
AgentFeishuRecoveryDisabled: "Ağ geçidi kurtarıldı, Feishu kapalı; özgün yapılandırma yedeği korundu."
|
AgentFeishuRecoveryDisabled: "Ağ geçidi kurtarıldı, Feishu kapalı; özgün yapılandırma yedeği korundu."
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
ContainerUpgradeWaiting: "Waiting for a container upgrade execution slot"
|
||||||
|
ContainerImageCheckUpdate: "İmaj güncellemelerini denetle"
|
||||||
|
ContainerImageCheckScope: "Sayfalama ve filtrelerden bağımsız olarak bu düğümdeki {{ .count }} konteynerin tümü denetleniyor"
|
||||||
|
ContainerImageCheckItem: "Konteyner {{ .name }}, imaj {{ .image }} denetleniyor"
|
||||||
|
ContainerImageCheckFailed: "{{ .name }} konteyneri denetlenemedi: {{ .error }}; mevcut imajla eşleşen önceki sonuçlar korunuyor"
|
||||||
|
ContainerImageCheckSkipped: "{{ .name }} atlanıyor: etiketli bir Docker Hub imajı değil, yerel bir imaj veya digest değeri sabitlenmiş"
|
||||||
|
ContainerImageCheckAvailable: "{{ .name }} konteyneri için güncelleme mevcut: {{ .image }} → {{ .target }}"
|
||||||
|
ContainerImageCheckCurrent: "{{ .name }} konteyneri için güncelleme bulunamadı"
|
||||||
|
ContainerImageCheckChanged: "{{ .name }} konteyneri silindi veya imajı değişti; sonuç kaydedilmedi"
|
||||||
|
ContainerImageCheckSummary: "Denetim tamamlandı: denetlenen {{ .checked }}, güncellenebilir {{ .updates }}, başarısız {{ .failed }}, atlanan {{ .skipped }}"
|
||||||
|
ContainerImageCheckFailures: "{{ .count }} konteyner denetlenemedi; görev günlüğünü inceleyin"
|
||||||
|
|
||||||
|
ErrImageCheckProxyRequired: "Geçerli çalışma ortamında imaj güncellemelerini kontrol etmek için önce bir proxy sunucusu yapılandırın."
|
||||||
|
|||||||
@@ -802,3 +802,15 @@ ErrFeishuUpgradeMounts: "目前掛載無法完整備份或還原,請使用應
|
|||||||
AgentFeishuRecoveryDisabled: "閘道已恢復,飛書保持停用;原設定備份已保留。"
|
AgentFeishuRecoveryDisabled: "閘道已恢復,飛書保持停用;原設定備份已保留。"
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "等待容器升級任務執行名額"
|
ContainerUpgradeWaiting: "等待容器升級任務執行名額"
|
||||||
|
ContainerImageCheckUpdate: "檢查更新映像"
|
||||||
|
ContainerImageCheckScope: "檢查目前節點全部 {{ .count }} 個容器,不受分頁和篩選影響"
|
||||||
|
ContainerImageCheckItem: "檢查容器 {{ .name }},映像 {{ .image }}"
|
||||||
|
ContainerImageCheckFailed: "容器 {{ .name }} 檢查失敗:{{ .error }};保留符合目前映像的上次結果"
|
||||||
|
ContainerImageCheckSkipped: "略過 {{ .name }}:非 Docker Hub 標籤映像、本機映像或固定 digest"
|
||||||
|
ContainerImageCheckAvailable: "容器 {{ .name }} 可升級:{{ .image }} → {{ .target }}"
|
||||||
|
ContainerImageCheckCurrent: "容器 {{ .name }} 未發現更新"
|
||||||
|
ContainerImageCheckChanged: "容器 {{ .name }} 已刪除或映像已變更,未儲存本次結果"
|
||||||
|
ContainerImageCheckSummary: "檢查結束:成功 {{ .checked }},可升級 {{ .updates }},失敗 {{ .failed }},略過 {{ .skipped }}"
|
||||||
|
ContainerImageCheckFailures: "{{ .count }} 個容器檢查失敗,請查看任務日誌"
|
||||||
|
|
||||||
|
ErrImageCheckProxyRequired: "目前運行環境下,如需檢查映像是否更新,請先設定代理伺服器"
|
||||||
|
|||||||
@@ -802,3 +802,15 @@ ErrFeishuUpgradeMounts: "当前挂载无法完整备份或恢复,请使用应
|
|||||||
AgentFeishuRecoveryDisabled: "网关已恢复,飞书保持停用;原配置备份已保留。"
|
AgentFeishuRecoveryDisabled: "网关已恢复,飞书保持停用;原配置备份已保留。"
|
||||||
|
|
||||||
ContainerUpgradeWaiting: "等待容器升级任务执行名额"
|
ContainerUpgradeWaiting: "等待容器升级任务执行名额"
|
||||||
|
ContainerImageCheckUpdate: "检查更新镜像"
|
||||||
|
ContainerImageCheckScope: "检查当前节点全部 {{ .count }} 个容器,不受分页和筛选影响"
|
||||||
|
ContainerImageCheckItem: "检查容器 {{ .name }},镜像 {{ .image }}"
|
||||||
|
ContainerImageCheckFailed: "容器 {{ .name }} 检查失败:{{ .error }};保留匹配当前镜像的上次结果"
|
||||||
|
ContainerImageCheckSkipped: "跳过 {{ .name }}:非 Docker Hub 标签镜像、本地镜像或固定 digest"
|
||||||
|
ContainerImageCheckAvailable: "容器 {{ .name }} 可升级:{{ .image }} → {{ .target }}"
|
||||||
|
ContainerImageCheckCurrent: "容器 {{ .name }} 未发现更新"
|
||||||
|
ContainerImageCheckChanged: "容器 {{ .name }} 已删除或镜像发生变化,未保存本次结果"
|
||||||
|
ContainerImageCheckSummary: "检查结束:成功 {{ .checked }},可升级 {{ .updates }},失败 {{ .failed }},跳过 {{ .skipped }}"
|
||||||
|
ContainerImageCheckFailures: "{{ .count }} 个容器检查失败,请查看任务日志"
|
||||||
|
|
||||||
|
ErrImageCheckProxyRequired: "当前运行环境下,如需检查镜像是否更新,请优先配置代理服务器"
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ func agentDBMigrations() []*gormigrate.Migration {
|
|||||||
migrations.AddQuickDirectory,
|
migrations.AddQuickDirectory,
|
||||||
migrations.AddLocalModelAccountLink,
|
migrations.AddLocalModelAccountLink,
|
||||||
migrations.AddOllamaModelAccountSync,
|
migrations.AddOllamaModelAccountSync,
|
||||||
|
migrations.AddContainerImageCheckUpdates,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var AddContainerImageCheckUpdates = &gormigrate.Migration{
|
||||||
|
ID: "20261028-container-image-updates",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
if err := tx.AutoMigrate(&model.ContainerImageCheckUpdate{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Where("key = ?", "ContainerImageUpdateEnabled").FirstOrCreate(&model.Setting{Key: "ContainerImageUpdateEnabled", Value: "Disable"}).Error
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ func (s *ContainerRouter) InitRouter(Router *gin.RouterGroup) {
|
|||||||
baRouter.POST("", baseApi.ContainerCreate)
|
baRouter.POST("", baseApi.ContainerCreate)
|
||||||
baRouter.POST("/update", baseApi.ContainerUpdate)
|
baRouter.POST("/update", baseApi.ContainerUpdate)
|
||||||
baRouter.POST("/upgrade", baseApi.ContainerUpgrade)
|
baRouter.POST("/upgrade", baseApi.ContainerUpgrade)
|
||||||
|
baRouter.GET("/image-updates", baseApi.GetContainerImageCheckUpdateStatus)
|
||||||
|
baRouter.POST("/image-updates/check", baseApi.ContainerImageCheckUpdate)
|
||||||
|
baRouter.POST("/image-updates/schedule", baseApi.SetContainerImageCheckUpdateSchedule)
|
||||||
baRouter.POST("/info", baseApi.ContainerInfo)
|
baRouter.POST("/info", baseApi.ContainerInfo)
|
||||||
baRouter.POST("/search", baseApi.SearchContainer)
|
baRouter.POST("/search", baseApi.SearchContainer)
|
||||||
baRouter.POST("/list", baseApi.ListContainer)
|
baRouter.POST("/list", baseApi.ListContainer)
|
||||||
@@ -64,6 +67,7 @@ func (s *ContainerRouter) InitRouter(Router *gin.RouterGroup) {
|
|||||||
baRouter.POST("/template", baseApi.CreateComposeTemplate)
|
baRouter.POST("/template", baseApi.CreateComposeTemplate)
|
||||||
baRouter.POST("/template/del", baseApi.DeleteComposeTemplate)
|
baRouter.POST("/template/del", baseApi.DeleteComposeTemplate)
|
||||||
|
|
||||||
|
baRouter.GET("/image/remote/check", baseApi.GetImageRemoteCheckStatus)
|
||||||
baRouter.GET("/image", baseApi.ListImage)
|
baRouter.GET("/image", baseApi.ListImage)
|
||||||
baRouter.GET("/image/all", baseApi.ListAllImage)
|
baRouter.GET("/image/all", baseApi.ListAllImage)
|
||||||
baRouter.POST("/image/search", baseApi.SearchImage)
|
baRouter.POST("/image/search", baseApi.SearchImage)
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
// Package dockerhub checks Docker Hub metadata without downloading image layers.
|
||||||
|
package dockerhub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/global"
|
||||||
|
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
|
||||||
|
"github.com/distribution/reference"
|
||||||
|
)
|
||||||
|
|
||||||
|
const registryURL = "https://registry-1.docker.io"
|
||||||
|
|
||||||
|
// Reference only accepts tagged Docker Hub references. Digest pins are intentional.
|
||||||
|
func Reference(raw string) (string, string, bool) {
|
||||||
|
if strings.HasPrefix(raw, "sha256:") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
ref, err := reference.ParseNormalizedNamed(raw)
|
||||||
|
if err != nil || reference.Domain(ref) != "docker.io" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
if _, pinned := ref.(reference.Digested); pinned {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
tagged := reference.TagNameOnly(ref).(reference.NamedTagged)
|
||||||
|
return reference.Path(ref), tagged.Tag(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
HTTP *http.Client
|
||||||
|
repository string
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Platform struct{ OS, Architecture, Variant string }
|
||||||
|
type Target struct{ Tag, Digest, ConfigDigest string }
|
||||||
|
|
||||||
|
func New(ctx context.Context, repository, username, password string, httpClient *http.Client) (*Client, error) {
|
||||||
|
if httpClient == nil {
|
||||||
|
httpClient = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
}
|
||||||
|
c := &Client{HTTP: httpClient, repository: repository}
|
||||||
|
endpoint := "https://auth.docker.io/token?service=registry.docker.io&scope=" + url.QueryEscape("repository:"+repository+":pull")
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if username != "" {
|
||||||
|
req.SetBasicAuth(username, password)
|
||||||
|
}
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("Docker Hub authentication: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.token = body.Token
|
||||||
|
if c.token == "" {
|
||||||
|
c.token = body.AccessToken
|
||||||
|
}
|
||||||
|
if c.token == "" {
|
||||||
|
return nil, fmt.Errorf("Docker Hub returned an empty token")
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) get(ctx context.Context, endpoint string, result interface{}) (http.Header, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
req.Header.Set("Accept", "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json")
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("Docker Hub metadata: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
err = json.NewDecoder(io.LimitReader(resp.Body, 16<<20)).Decode(result)
|
||||||
|
return resp.Header, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Tags(ctx context.Context) ([]string, error) {
|
||||||
|
endpoint := registryURL + "/v2/" + c.repository + "/tags/list?n=1000"
|
||||||
|
var tags []string
|
||||||
|
for page := 0; page < 100; page++ {
|
||||||
|
var body struct {
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
headers, err := c.get(ctx, endpoint, &body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tags = append(tags, body.Tags...)
|
||||||
|
link := headers.Get("Link")
|
||||||
|
if link == "" {
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
start, end := strings.Index(link, "<"), strings.Index(link, ">")
|
||||||
|
if start < 0 || end <= start {
|
||||||
|
return nil, fmt.Errorf("invalid Docker Hub pagination")
|
||||||
|
}
|
||||||
|
base, _ := url.Parse(registryURL)
|
||||||
|
next, err := base.Parse(link[start+1 : end])
|
||||||
|
if err != nil || next.Scheme != "https" || next.Host != base.Host || next.Path != "/v2/"+c.repository+"/tags/list" {
|
||||||
|
return nil, fmt.Errorf("invalid Docker Hub pagination URL")
|
||||||
|
}
|
||||||
|
endpoint = next.String()
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("Docker Hub tag pagination limit exceeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
type manifest struct {
|
||||||
|
Config struct {
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
} `json:"config"`
|
||||||
|
Manifests []struct {
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
Platform struct {
|
||||||
|
OS string `json:"os"`
|
||||||
|
Architecture string `json:"architecture"`
|
||||||
|
Variant string `json:"variant"`
|
||||||
|
} `json:"platform"`
|
||||||
|
} `json:"manifests"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Resolve(ctx context.Context, tag string, platform Platform) (Target, bool, error) {
|
||||||
|
var m manifest
|
||||||
|
headers, err := c.get(ctx, registryURL+"/v2/"+c.repository+"/manifests/"+url.PathEscape(tag), &m)
|
||||||
|
if err != nil {
|
||||||
|
return Target{}, false, err
|
||||||
|
}
|
||||||
|
digest := headers.Get("Docker-Content-Digest")
|
||||||
|
if len(m.Manifests) > 0 {
|
||||||
|
digest = ""
|
||||||
|
for _, item := range m.Manifests {
|
||||||
|
if item.Platform.OS == platform.OS && item.Platform.Architecture == platform.Architecture && normalizedVariant(platform.Architecture, item.Platform.Variant) == normalizedVariant(platform.Architecture, platform.Variant) {
|
||||||
|
digest = item.Digest
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if digest == "" {
|
||||||
|
return Target{}, false, nil
|
||||||
|
}
|
||||||
|
m = manifest{}
|
||||||
|
if _, err := c.get(ctx, registryURL+"/v2/"+c.repository+"/manifests/"+url.PathEscape(digest), &m); err != nil {
|
||||||
|
return Target{}, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.Config.Digest == "" {
|
||||||
|
return Target{}, false, fmt.Errorf("unsupported Docker Hub manifest")
|
||||||
|
}
|
||||||
|
// Validate single-platform manifests too; the config digest is the Docker Image ID.
|
||||||
|
var config struct {
|
||||||
|
OS string `json:"os"`
|
||||||
|
Architecture string `json:"architecture"`
|
||||||
|
Variant string `json:"variant"`
|
||||||
|
}
|
||||||
|
if _, err := c.get(ctx, registryURL+"/v2/"+c.repository+"/blobs/"+url.PathEscape(m.Config.Digest), &config); err != nil {
|
||||||
|
return Target{}, false, err
|
||||||
|
}
|
||||||
|
if config.OS != platform.OS || config.Architecture != platform.Architecture || (platform.Variant != "" && config.Variant != "" && config.Variant != platform.Variant) {
|
||||||
|
return Target{}, false, nil
|
||||||
|
}
|
||||||
|
return Target{Tag: tag, Digest: digest, ConfigDigest: m.Config.Digest}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var versionPattern = regexp.MustCompile(`^v?(\d+)\.(\d+)(?:\.(\d+))?(-[a-zA-Z][a-zA-Z0-9_.-]*)?$`)
|
||||||
|
var prereleasePattern = regexp.MustCompile(`(?i)(^|[-_.])(alpha|beta|rc|dev|nightly|edge|test|unstable|preview)([0-9_.-]|$)`)
|
||||||
|
|
||||||
|
type version struct {
|
||||||
|
numbers [3]uint64
|
||||||
|
parts int
|
||||||
|
variant string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseVersion(tag string) (version, bool) {
|
||||||
|
m := versionPattern.FindStringSubmatch(tag)
|
||||||
|
if m == nil || prereleasePattern.MatchString(m[4]) {
|
||||||
|
return version{}, false
|
||||||
|
}
|
||||||
|
v := version{parts: 2, variant: m[4]}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if m[i+1] != "" {
|
||||||
|
n, err := strconv.ParseUint(m[i+1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return version{}, false
|
||||||
|
}
|
||||||
|
v.numbers[i] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m[3] != "" {
|
||||||
|
v.parts = 3
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
func greater(a, b version) bool {
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if a.numbers[i] != b.numbers[i] {
|
||||||
|
return a.numbers[i] > b.numbers[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidates preserves the current tag's major version, variant and precision. Unknown/floating
|
||||||
|
// tags are checked only for content changes, never guessed from upload dates.
|
||||||
|
func Candidates(current string, tags []string) []string {
|
||||||
|
baseline, ok := parseVersion(current)
|
||||||
|
if !ok {
|
||||||
|
return []string{current}
|
||||||
|
}
|
||||||
|
type candidate struct {
|
||||||
|
tag string
|
||||||
|
version version
|
||||||
|
}
|
||||||
|
candidates := []candidate{}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, tag := range tags {
|
||||||
|
v, valid := parseVersion(tag)
|
||||||
|
if valid && v.numbers[0] == baseline.numbers[0] && v.parts == baseline.parts && v.variant == baseline.variant && greater(v, baseline) && !seen[tag] {
|
||||||
|
candidates = append(candidates, candidate{tag: tag, version: v})
|
||||||
|
seen[tag] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(candidates, func(i, j int) bool {
|
||||||
|
a, b := candidates[i], candidates[j]
|
||||||
|
if a.version.numbers == b.version.numbers {
|
||||||
|
return a.tag < b.tag
|
||||||
|
}
|
||||||
|
return greater(a.version, b.version)
|
||||||
|
})
|
||||||
|
result := make([]string, 0, len(candidates)+1)
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
result = append(result, candidate.tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return append(result, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NeedsTags(tag string) bool { _, ok := parseVersion(tag); return ok }
|
||||||
|
|
||||||
|
func normalizedVariant(architecture, variant string) string {
|
||||||
|
if architecture == "arm64" && variant == "" {
|
||||||
|
return "v8"
|
||||||
|
}
|
||||||
|
return variant
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTTPClient reads the synchronized panel settings for each check, including cron.
|
||||||
|
func NewHTTPClient() (*http.Client, error) {
|
||||||
|
var rows []model.Setting
|
||||||
|
if err := global.DB.Where("key IN ?", []string{"Edition", "ProxyType", "ProxyUrl", "ProxyPort", "ProxyUser", "ProxyPasswd"}).Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
settings := make(map[string]string, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
settings[row.Key] = row.Value
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
if settings["Edition"] == "intl" {
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
kind, host, port := settings["ProxyType"], strings.TrimSpace(settings["ProxyUrl"]), settings["ProxyPort"]
|
||||||
|
if kind == "" || kind == "close" || host == "" || port == "" {
|
||||||
|
return nil, buserr.New("ErrImageCheckProxyRequired")
|
||||||
|
}
|
||||||
|
if kind != "http" && kind != "https" && kind != "socks5" {
|
||||||
|
return nil, buserr.New("ErrImageCheckProxyRequired")
|
||||||
|
}
|
||||||
|
number, err := strconv.Atoi(port)
|
||||||
|
if err != nil || number < 1 || number > 65535 {
|
||||||
|
return nil, buserr.New("ErrImageCheckProxyRequired")
|
||||||
|
}
|
||||||
|
if !strings.Contains(host, "://") {
|
||||||
|
host = kind + "://" + host
|
||||||
|
}
|
||||||
|
address, err := url.Parse(host)
|
||||||
|
if err != nil || address.Scheme != kind || address.Hostname() == "" || address.User != nil || address.RawQuery != "" || address.Fragment != "" || (address.Path != "" && address.Path != "/") {
|
||||||
|
return nil, buserr.New("ErrImageCheckProxyRequired")
|
||||||
|
}
|
||||||
|
address.Host = net.JoinHostPort(address.Hostname(), port)
|
||||||
|
address.Path = ""
|
||||||
|
if settings["ProxyUser"] != "" {
|
||||||
|
password, err := encrypt.StringDecrypt(settings["ProxyPasswd"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, buserr.New("ErrImageCheckProxyRequired")
|
||||||
|
}
|
||||||
|
address.User = url.UserPassword(settings["ProxyUser"], password)
|
||||||
|
}
|
||||||
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
// Explicit proxy selection prevents NO_PROXY from bypassing the panel proxy.
|
||||||
|
// net/http also supports SOCKS5 URLs and proxy authentication.
|
||||||
|
transport.Proxy = http.ProxyURL(address)
|
||||||
|
client.Transport = transport
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
+2
-144
@@ -15,7 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func bindAPIKeyRequest(c *gin.Context, value interface{}) bool {
|
func bindAPIKeyRequest(c *gin.Context, value interface{}) bool {
|
||||||
if _, err := auth.RequireAPIKeyManagement(c); err != nil {
|
if _, err := auth.RequireAPIKeySession(c); err != nil {
|
||||||
helper.BadAuth(c, "ErrNotLogin", nil)
|
helper.BadAuth(c, "ErrNotLogin", nil)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -41,24 +41,6 @@ func apiKeyError(c *gin.Context, err error) {
|
|||||||
helper.InternalServer(c, err)
|
helper.InternalServer(c, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swag's legacy || syntax combines schemes in one requirement (AND);
|
|
||||||
// separate @Security lines represent alternative authentication methods.
|
|
||||||
// @Tags API Key
|
|
||||||
// @Summary List the current account's API keys
|
|
||||||
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. The authenticated account determines ownership; another account cannot be selected in the request.
|
|
||||||
// @Description Returns key metadata and masked hints, never plaintext secrets. The legacy key is included when present; deleted keys are excluded.
|
|
||||||
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500; check code rather than HTTP status alone.
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param request body dto.APIKeySearch true "Pagination and revoked-key filter"
|
|
||||||
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
|
|
||||||
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
|
|
||||||
// @Success 200 {object} dto.Response{data=dto.APIKeyPage} "code=200 on success; data is null on errors"
|
|
||||||
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
|
|
||||||
// @Header 200 {string} Cache-Control "no-store"
|
|
||||||
// @Security ApiKeyAuth || Timestamp
|
|
||||||
// @Security PanelSession || CSRFToken
|
|
||||||
// @Router /core/auth/api/keys/search [post]
|
|
||||||
func (b *BaseApi) SearchAPIKeys(c *gin.Context) {
|
func (b *BaseApi) SearchAPIKeys(c *gin.Context) {
|
||||||
var req dto.APIKeySearch
|
var req dto.APIKeySearch
|
||||||
if !bindAPIKeyRequest(c, &req) {
|
if !bindAPIKeyRequest(c, &req) {
|
||||||
@@ -73,25 +55,6 @@ func (b *BaseApi) SearchAPIKeys(c *gin.Context) {
|
|||||||
helper.SuccessWithData(c, result)
|
helper.SuccessWithData(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Tags API Key
|
|
||||||
// @Summary Create an API key for the current account
|
|
||||||
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. The new key belongs to the authenticated account.
|
|
||||||
// @Description requestID is optional. Omitted or empty values are generated by the server. For idempotent retries, supply a UUID and reuse it with the same payload.
|
|
||||||
// @Description A successful first request returns the plaintext secret. Repeating the same requestID and payload returns alreadyCreated=true and an empty apiKey; changing the payload for that requestID is rejected.
|
|
||||||
// @Description Omitted expiresAt defaults to 90 days; explicit null means no expiration. Omitted apiKeyValidityTime defaults to 120 minutes; zero disables timestamp validation.
|
|
||||||
// @Description API-created keys may have a longer lifetime or broader IP policy than the calling key. Revoking the calling key does not revoke keys it created.
|
|
||||||
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500; check code rather than HTTP status alone. Unknown fields and bodies larger than 32 KiB are rejected.
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param request body dto.APIKeyCreate true "Key policy and an optional UUID requestID for idempotency"
|
|
||||||
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
|
|
||||||
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
|
|
||||||
// @Success 200 {object} dto.Response{data=dto.APIKeyCreated} "code=200 on success; data is null on errors"
|
|
||||||
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
|
|
||||||
// @Header 200 {string} Cache-Control "no-store"
|
|
||||||
// @Security ApiKeyAuth || Timestamp
|
|
||||||
// @Security PanelSession || CSRFToken
|
|
||||||
// @Router /core/auth/api/keys/create [post]
|
|
||||||
func (b *BaseApi) CreateAPIKey(c *gin.Context) {
|
func (b *BaseApi) CreateAPIKey(c *gin.Context) {
|
||||||
req := dto.APIKeyCreate{APIKeyFields: dto.APIKeyFields{APIKeyValidityTime: 120}}
|
req := dto.APIKeyCreate{APIKeyFields: dto.APIKeyFields{APIKeyValidityTime: 120}}
|
||||||
if !bindAPIKeyRequest(c, &req) {
|
if !bindAPIKeyRequest(c, &req) {
|
||||||
@@ -106,22 +69,6 @@ func (b *BaseApi) CreateAPIKey(c *gin.Context) {
|
|||||||
helper.SuccessWithData(c, result)
|
helper.SuccessWithData(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Tags API Key
|
|
||||||
// @Summary Update an API key's policy
|
|
||||||
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be changed.
|
|
||||||
// @Description Submit the current revision returned by search; stale revisions are rejected. This replaces editable policy fields without changing the secret. expiresAt must be supplied as an RFC 3339 timestamp or null.
|
|
||||||
// @Description Use id=legacy for the legacy key; its name is fixed and expiresAt must be null. Revoked or deleted independent keys cannot be updated.
|
|
||||||
// @Description Success increments the revision; search again before another mutation. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param request body dto.APIKeyUpdate true "Key ID, current revision and complete editable policy"
|
|
||||||
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
|
|
||||||
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
|
|
||||||
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
|
|
||||||
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
|
|
||||||
// @Security ApiKeyAuth || Timestamp
|
|
||||||
// @Security PanelSession || CSRFToken
|
|
||||||
// @Router /core/auth/api/keys/update [post]
|
|
||||||
func (b *BaseApi) UpdateAPIKey(c *gin.Context) {
|
func (b *BaseApi) UpdateAPIKey(c *gin.Context) {
|
||||||
var req dto.APIKeyUpdate
|
var req dto.APIKeyUpdate
|
||||||
if !bindAPIKeyRequest(c, &req) {
|
if !bindAPIKeyRequest(c, &req) {
|
||||||
@@ -134,21 +81,6 @@ func (b *BaseApi) UpdateAPIKey(c *gin.Context) {
|
|||||||
apiKeyMutationSuccess(c)
|
apiKeyMutationSuccess(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Tags API Key
|
|
||||||
// @Summary Enable or disable an API key
|
|
||||||
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be changed; id=legacy is supported.
|
|
||||||
// @Description Submit the current revision returned by search. Revoked or deleted keys cannot be enabled; expired keys must first receive a future expiration time. Disabling a key also closes its active terminal sessions.
|
|
||||||
// @Description Success increments the revision; search again before another mutation. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param request body dto.APIKeyStatus true "Key ID, current revision and Enable or Disable status"
|
|
||||||
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
|
|
||||||
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
|
|
||||||
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
|
|
||||||
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
|
|
||||||
// @Security ApiKeyAuth || Timestamp
|
|
||||||
// @Security PanelSession || CSRFToken
|
|
||||||
// @Router /core/auth/api/keys/status [post]
|
|
||||||
func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) {
|
func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) {
|
||||||
var req dto.APIKeyStatus
|
var req dto.APIKeyStatus
|
||||||
if !bindAPIKeyRequest(c, &req) {
|
if !bindAPIKeyRequest(c, &req) {
|
||||||
@@ -161,22 +93,6 @@ func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) {
|
|||||||
apiKeyMutationSuccess(c)
|
apiKeyMutationSuccess(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Tags API Key
|
|
||||||
// @Summary Revoke an API key
|
|
||||||
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be revoked.
|
|
||||||
// @Description Submit the current revision returned by search. Revocation destroys the independent key's stored secret and cannot be undone; its metadata remains listed. For id=legacy, the legacy secret is cleared and API access is disabled.
|
|
||||||
// @Description A key can revoke itself; subsequent requests signed with it fail. Keys previously created by the revoked key remain independent. Success increments the revision and closes associated terminal sessions.
|
|
||||||
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param request body dto.APIKeyMutation true "Key ID and current revision"
|
|
||||||
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
|
|
||||||
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
|
|
||||||
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
|
|
||||||
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
|
|
||||||
// @Security ApiKeyAuth || Timestamp
|
|
||||||
// @Security PanelSession || CSRFToken
|
|
||||||
// @Router /core/auth/api/keys/revoke [post]
|
|
||||||
func (b *BaseApi) RevokeAPIKey(c *gin.Context) {
|
func (b *BaseApi) RevokeAPIKey(c *gin.Context) {
|
||||||
var req dto.APIKeyMutation
|
var req dto.APIKeyMutation
|
||||||
if !bindAPIKeyRequest(c, &req) {
|
if !bindAPIKeyRequest(c, &req) {
|
||||||
@@ -190,63 +106,5 @@ func (b *BaseApi) RevokeAPIKey(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func apiKeyMutationSuccess(c *gin.Context) {
|
func apiKeyMutationSuccess(c *gin.Context) {
|
||||||
helper.SuccessWithData(c, dto.APIKeyMutationResult{TerminalClosePending: c.GetBool("API_KEY_TERMINAL_CLOSE_PENDING")})
|
helper.SuccessWithData(c, gin.H{"terminalClosePending": c.GetBool("API_KEY_TERMINAL_CLOSE_PENDING")})
|
||||||
}
|
|
||||||
|
|
||||||
// @Tags API Key
|
|
||||||
// @Summary Delete an independent API key
|
|
||||||
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be deleted; id=legacy is not supported.
|
|
||||||
// @Description Submit the current revision returned by search. Deletion immediately revokes the key, destroys its stored secret and removes it from lists. Audit and idempotency records are retained. Already-revoked keys can be deleted; deletion cannot be undone.
|
|
||||||
// @Description A key can delete itself; subsequent requests signed with it fail. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param request body dto.APIKeyMutation true "Independent key ID and current revision"
|
|
||||||
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
|
|
||||||
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
|
|
||||||
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
|
|
||||||
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
|
|
||||||
// @Security ApiKeyAuth || Timestamp
|
|
||||||
// @Security PanelSession || CSRFToken
|
|
||||||
// @Router /core/auth/api/keys/delete [post]
|
|
||||||
func (b *BaseApi) DeleteAPIKey(c *gin.Context) {
|
|
||||||
var req dto.APIKeyMutation
|
|
||||||
if !bindAPIKeyRequest(c, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := service.NewAPIKeyService().Delete(c, req); err != nil {
|
|
||||||
apiKeyError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
apiKeyMutationSuccess(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
// @Tags API Key
|
|
||||||
// @Summary Reveal an independent API key's plaintext secret
|
|
||||||
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported, including requests that also contain a valid session cookie; omit all 1Panel authentication headers.
|
|
||||||
// @Description Only the current account's independent keys can be revealed. Submit the current revision returned by search. Legacy, revoked and deleted keys are rejected; disabled or expired keys can still be revealed.
|
|
||||||
// @Description Secret disclosure is audited. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500. The response must not be cached or logged.
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param request body dto.APIKeyMutation true "Independent key ID and current revision"
|
|
||||||
// @Success 200 {object} dto.Response{data=dto.APIKeyRevealed} "code=200 on success; data is null on errors"
|
|
||||||
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token"
|
|
||||||
// @Header 200 {string} Cache-Control "no-store"
|
|
||||||
// @Security PanelSession || CSRFToken
|
|
||||||
// @Router /core/auth/api/keys/reveal [post]
|
|
||||||
func (b *BaseApi) RevealAPIKey(c *gin.Context) {
|
|
||||||
c.Header("Cache-Control", "no-store")
|
|
||||||
if _, err := auth.RequireAPIKeySession(c); err != nil {
|
|
||||||
helper.BadAuth(c, "ErrNotLogin", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req dto.APIKeyMutation
|
|
||||||
if !bindAPIKeyRequest(c, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
result, err := service.NewAPIKeyService().Reveal(c, req)
|
|
||||||
if err != nil {
|
|
||||||
apiKeyError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
helper.SuccessWithData(c, result)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-11
@@ -417,13 +417,11 @@ func (b *BaseApi) MFAClose(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @Tags Auth
|
// @Tags Auth
|
||||||
// @Summary Generate or reset the legacy API key
|
// @Summary generate api key
|
||||||
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported. This replaces the current account's legacy secret; existing clients using the previous secret must be updated. Independent API keys are unaffected.
|
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Success 200 {string} key
|
||||||
// @Success 200 {object} dto.Response{data=string} "code=200 with the new secret; authentication and business errors use envelope code 400, 401 or 500"
|
// @Security ApiKeyAuth
|
||||||
// @Header 200 {string} Cache-Control "no-store"
|
// @Security Timestamp
|
||||||
// @Security PanelSession || CSRFToken
|
|
||||||
// @Router /core/auth/api/generate [post]
|
// @Router /core/auth/api/generate [post]
|
||||||
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成 API 接口密钥","formatEN":"generate api key"}
|
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成 API 接口密钥","formatEN":"generate api key"}
|
||||||
func (b *BaseApi) GenerateApiKey(c *gin.Context) {
|
func (b *BaseApi) GenerateApiKey(c *gin.Context) {
|
||||||
@@ -442,13 +440,12 @@ func (b *BaseApi) GenerateApiKey(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @Tags Auth
|
// @Tags Auth
|
||||||
// @Summary Update the legacy API key configuration
|
// @Summary Update api config
|
||||||
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported. This updates only the current account's legacy API configuration.
|
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
|
||||||
// @Param request body dto.ApiInterfaceConfig true "request"
|
// @Param request body dto.ApiInterfaceConfig true "request"
|
||||||
// @Success 200 {object} dto.Response "code=200 on success; authentication and business errors use envelope code 400, 401 or 500"
|
// @Success 200
|
||||||
// @Security PanelSession || CSRFToken
|
// @Security ApiKeyAuth
|
||||||
|
// @Security Timestamp
|
||||||
// @Router /core/auth/api/update [post]
|
// @Router /core/auth/api/update [post]
|
||||||
// @x-panel-log {"bodyKeys":["ipWhiteList","apiTrustedProxies"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 API 接口配置 => IP 白名单: [ipWhiteList], API 可信代理: [apiTrustedProxies]","formatEN":"update api config => IP Allowlist: [ipWhiteList], API Trusted Proxies: [apiTrustedProxies]"}
|
// @x-panel-log {"bodyKeys":["ipWhiteList","apiTrustedProxies"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 API 接口配置 => IP 白名单: [ipWhiteList], API 可信代理: [apiTrustedProxies]","formatEN":"update api config => IP Allowlist: [ipWhiteList], API Trusted Proxies: [apiTrustedProxies]"}
|
||||||
func (b *BaseApi) UpdateApiConfig(c *gin.Context) {
|
func (b *BaseApi) UpdateApiConfig(c *gin.Context) {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -43,7 +42,7 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH
|
|||||||
ownerProvider = ownerProviders[0]
|
ownerProvider = ownerProviders[0]
|
||||||
}
|
}
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if strings.HasPrefix(c.Request.URL.Path, "/api/v2/core/auth") && !isAPIKeyManagementRequest(c) {
|
if strings.HasPrefix(c.Request.URL.Path, "/api/v2/core/auth") {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -98,20 +97,6 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func isAPIKeyManagementRequest(c *gin.Context) bool {
|
|
||||||
if c.Request.Method != http.MethodPost {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
switch c.Request.URL.Path {
|
|
||||||
case "/api/v2/core/auth/api/keys/search", "/api/v2/core/auth/api/keys/create",
|
|
||||||
"/api/v2/core/auth/api/keys/update", "/api/v2/core/auth/api/keys/status",
|
|
||||||
"/api/v2/core/auth/api/keys/revoke", "/api/v2/core/auth/api/keys/delete":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadAPIAuthConfig(_ *gin.Context) (APIAuthConfig, error) {
|
func LoadAPIAuthConfig(_ *gin.Context) (APIAuthConfig, error) {
|
||||||
settingRepo := repo.NewISettingRepo()
|
settingRepo := repo.NewISettingRepo()
|
||||||
config := APIAuthConfig{}
|
config := APIAuthConfig{}
|
||||||
|
|||||||
@@ -66,26 +66,6 @@ func RequireAPIKeySession(c *gin.Context) (psession.SessionUser, error) {
|
|||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func RequireAPIKeyManagement(c *gin.Context) (psession.SessionUser, error) {
|
|
||||||
if c == nil || !c.GetBool("API_AUTH") {
|
|
||||||
return RequireAPIKeySession(c)
|
|
||||||
}
|
|
||||||
if c.GetBool("LOCAL_REQUEST") {
|
|
||||||
return psession.SessionUser{}, buserr.New("ErrNotLogin")
|
|
||||||
}
|
|
||||||
kind, keyID := c.GetString("API_AUTH_KEY_KIND"), c.GetString("API_AUTH_KEY_ID")
|
|
||||||
if (kind != "apiKey" && kind != "legacy") || keyID == "" || (kind == "legacy") != (keyID == "legacy") {
|
|
||||||
return psession.SessionUser{}, buserr.New("ErrNotLogin")
|
|
||||||
}
|
|
||||||
ownerType, ownerID := c.GetString("API_AUTH_OWNER_TYPE"), c.GetString("API_AUTH_OWNER_ID")
|
|
||||||
value, _ := c.Get(psession.GinContextSessionUserKey)
|
|
||||||
u, ok := value.(psession.SessionUser)
|
|
||||||
if (ownerType != APIKeyOwnerPanelAdmin && ownerType != APIKeyOwnerEnterpriseUser) || ownerID == "" || !ok || u.ID != ownerID || u.Name == "" {
|
|
||||||
return psession.SessionUser{}, buserr.New("ErrNotLogin")
|
|
||||||
}
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func HasAPICredentials(c *gin.Context) bool {
|
func HasAPICredentials(c *gin.Context) bool {
|
||||||
return c.GetHeader("1Panel-Token") != "" || c.GetHeader("1Panel-Timestamp") != "" || c.GetHeader("1Panel-Key-ID") != "" || c.GetHeader("1Panel-Signature-Version") != ""
|
return c.GetHeader("1Panel-Token") != "" || c.GetHeader("1Panel-Timestamp") != "" || c.GetHeader("1Panel-Key-ID") != "" || c.GetHeader("1Panel-Signature-Version") != ""
|
||||||
}
|
}
|
||||||
@@ -107,7 +87,7 @@ func LegacyAPIKeyFingerprint(owner APIKeyOwner, config APIAuthConfig, policy mod
|
|||||||
}
|
}
|
||||||
|
|
||||||
func APIKeySecret(key model.APIKey) (string, error) {
|
func APIKeySecret(key model.APIKey) (string, error) {
|
||||||
if key.DeletedAt != nil || key.Status == "Revoked" || key.SecretVersion != 1 || key.SecretCiphertext == "" {
|
if key.SecretVersion != 1 || key.SecretCiphertext == "" {
|
||||||
return "", buserr.New("ErrApiConfigKeyInvalid")
|
return "", buserr.New("ErrApiConfigKeyInvalid")
|
||||||
}
|
}
|
||||||
master, err := encrypt.APIKeyEncryptionKey()
|
master, err := encrypt.APIKeyEncryptionKey()
|
||||||
@@ -118,7 +98,7 @@ func APIKeySecret(key model.APIKey) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ValidateAPIKeyState(key model.APIKey, now time.Time) error {
|
func ValidateAPIKeyState(key model.APIKey, now time.Time) error {
|
||||||
if key.DeletedAt != nil || key.Status != constant.StatusEnable {
|
if key.Status != constant.StatusEnable {
|
||||||
return buserr.New("ErrApiConfigStatusInvalid")
|
return buserr.New("ErrApiConfigStatusInvalid")
|
||||||
}
|
}
|
||||||
if key.ExpiresAt != nil && !now.Before(*key.ExpiresAt) {
|
if key.ExpiresAt != nil && !now.Before(*key.ExpiresAt) {
|
||||||
@@ -133,7 +113,7 @@ func configForAPIKey(key model.APIKey, secret string, owner APIKeyOwner) APIAuth
|
|||||||
|
|
||||||
func loadMultiAPIKeyConfig(c *gin.Context, legacy APIAuthConfigLoader, provider APIKeyOwnerProvider) (APIAuthConfig, error) {
|
func loadMultiAPIKeyConfig(c *gin.Context, legacy APIAuthConfigLoader, provider APIKeyOwnerProvider) (APIAuthConfig, error) {
|
||||||
keyID := c.GetHeader("1Panel-Key-ID")
|
keyID := c.GetHeader("1Panel-Key-ID")
|
||||||
q := global.DB.Model(&model.APIKey{}).Where("deleted_at IS NULL").Select("id", "secret_ciphertext", "secret_version", "revision")
|
q := global.DB.Model(&model.APIKey{}).Select("id", "secret_ciphertext", "secret_version", "revision")
|
||||||
if keyID != "" {
|
if keyID != "" {
|
||||||
q = q.Where("id = ?", keyID)
|
q = q.Where("id = ?", keyID)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+27
-35
@@ -6,42 +6,41 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type APIKeyItem struct {
|
type APIKeyItem struct {
|
||||||
ID string `json:"id" example:"8a8f0f77-1ec8-4d46-8f5a-0ea8807e2d8a"`
|
ID string `json:"id"`
|
||||||
Kind string `json:"kind" enums:"apiKey,legacy" example:"apiKey"`
|
Kind string `json:"kind"`
|
||||||
Name string `json:"name" example:"Deployment automation"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description" example:"Used by the deployment service"`
|
Description string `json:"description"`
|
||||||
KeyHint string `json:"keyHint" example:"abcd••••1234"`
|
KeyHint string `json:"keyHint"`
|
||||||
Status string `json:"status" enums:"Enable,Disable,Revoked,Expired" example:"Enable"`
|
Status string `json:"status"`
|
||||||
IPWhiteList string `json:"ipWhiteList" example:"192.0.2.10"`
|
IPWhiteList string `json:"ipWhiteList"`
|
||||||
APITrustedProxies string `json:"apiTrustedProxies" example:"192.0.2.1"`
|
APITrustedProxies string `json:"apiTrustedProxies"`
|
||||||
APIKeyValidityTime int `json:"apiKeyValidityTime" example:"120"`
|
APIKeyValidityTime int `json:"apiKeyValidityTime"`
|
||||||
ExpiresAt *time.Time `json:"expiresAt" format:"date-time" extensions:"x-nullable"`
|
ExpiresAt *time.Time `json:"expiresAt"`
|
||||||
AllowAppBinding bool `json:"allowAppBinding" example:"false"`
|
AllowAppBinding bool `json:"allowAppBinding"`
|
||||||
Revision uint64 `json:"revision" minimum:"1" example:"1"`
|
Revision uint64 `json:"revision"`
|
||||||
CreatedAt *time.Time `json:"createdAt" format:"date-time" extensions:"x-nullable"`
|
CreatedAt *time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIKeySearch struct {
|
type APIKeySearch struct {
|
||||||
Page int `json:"page" minimum:"1" example:"1"`
|
Page int `json:"page"`
|
||||||
PageSize int `json:"pageSize" minimum:"1" maximum:"100" example:"20"`
|
PageSize int `json:"pageSize"`
|
||||||
ExcludeRevoked bool `json:"excludeRevoked" default:"false"`
|
ExcludeRevoked bool `json:"excludeRevoked"`
|
||||||
}
|
}
|
||||||
type APIKeyPage struct {
|
type APIKeyPage struct {
|
||||||
Items []APIKeyItem `json:"items"`
|
Items []APIKeyItem `json:"items"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
}
|
}
|
||||||
type APIKeyFields struct {
|
type APIKeyFields struct {
|
||||||
Name string `json:"name" minLength:"1" maxLength:"64" example:"Deployment automation"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description" maxLength:"256" example:"Used by the deployment service"`
|
Description string `json:"description"`
|
||||||
IPWhiteList string `json:"ipWhiteList" maxLength:"4096" example:"192.0.2.10"`
|
IPWhiteList string `json:"ipWhiteList"`
|
||||||
APITrustedProxies string `json:"apiTrustedProxies" maxLength:"4096" example:"192.0.2.1"`
|
APITrustedProxies string `json:"apiTrustedProxies"`
|
||||||
APIKeyValidityTime int `json:"apiKeyValidityTime" minimum:"0" maximum:"1440" example:"120"`
|
APIKeyValidityTime int `json:"apiKeyValidityTime"`
|
||||||
ExpiresAt json.RawMessage `json:"expiresAt" swaggertype:"string" format:"date-time" extensions:"x-nullable" example:"2030-01-01T00:00:00Z"`
|
ExpiresAt json.RawMessage `json:"expiresAt"`
|
||||||
AllowAppBinding bool `json:"allowAppBinding" default:"false"`
|
AllowAppBinding bool `json:"allowAppBinding"`
|
||||||
}
|
}
|
||||||
type APIKeyCreate struct {
|
type APIKeyCreate struct {
|
||||||
// Optional client-generated UUID for idempotent retries. Omitted or empty values are generated by the server.
|
RequestID string `json:"requestID"`
|
||||||
RequestID string `json:"requestID" format:"uuid" example:"df1a75a3-5f56-4fa3-9e7a-a573a853d894"`
|
|
||||||
APIKeyFields
|
APIKeyFields
|
||||||
}
|
}
|
||||||
type APIKeyCreated struct {
|
type APIKeyCreated struct {
|
||||||
@@ -50,15 +49,8 @@ type APIKeyCreated struct {
|
|||||||
AlreadyCreated bool `json:"alreadyCreated,omitempty"`
|
AlreadyCreated bool `json:"alreadyCreated,omitempty"`
|
||||||
}
|
}
|
||||||
type APIKeyMutation struct {
|
type APIKeyMutation struct {
|
||||||
ID string `json:"id" example:"8a8f0f77-1ec8-4d46-8f5a-0ea8807e2d8a"`
|
ID string `json:"id"`
|
||||||
Revision uint64 `json:"revision" minimum:"1" example:"1"`
|
Revision uint64 `json:"revision"`
|
||||||
}
|
|
||||||
type APIKeyMutationResult struct {
|
|
||||||
TerminalClosePending bool `json:"terminalClosePending"`
|
|
||||||
}
|
|
||||||
type APIKeyRevealed struct {
|
|
||||||
APIKeyMutation
|
|
||||||
APIKey string `json:"apiKey"`
|
|
||||||
}
|
}
|
||||||
type APIKeyUpdate struct {
|
type APIKeyUpdate struct {
|
||||||
APIKeyMutation
|
APIKeyMutation
|
||||||
@@ -66,5 +58,5 @@ type APIKeyUpdate struct {
|
|||||||
}
|
}
|
||||||
type APIKeyStatus struct {
|
type APIKeyStatus struct {
|
||||||
APIKeyMutation
|
APIKeyMutation
|
||||||
Status string `json:"status" enums:"Enable,Disable" example:"Disable"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ type APIKey struct {
|
|||||||
CreatedAt time.Time `json:"-"`
|
CreatedAt time.Time `json:"-"`
|
||||||
UpdatedAt time.Time `json:"-"`
|
UpdatedAt time.Time `json:"-"`
|
||||||
RevokedAt *time.Time `json:"-"`
|
RevokedAt *time.Time `json:"-"`
|
||||||
DeletedAt *time.Time `json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type LegacyAPIKeyPolicy struct {
|
type LegacyAPIKeyPolicy struct {
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ func APIKeyOwnerQuery(db *gorm.DB, ownerType, ownerID string) *gorm.DB {
|
|||||||
}
|
}
|
||||||
func (r *APIKeyRepo) Get(id string) (model.APIKey, error) {
|
func (r *APIKeyRepo) Get(id string) (model.APIKey, error) {
|
||||||
var key model.APIKey
|
var key model.APIKey
|
||||||
err := global.DB.Where("id = ? AND deleted_at IS NULL", id).First(&key).Error
|
err := global.DB.Where("id = ?", id).First(&key).Error
|
||||||
return key, err
|
return key, err
|
||||||
}
|
}
|
||||||
func (r *APIKeyRepo) List(ownerType, ownerID string, excludeRevoked bool) ([]model.APIKey, error) {
|
func (r *APIKeyRepo) List(ownerType, ownerID string, excludeRevoked bool) ([]model.APIKey, error) {
|
||||||
items := make([]model.APIKey, 0)
|
items := make([]model.APIKey, 0)
|
||||||
q := APIKeyOwnerQuery(global.DB, ownerType, ownerID).Where("deleted_at IS NULL")
|
q := APIKeyOwnerQuery(global.DB, ownerType, ownerID)
|
||||||
if excludeRevoked {
|
if excludeRevoked {
|
||||||
q = q.Where("status <> ?", "Revoked")
|
q = q.Where("status <> ?", "Revoked")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,27 +52,6 @@ func (s *APIKeyService) owner(c *gin.Context) (auth.APIKeyOwner, error) {
|
|||||||
return s.Provider.CurrentAPIKeyOwner(c)
|
return s.Provider.CurrentAPIKeyOwner(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIKeyService) managementOwner(c *gin.Context) (auth.APIKeyOwner, error) {
|
|
||||||
if _, err := auth.RequireAPIKeyManagement(c); err != nil {
|
|
||||||
return auth.APIKeyOwner{}, err
|
|
||||||
}
|
|
||||||
if s.Provider == nil {
|
|
||||||
return auth.APIKeyOwner{}, buserr.New("ErrApiConfigStatusInvalid")
|
|
||||||
}
|
|
||||||
if !c.GetBool("API_AUTH") {
|
|
||||||
return s.Provider.CurrentAPIKeyOwner(c)
|
|
||||||
}
|
|
||||||
ownerType, ownerID := c.GetString("API_AUTH_OWNER_TYPE"), c.GetString("API_AUTH_OWNER_ID")
|
|
||||||
owner, err := s.Provider.ResolveAPIKeyOwner(c, ownerType, ownerID)
|
|
||||||
if err != nil {
|
|
||||||
return auth.APIKeyOwner{}, err
|
|
||||||
}
|
|
||||||
if owner.Type != ownerType || owner.ID != ownerID {
|
|
||||||
return auth.APIKeyOwner{}, buserr.New("ErrNotLogin")
|
|
||||||
}
|
|
||||||
return owner, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func apiKeyItem(key model.APIKey) dto.APIKeyItem {
|
func apiKeyItem(key model.APIKey) dto.APIKeyItem {
|
||||||
status := key.Status
|
status := key.Status
|
||||||
if status != "Revoked" && key.ExpiresAt != nil && !time.Now().Before(*key.ExpiresAt) {
|
if status != "Revoked" && key.ExpiresAt != nil && !time.Now().Before(*key.ExpiresAt) {
|
||||||
@@ -96,7 +75,7 @@ func (s *APIKeyService) legacy(owner auth.APIKeyOwner) (dto.APIKeyItem, auth.API
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIKeyService) Search(c *gin.Context, req dto.APIKeySearch) (*dto.APIKeyPage, error) {
|
func (s *APIKeyService) Search(c *gin.Context, req dto.APIKeySearch) (*dto.APIKeyPage, error) {
|
||||||
owner, err := s.managementOwner(c)
|
owner, err := s.owner(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -167,13 +146,11 @@ func keyHint(secret string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKeyCreated, error) {
|
func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKeyCreated, error) {
|
||||||
owner, err := s.managementOwner(c)
|
owner, err := s.owner(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if req.RequestID == "" {
|
if _, err = uuid.Parse(req.RequestID); err != nil {
|
||||||
req.RequestID = uuid.NewString()
|
|
||||||
} else if _, err = uuid.Parse(req.RequestID); err != nil {
|
|
||||||
return nil, buserr.New("ErrInvalidParams")
|
return nil, buserr.New("ErrInvalidParams")
|
||||||
}
|
}
|
||||||
expires, err := validateAPIKeyFields(&req.APIKeyFields, true)
|
expires, err := validateAPIKeyFields(&req.APIKeyFields, true)
|
||||||
@@ -195,9 +172,6 @@ func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKe
|
|||||||
q := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID)
|
q := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID)
|
||||||
err := q.Where("request_id = ?", req.RequestID).First(&key).Error
|
err := q.Where("request_id = ?", req.RequestID).First(&key).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if key.DeletedAt != nil {
|
|
||||||
return buserr.New("ErrAPIKeyNotFound")
|
|
||||||
}
|
|
||||||
if key.RequestHash != requestHash {
|
if key.RequestHash != requestHash {
|
||||||
return buserr.New("ErrAPIKeyConflict")
|
return buserr.New("ErrAPIKeyConflict")
|
||||||
}
|
}
|
||||||
@@ -245,7 +219,7 @@ func ensureAPIKeyName(tx *gorm.DB, owner auth.APIKeyOwner, name, id string) erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error {
|
func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error {
|
||||||
owner, err := s.managementOwner(c)
|
owner, err := s.owner(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -273,7 +247,7 @@ func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error {
|
func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error {
|
||||||
owner, err := s.managementOwner(c)
|
owner, err := s.owner(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -297,7 +271,7 @@ func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error {
|
func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error {
|
||||||
owner, err := s.managementOwner(c)
|
owner, err := s.owner(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -315,75 +289,21 @@ func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIKeyService) Delete(c *gin.Context, req dto.APIKeyMutation) error {
|
|
||||||
owner, err := s.managementOwner(c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if req.ID == "legacy" {
|
|
||||||
return buserr.New("ErrInvalidParams")
|
|
||||||
}
|
|
||||||
return s.mutate(c, owner, req, "delete", func(_ *gorm.DB, key *model.APIKey) error {
|
|
||||||
now := time.Now()
|
|
||||||
key.Status = "Revoked"
|
|
||||||
if key.RevokedAt == nil {
|
|
||||||
key.RevokedAt = &now
|
|
||||||
}
|
|
||||||
key.DeletedAt = &now
|
|
||||||
key.ActiveName = nil
|
|
||||||
key.SecretCiphertext = ""
|
|
||||||
key.KeyHint = ""
|
|
||||||
key.AllowAppBinding = false
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIKeyService) Reveal(c *gin.Context, req dto.APIKeyMutation) (*dto.APIKeyRevealed, error) {
|
|
||||||
owner, err := s.owner(c)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if req.ID == "" || req.ID == "legacy" || req.Revision == 0 {
|
|
||||||
return nil, buserr.New("ErrInvalidParams")
|
|
||||||
}
|
|
||||||
// Serialize the snapshot and disclosure audit with revocation/deletion.
|
|
||||||
apiKeyMutationMu.Lock()
|
|
||||||
defer apiKeyMutationMu.Unlock()
|
|
||||||
var key model.APIKey
|
|
||||||
if err = repo.APIKeyOwnerQuery(global.DB, owner.Type, owner.ID).Where("id = ? AND deleted_at IS NULL", req.ID).First(&key).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, buserr.New("ErrAPIKeyNotFound")
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if key.Revision != req.Revision || key.Status == "Revoked" {
|
|
||||||
return nil, buserr.New("ErrAPIKeyConflict")
|
|
||||||
}
|
|
||||||
secret, err := auth.APIKeySecret(key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, buserr.New("ErrApiConfigKeyInvalid")
|
|
||||||
}
|
|
||||||
if err = apikey_audit.Record(c, apikey_audit.Event{OwnerType: owner.Type, OwnerID: owner.ID, KeyID: key.ID, KeyName: key.Name, Action: "reveal", Status: constant.StatusSuccess}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &dto.APIKeyRevealed{APIKeyMutation: req, APIKey: secret}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIKeyService) mutate(c *gin.Context, owner auth.APIKeyOwner, req dto.APIKeyMutation, action string, change func(*gorm.DB, *model.APIKey) error) error {
|
func (s *APIKeyService) mutate(c *gin.Context, owner auth.APIKeyOwner, req dto.APIKeyMutation, action string, change func(*gorm.DB, *model.APIKey) error) error {
|
||||||
if req.ID == "" || req.Revision == 0 {
|
if req.ID == "" || req.Revision == 0 {
|
||||||
return buserr.New("ErrInvalidParams")
|
return buserr.New("ErrInvalidParams")
|
||||||
}
|
}
|
||||||
apiKeyMutationMu.Lock()
|
apiKeyMutationMu.Lock()
|
||||||
var key model.APIKey
|
var key model.APIKey
|
||||||
closeTerminals := action == "disable" || action == "revoke" || action == "delete"
|
closeTerminals := action == "disable" || action == "revoke"
|
||||||
err := global.DB.Transaction(func(tx *gorm.DB) error {
|
err := global.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID).Where("id = ? AND deleted_at IS NULL", req.ID).First(&key).Error; err != nil {
|
if err := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID).Where("id = ?", req.ID).First(&key).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return buserr.New("ErrAPIKeyNotFound")
|
return buserr.New("ErrAPIKeyNotFound")
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if key.Revision != req.Revision || (key.Status == "Revoked" && action != "delete") {
|
if key.Revision != req.Revision || key.Status == "Revoked" {
|
||||||
return buserr.New("ErrAPIKeyConflict")
|
return buserr.New("ErrAPIKeyConflict")
|
||||||
}
|
}
|
||||||
before := key
|
before := key
|
||||||
|
|||||||
+262
-2138
File diff suppressed because it is too large
Load Diff
@@ -18,10 +18,7 @@ import (
|
|||||||
// @schemes http https
|
// @schemes http https
|
||||||
|
|
||||||
// @securityDefinitions.apikey ApiKeyAuth
|
// @securityDefinitions.apikey ApiKeyAuth
|
||||||
// @description Signed API Key authentication. Send the signature, never the plaintext API Key, in 1Panel-Token.
|
// @description Custom Token Format, Format: md5('1panel' + API-Key + UnixTimestamp).
|
||||||
// @description Supported formats: hex(HMAC-SHA256(API-Key, '1panel:' + UnixTimestamp)) or md5('1panel' + API-Key + UnixTimestamp).
|
|
||||||
// @description Send the matching Unix timestamp in 1Panel-Timestamp. Optionally set 1Panel-Signature-Version to hmac-sha256, v1 or md5; omission accepts either algorithm.
|
|
||||||
// @description Independent keys may include their ID in 1Panel-Key-ID to select the credential; omit that header for a legacy key.
|
|
||||||
// @description ```
|
// @description ```
|
||||||
// @description eg:
|
// @description eg:
|
||||||
// @description curl -X GET "http://{host}:{port}/api/v2/toolbox/device/base" \
|
// @description curl -X GET "http://{host}:{port}/api/v2/toolbox/device/base" \
|
||||||
@@ -30,7 +27,7 @@ import (
|
|||||||
// @description ```
|
// @description ```
|
||||||
// @description - `1Panel-Token` is the key for the panel API Key.
|
// @description - `1Panel-Token` is the key for the panel API Key.
|
||||||
// @type apiKey
|
// @type apiKey
|
||||||
// @in header
|
// @in Header
|
||||||
// @name 1Panel-Token
|
// @name 1Panel-Token
|
||||||
// @securityDefinitions.apikey Timestamp
|
// @securityDefinitions.apikey Timestamp
|
||||||
// @type apiKey
|
// @type apiKey
|
||||||
|
|||||||
@@ -72,6 +72,5 @@ func coreMigrations() []*gormigrate.Migration {
|
|||||||
migrations.UpdateAiMenuOrder,
|
migrations.UpdateAiMenuOrder,
|
||||||
migrations.UpdateAiMenuOrderV2,
|
migrations.UpdateAiMenuOrderV2,
|
||||||
migrations.AddPanelMCP,
|
migrations.AddPanelMCP,
|
||||||
migrations.AddAPIKeyDeletedAt,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,3 @@ var AddOperationLogAPIKey = &gormigrate.Migration{
|
|||||||
ID: "20260915-add-operation-log-api-key",
|
ID: "20260915-add-operation-log-api-key",
|
||||||
Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.OperationLog{}) },
|
Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.OperationLog{}) },
|
||||||
}
|
}
|
||||||
|
|
||||||
var AddAPIKeyDeletedAt = &gormigrate.Migration{
|
|
||||||
ID: "20261028-api-key-deleted-at",
|
|
||||||
Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.APIKey{}) },
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -42,10 +42,6 @@ func OperationLog() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch c.Request.URL.Path {
|
switch c.Request.URL.Path {
|
||||||
case "/api/v2/core/auth/api/keys/reveal":
|
|
||||||
// Disclosure has its own audit and must not enter response-body capture.
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
case "/api/v2/core/mcp/credentials/create", "/api/v2/core/mcp/credentials/reveal", "/api/v2/core/mcp/credentials/rotate", "/api/v2/core/mcp/credentials/export":
|
case "/api/v2/core/mcp/credentials/create", "/api/v2/core/mcp/credentials/reveal", "/api/v2/core/mcp/credentials/rotate", "/api/v2/core/mcp/credentials/export":
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -40,8 +40,6 @@ func (s *BaseRouter) InitRouter(Router *gin.RouterGroup) {
|
|||||||
authRouter.POST("/api/keys/update", baseApi.UpdateAPIKey)
|
authRouter.POST("/api/keys/update", baseApi.UpdateAPIKey)
|
||||||
authRouter.POST("/api/keys/status", baseApi.SetAPIKeyStatus)
|
authRouter.POST("/api/keys/status", baseApi.SetAPIKeyStatus)
|
||||||
authRouter.POST("/api/keys/revoke", baseApi.RevokeAPIKey)
|
authRouter.POST("/api/keys/revoke", baseApi.RevokeAPIKey)
|
||||||
authRouter.POST("/api/keys/delete", baseApi.DeleteAPIKey)
|
|
||||||
authRouter.POST("/api/keys/reveal", baseApi.RevealAPIKey)
|
|
||||||
|
|
||||||
authRouter.GET("/current", baseApi.GetCurrentUser)
|
authRouter.GET("/current", baseApi.GetCurrentUser)
|
||||||
authRouter.POST("/current/update", baseApi.UpdateCurrentUser)
|
authRouter.POST("/current/update", baseApi.UpdateCurrentUser)
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ func Build(c *gin.Context, event Event) (*model.OperationLog, error) {
|
|||||||
"enable": {"启用 API Key", "Enable API key"},
|
"enable": {"启用 API Key", "Enable API key"},
|
||||||
"disable": {"停用 API Key", "Disable API key"},
|
"disable": {"停用 API Key", "Disable API key"},
|
||||||
"revoke": {"撤销 API Key", "Revoke API key"},
|
"revoke": {"撤销 API Key", "Revoke API key"},
|
||||||
"delete": {"删除 API Key", "Delete API key"},
|
|
||||||
"reveal": {"查看 API Key", "Reveal API key"},
|
|
||||||
"app_binding": {"向 APP 交付 API Key", "Deliver API key to APP"},
|
"app_binding": {"向 APP 交付 API Key", "Deliver API key to APP"},
|
||||||
}
|
}
|
||||||
label, ok := labels[event.Action]
|
label, ok := labels[event.Action]
|
||||||
@@ -82,17 +80,6 @@ func Build(c *gin.Context, event Event) (*model.OperationLog, error) {
|
|||||||
record.UserAgent = c.Request.UserAgent()
|
record.UserAgent = c.Request.UserAgent()
|
||||||
record.IP = c.ClientIP()
|
record.IP = c.ClientIP()
|
||||||
}
|
}
|
||||||
if c.GetBool("API_AUTH") && event.Action != "app_binding" {
|
|
||||||
record.AuthMethod = "api_key"
|
|
||||||
record.APIKeyID = c.GetString("API_AUTH_KEY_ID")
|
|
||||||
record.APIKeyName = c.GetString("API_AUTH_KEY_NAME")
|
|
||||||
if c.GetString("API_AUTH_KEY_KIND") == "legacy" {
|
|
||||||
record.APIKeyID = "legacy:" + c.GetString("API_AUTH_OWNER_TYPE") + ":" + c.GetString("API_AUTH_OWNER_ID")
|
|
||||||
}
|
|
||||||
if clientIP := c.GetString("API_AUTH_CLIENT_IP"); clientIP != "" {
|
|
||||||
record.IP = clientIP
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if event.ClientIP != "" {
|
if event.ClientIP != "" {
|
||||||
record.IP = event.ClientIP
|
record.IP = event.ClientIP
|
||||||
|
|||||||
@@ -30,7 +30,4 @@ export namespace APIKey {
|
|||||||
id: string;
|
id: string;
|
||||||
revision: number;
|
revision: number;
|
||||||
}
|
}
|
||||||
export interface Revealed extends Reference {
|
|
||||||
apiKey: string;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,9 @@ export namespace Container {
|
|||||||
mode: string;
|
mode: string;
|
||||||
}
|
}
|
||||||
export interface ContainerInfo {
|
export interface ContainerInfo {
|
||||||
|
latestVersion?: string;
|
||||||
|
targetImage?: string;
|
||||||
|
hasUpdate?: boolean;
|
||||||
containerID: string;
|
containerID: string;
|
||||||
name: string;
|
name: string;
|
||||||
imageName: string;
|
imageName: string;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const prefix = '/core/auth/api/keys';
|
|||||||
export const searchAPIKeys = (params: { page: number; pageSize: number; excludeRevoked?: boolean }) => {
|
export const searchAPIKeys = (params: { page: number; pageSize: number; excludeRevoked?: boolean }) => {
|
||||||
return http.post<APIKey.Search>(`${prefix}/search`, params);
|
return http.post<APIKey.Search>(`${prefix}/search`, params);
|
||||||
};
|
};
|
||||||
export const createAPIKey = (params: APIKey.Editable & { requestID?: string }) => {
|
export const createAPIKey = (params: APIKey.Editable & { requestID: string }) => {
|
||||||
return http.post<APIKey.Created>(`${prefix}/create`, params);
|
return http.post<APIKey.Created>(`${prefix}/create`, params);
|
||||||
};
|
};
|
||||||
export const updateAPIKey = (params: APIKey.Editable & APIKey.Reference) => {
|
export const updateAPIKey = (params: APIKey.Editable & APIKey.Reference) => {
|
||||||
@@ -18,9 +18,3 @@ export const setAPIKeyStatus = (params: APIKey.Reference & { status: 'Enable' |
|
|||||||
export const revokeAPIKey = (params: APIKey.Reference) => {
|
export const revokeAPIKey = (params: APIKey.Reference) => {
|
||||||
return http.post<{ terminalClosePending: boolean }>(`${prefix}/revoke`, params);
|
return http.post<{ terminalClosePending: boolean }>(`${prefix}/revoke`, params);
|
||||||
};
|
};
|
||||||
export const deleteAPIKey = (params: APIKey.Reference) => {
|
|
||||||
return http.post<{ terminalClosePending: boolean }>(`${prefix}/delete`, params);
|
|
||||||
};
|
|
||||||
export const revealAPIKey = (params: APIKey.Reference) => {
|
|
||||||
return http.post<APIKey.Revealed>(`${prefix}/reveal`, params);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -290,3 +290,43 @@ export const updateIpv6Option = (fixedCidrV6: string, ip6Tables: boolean, experi
|
|||||||
export const updateDaemonJsonByfile = (params: Container.DaemonJsonUpdateByFile) => {
|
export const updateDaemonJsonByfile = (params: Container.DaemonJsonUpdateByFile) => {
|
||||||
return http.post(`/containers/daemonjson/update/byfile`, params, TimeoutEnum.T_3M);
|
return http.post(`/containers/daemonjson/update/byfile`, params, TimeoutEnum.T_3M);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface ImageRemoteCheckStatus {
|
||||||
|
proxyRequired: boolean;
|
||||||
|
}
|
||||||
|
export const getImageRemoteCheckStatus = (node: string, signal?: AbortSignal) =>
|
||||||
|
http.get<ImageRemoteCheckStatus>(
|
||||||
|
'/containers/image/remote/check',
|
||||||
|
{},
|
||||||
|
{ headers: { CurrentNode: node }, signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ContainerImageCheckUpdateStatus {
|
||||||
|
taskID: string;
|
||||||
|
enabled: boolean;
|
||||||
|
running: boolean;
|
||||||
|
checked: number;
|
||||||
|
updates: number;
|
||||||
|
failed: number;
|
||||||
|
skipped: number;
|
||||||
|
error: string;
|
||||||
|
finishedAt: number;
|
||||||
|
}
|
||||||
|
export const getContainerImageCheckUpdateStatus = (node: string, signal?: AbortSignal) =>
|
||||||
|
http.get<ContainerImageCheckUpdateStatus>(
|
||||||
|
'/containers/image-updates',
|
||||||
|
{},
|
||||||
|
{ headers: { CurrentNode: node }, signal },
|
||||||
|
);
|
||||||
|
export const runContainerImageCheckUpdate = (node: string) =>
|
||||||
|
http.postWithConfig<ContainerImageCheckUpdateStatus>(
|
||||||
|
'/containers/image-updates/check',
|
||||||
|
{},
|
||||||
|
{ headers: { CurrentNode: node } },
|
||||||
|
);
|
||||||
|
export const setContainerImageCheckUpdateSchedule = (enabled: boolean, node: string) =>
|
||||||
|
http.postWithConfig<ContainerImageCheckUpdateStatus>(
|
||||||
|
'/containers/image-updates/schedule',
|
||||||
|
{ enabled },
|
||||||
|
{ headers: { CurrentNode: node } },
|
||||||
|
);
|
||||||
|
|||||||
@@ -6,14 +6,27 @@
|
|||||||
:confirm-before-close="true"
|
:confirm-before-close="true"
|
||||||
@before-close="beforeClose"
|
@before-close="beforeClose"
|
||||||
>
|
>
|
||||||
<el-form
|
<template v-if="created">
|
||||||
ref="formRef"
|
<el-alert
|
||||||
:model="form"
|
:title="$t(secret ? 'apiKeyManagement.saveSecret' : 'apiKeyManagement.alreadyCreated')"
|
||||||
:rules="rules"
|
:type="secret ? 'warning' : 'info'"
|
||||||
label-position="top"
|
:closable="false"
|
||||||
class="text-[var(--el-text-color-regular)]"
|
show-icon
|
||||||
@submit.prevent="submit"
|
class="mb-4"
|
||||||
>
|
/>
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item :label="$t('commons.table.name')">{{ created.name }}</el-form-item>
|
||||||
|
<el-form-item v-if="secret" :label="$t('setting.apiKey')">
|
||||||
|
<el-input :model-value="secret" readonly autocomplete="off">
|
||||||
|
<template #append>
|
||||||
|
<CopyButton :content="secret" :is-icon="false" />
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<el-checkbox v-if="secret" v-model="saved">{{ $t('apiKeyManagement.saved') }}</el-checkbox>
|
||||||
|
</template>
|
||||||
|
<el-form v-else ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent="submit">
|
||||||
<el-alert v-if="!item" type="warning" :closable="false" class="!mb-4">
|
<el-alert v-if="!item" type="warning" :closable="false" class="!mb-4">
|
||||||
<ul class="m-0 list-disc space-y-1 pl-4 break-words leading-6">
|
<ul class="m-0 list-disc space-y-1 pl-4 break-words leading-6">
|
||||||
<li>
|
<li>
|
||||||
@@ -126,10 +139,13 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button :disabled="busy" @click="beforeClose(() => (visible = false))">
|
<el-button v-if="!created" :disabled="busy" @click="beforeClose(() => (visible = false))">
|
||||||
{{ $t('commons.button.cancel') }}
|
{{ $t('commons.button.cancel') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button type="primary" :loading="busy" @click="submit">
|
<el-button v-if="created" type="primary" :disabled="!!secret && !saved" @click="finish">
|
||||||
|
{{ $t('commons.button.confirm') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button v-else type="primary" :loading="busy" @click="submit">
|
||||||
{{ $t(item ? 'commons.button.save' : 'apiKeyManagement.create') }}
|
{{ $t(item ? 'commons.button.save' : 'apiKeyManagement.create') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -138,7 +154,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue';
|
||||||
import type { FormInstance } from 'element-plus';
|
import { ElMessageBox, type FormInstance } from 'element-plus';
|
||||||
import { isAxiosError } from 'axios';
|
import { isAxiosError } from 'axios';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { onBeforeRouteLeave } from 'vue-router';
|
import { onBeforeRouteLeave } from 'vue-router';
|
||||||
@@ -158,6 +174,9 @@ const visible = ref(false);
|
|||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const uncertain = ref(false);
|
const uncertain = ref(false);
|
||||||
const item = ref<APIKey.Item>();
|
const item = ref<APIKey.Item>();
|
||||||
|
const created = ref<APIKey.Item>();
|
||||||
|
const secret = ref('');
|
||||||
|
const saved = ref(false);
|
||||||
const formRef = ref<FormInstance>();
|
const formRef = ref<FormInstance>();
|
||||||
const ipMode = ref('restricted');
|
const ipMode = ref('restricted');
|
||||||
const neverExpires = ref(false);
|
const neverExpires = ref(false);
|
||||||
@@ -177,6 +196,8 @@ let editorVersion = 0;
|
|||||||
|
|
||||||
const discardEditor = () => {
|
const discardEditor = () => {
|
||||||
editorVersion++;
|
editorVersion++;
|
||||||
|
secret.value = '';
|
||||||
|
created.value = undefined;
|
||||||
visible.value = false;
|
visible.value = false;
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
};
|
};
|
||||||
@@ -219,6 +240,9 @@ const rules = {
|
|||||||
const open = (existing?: APIKey.Item, fromApp = false) => {
|
const open = (existing?: APIKey.Item, fromApp = false) => {
|
||||||
editorVersion++;
|
editorVersion++;
|
||||||
item.value = existing;
|
item.value = existing;
|
||||||
|
created.value = undefined;
|
||||||
|
secret.value = '';
|
||||||
|
saved.value = false;
|
||||||
uncertain.value = false;
|
uncertain.value = false;
|
||||||
pendingCreate = undefined;
|
pendingCreate = undefined;
|
||||||
requestID = uuidv4();
|
requestID = uuidv4();
|
||||||
@@ -245,9 +269,19 @@ const open = (existing?: APIKey.Item, fromApp = false) => {
|
|||||||
nextTick(() => formRef.value?.clearValidate());
|
nextTick(() => formRef.value?.clearValidate());
|
||||||
};
|
};
|
||||||
|
|
||||||
const beforeClose = (done: () => void) => {
|
const finish = () => {
|
||||||
|
secret.value = '';
|
||||||
|
visible.value = false;
|
||||||
|
if (created.value) emit('created', created.value);
|
||||||
|
};
|
||||||
|
const beforeClose = async (done: () => void) => {
|
||||||
if (busy.value) return;
|
if (busy.value) return;
|
||||||
if (uncertain.value) emit('changed');
|
if (secret.value && !saved.value) {
|
||||||
|
await ElMessageBox.alert(i18n.global.t('apiKeyManagement.saveSecret'), i18n.global.t('apiKeyManagement.title'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (created.value) finish();
|
||||||
|
else if (uncertain.value) emit('changed');
|
||||||
done();
|
done();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -276,13 +310,9 @@ const submit = async () => {
|
|||||||
pendingCreate ??= { ...payload, requestID };
|
pendingCreate ??= { ...payload, requestID };
|
||||||
const response = await createAPIKey(pendingCreate);
|
const response = await createAPIKey(pendingCreate);
|
||||||
if (ticket !== editorVersion) return;
|
if (ticket !== editorVersion) return;
|
||||||
|
created.value = response.data.item;
|
||||||
|
secret.value = response.data.apiKey;
|
||||||
uncertain.value = false;
|
uncertain.value = false;
|
||||||
pendingCreate = undefined;
|
|
||||||
MsgSuccess(i18n.global.t('commons.msg.createSuccess'));
|
|
||||||
visible.value = false;
|
|
||||||
emit('changed');
|
|
||||||
emit('created', response.data.item);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
emit('changed');
|
emit('changed');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -311,12 +341,16 @@ watch(
|
|||||||
{ flush: 'sync' },
|
{ flush: 'sync' },
|
||||||
);
|
);
|
||||||
onBeforeUnmount(discardEditor);
|
onBeforeUnmount(discardEditor);
|
||||||
onBeforeRouteLeave((to) => {
|
onBeforeRouteLeave(async (to) => {
|
||||||
if (!globalStore.isLogin || ['entrance', 'Expired', 'EnterpriseLicenseRequired'].includes(String(to.name))) {
|
if (!globalStore.isLogin || ['entrance', 'Expired', 'EnterpriseLicenseRequired'].includes(String(to.name))) {
|
||||||
discardEditor();
|
discardEditor();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (busy.value) return false;
|
if (busy.value) return false;
|
||||||
|
if (secret.value && !saved.value) {
|
||||||
|
await ElMessageBox.alert(i18n.global.t('apiKeyManagement.saveSecret'), i18n.global.t('apiKeyManagement.title'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
discardEditor();
|
discardEditor();
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,14 +13,16 @@
|
|||||||
@click.stop="handleButtonClick(button)"
|
@click.stop="handleButtonClick(button)"
|
||||||
>
|
>
|
||||||
<el-icon v-if="button.icon" class="mr-1"><component :is="button.icon" /></el-icon>
|
<el-icon v-if="button.icon" class="mr-1"><component :is="button.icon" /></el-icon>
|
||||||
|
<el-badge is-dot :hidden="!hasDot(button)">
|
||||||
<span>{{ button.label }}</span>
|
<span>{{ button.label }}</span>
|
||||||
|
</el-badge>
|
||||||
</el-button>
|
</el-button>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="actions.more.length" class="fu-table-operations__action">
|
<span v-if="actions.more.length" class="fu-table-operations__action">
|
||||||
<el-dropdown class="fu-table-more-button" :trigger="trigger" @command="handleButtonClick">
|
<el-dropdown class="fu-table-more-button" :trigger="trigger" @command="handleButtonClick">
|
||||||
<span class="fu-table-operations__dropdown-trigger">
|
<span class="fu-table-operations__dropdown-trigger">
|
||||||
<el-button class="fu-table-operations__button" link type="primary">
|
<el-button class="fu-table-operations__button" link type="primary">
|
||||||
{{ t('fu.table.more') }}
|
<el-badge is-dot :hidden="!actions.more.some(hasDot)">{{ t('fu.table.more') }}</el-badge>
|
||||||
</el-button>
|
</el-button>
|
||||||
</span>
|
</span>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
@@ -33,7 +35,9 @@
|
|||||||
:divided="button.divided"
|
:divided="button.divided"
|
||||||
>
|
>
|
||||||
<el-icon v-if="button.icon" class="mr-1"><component :is="button.icon" /></el-icon>
|
<el-icon v-if="button.icon" class="mr-1"><component :is="button.icon" /></el-icon>
|
||||||
|
<el-badge is-dot :hidden="!hasDot(button)">
|
||||||
<span>{{ button.label }}</span>
|
<span>{{ button.label }}</span>
|
||||||
|
</el-badge>
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
@@ -75,6 +79,9 @@ const actions = computed(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasDot = (button: FuTableOperationButton) =>
|
||||||
|
typeof button.dot === 'function' ? button.dot(props.row) : Boolean(button.dot);
|
||||||
|
|
||||||
const handleButtonClick = (button: FuTableOperationButton) => {
|
const handleButtonClick = (button: FuTableOperationButton) => {
|
||||||
if (!isOperationDisabled(button, props.row)) {
|
if (!isOperationDisabled(button, props.row)) {
|
||||||
button.click?.(props.row);
|
button.click?.(props.row);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface FuTableOperationButton<Row = any> {
|
|||||||
type?: string;
|
type?: string;
|
||||||
icon?: any;
|
icon?: any;
|
||||||
divided?: boolean;
|
divided?: boolean;
|
||||||
|
dot?: boolean | ((row: Row) => boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FU_TABLE_STORAGE_PREFIX = 'FU-T-';
|
export const FU_TABLE_STORAGE_PREFIX = 'FU-T-';
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
reveal: 'Show secret',
|
|
||||||
hideSecret: 'Hide secret',
|
|
||||||
revealHelp: 'Keep this secret secure. Disabled or expired keys remain unusable even when displayed.',
|
|
||||||
deleteConfirm:
|
|
||||||
'Delete “{0}”? Its secret will be destroyed and the key removed from the list. New requests will be rejected and terminal connections closed. Audit records are retained; running jobs are not cancelled. This cannot be undone.',
|
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: 'Manage API Keys',
|
manage: 'Manage API Keys',
|
||||||
personalEntry: 'Manage your keys, access conditions, and APP binding in Settings.',
|
personalEntry: 'Manage your keys, access conditions, and APP binding in Settings.',
|
||||||
@@ -28,6 +23,10 @@ const message = {
|
|||||||
allowAppBinding: 'Allow APP QR binding',
|
allowAppBinding: 'Allow APP QR binding',
|
||||||
bindingDisableHelp:
|
bindingDisableHelp:
|
||||||
'Disabling QR binding prevents new device bindings. Devices already bound can continue to use the key until it is disabled, revoked, or expires.',
|
'Disabling QR binding prevents new device bindings. Devices already bound can continue to use the key until it is disabled, revoked, or expires.',
|
||||||
|
saveSecret: 'Copy and save this secret now. After closing, ordinary key management cannot display it again.',
|
||||||
|
saved: 'I have saved the secret',
|
||||||
|
alreadyCreated:
|
||||||
|
'This key was already created. Its secret will not be shown again. If the original response was lost, revoke and recreate it, or use QR binding if allowed.',
|
||||||
uncertain:
|
uncertain:
|
||||||
'The request result is uncertain. Retry with the same details to avoid duplicate keys, or close and check the list before creating another.',
|
'The request result is uncertain. Retry with the same details to avoid duplicate keys, or close and check the list before creating another.',
|
||||||
legacyHelp:
|
legacyHelp:
|
||||||
@@ -2117,6 +2116,19 @@ const message = {
|
|||||||
batchUpgradeWaiting: 'Queued',
|
batchUpgradeWaiting: 'Queued',
|
||||||
batchUpgradeRunning: 'Upgrading',
|
batchUpgradeRunning: 'Upgrading',
|
||||||
batchUpgradeSubmitted: 'Upgrade task submitted',
|
batchUpgradeSubmitted: 'Upgrade task submitted',
|
||||||
|
imageCheckUpdateProxyRequired:
|
||||||
|
'To check for image updates in the current runtime environment, please configure a proxy server first.',
|
||||||
|
imageCheckUpdateConfigureProxy: 'Configure',
|
||||||
|
imageCheckUpdateAvailableVersion: 'Available version “{version}”',
|
||||||
|
imageCheckUpdateContentChanged: '(image content changed)',
|
||||||
|
imageCheckUpdates: 'Check for image updates',
|
||||||
|
imageCheckUpdateNow: 'Check now',
|
||||||
|
imageCheckUpdateSchedule: 'Scheduled checks',
|
||||||
|
imageCheckUpdateDisable: 'Disable scheduled checks',
|
||||||
|
imageCheckUpdateStarted: 'Checking images used by all containers on this node',
|
||||||
|
imageCheckUpdateResult:
|
||||||
|
'Check complete: {checked} checked, {updates} upgradable, {failed} failed, {skipped} skipped',
|
||||||
|
|
||||||
createByCommand: 'Create by command',
|
createByCommand: 'Create by command',
|
||||||
importContainerBackupTip: 'Import container backup file, only .tar.gz is supported',
|
importContainerBackupTip: 'Import container backup file, only .tar.gz is supported',
|
||||||
importComposeBackupTip: 'Import compose backup file, only .tar.gz is supported',
|
importComposeBackupTip: 'Import compose backup file, only .tar.gz is supported',
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
reveal: 'Mostrar secreto',
|
|
||||||
hideSecret: 'Ocultar secreto',
|
|
||||||
revealHelp: 'Guarde el secreto de forma segura. Mostrarlo no reactiva claves deshabilitadas o caducadas.',
|
|
||||||
deleteConfirm:
|
|
||||||
'¿Eliminar «{0}»? Se destruirá el secreto y se quitará la clave de la lista. Se rechazarán nuevas solicitudes y se cerrarán sus terminales. Se conservará la auditoría; las tareas en ejecución no se cancelarán. Esta acción es irreversible.',
|
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: 'Gestionar claves API',
|
manage: 'Gestionar claves API',
|
||||||
personalEntry: 'Gestiona tus claves, condiciones de acceso y vinculación de la APP en Ajustes.',
|
personalEntry: 'Gestiona tus claves, condiciones de acceso y vinculación de la APP en Ajustes.',
|
||||||
@@ -28,6 +23,10 @@ const message = {
|
|||||||
allowAppBinding: 'Permitir vinculación de APP por QR',
|
allowAppBinding: 'Permitir vinculación de APP por QR',
|
||||||
bindingDisableHelp:
|
bindingDisableHelp:
|
||||||
'Desactivar la vinculación por QR impide nuevas vinculaciones. Los dispositivos ya vinculados pueden seguir usando la clave hasta que se desactive, se revoque o caduque.',
|
'Desactivar la vinculación por QR impide nuevas vinculaciones. Los dispositivos ya vinculados pueden seguir usando la clave hasta que se desactive, se revoque o caduque.',
|
||||||
|
saveSecret: 'Copia y guarda la clave ahora. Tras cerrar, la gestión normal no podrá mostrarla de nuevo.',
|
||||||
|
saved: 'He guardado la clave',
|
||||||
|
alreadyCreated:
|
||||||
|
'La clave ya se creó y no se mostrará de nuevo. Si se perdió la respuesta original, revócala y crea otra, o usa QR si está permitido.',
|
||||||
uncertain:
|
uncertain:
|
||||||
'El resultado es incierto. Reintenta con los mismos datos para evitar duplicados, o cierra y revisa la lista antes de crear otra.',
|
'El resultado es incierto. Reintenta con los mismos datos para evitar duplicados, o cierra y revisa la lista antes de crear otra.',
|
||||||
legacyHelp:
|
legacyHelp:
|
||||||
@@ -2136,6 +2135,19 @@ const message = {
|
|||||||
batchUpgradeWaiting: 'Queued',
|
batchUpgradeWaiting: 'Queued',
|
||||||
batchUpgradeRunning: 'Upgrading',
|
batchUpgradeRunning: 'Upgrading',
|
||||||
batchUpgradeSubmitted: 'Upgrade task submitted',
|
batchUpgradeSubmitted: 'Upgrade task submitted',
|
||||||
|
imageCheckUpdateProxyRequired:
|
||||||
|
'Para comprobar actualizaciones de imágenes en el entorno actual, configura primero un servidor proxy.',
|
||||||
|
imageCheckUpdateConfigureProxy: 'Configurar',
|
||||||
|
imageCheckUpdateAvailableVersion: 'Versión disponible «{version}»',
|
||||||
|
imageCheckUpdateContentChanged: '(el contenido de la imagen ha cambiado)',
|
||||||
|
imageCheckUpdates: 'Buscar actualizaciones de imágenes',
|
||||||
|
imageCheckUpdateNow: 'Comprobar ahora',
|
||||||
|
imageCheckUpdateSchedule: 'Comprobación programada',
|
||||||
|
imageCheckUpdateDisable: 'Desactivar comprobación programada',
|
||||||
|
imageCheckUpdateStarted: 'Buscando actualizaciones de las imágenes de todos los contenedores de este nodo',
|
||||||
|
imageCheckUpdateResult:
|
||||||
|
'Comprobación finalizada: comprobados {checked}, actualizables {updates}, fallidos {failed}, omitidos {skipped}',
|
||||||
|
|
||||||
createByCommand: 'Crear por comando',
|
createByCommand: 'Crear por comando',
|
||||||
importContainerBackupTip: 'Importar archivo de copia de seguridad de contenedor, solo se admite .tar.gz',
|
importContainerBackupTip: 'Importar archivo de copia de seguridad de contenedor, solo se admite .tar.gz',
|
||||||
importComposeBackupTip: 'Importar archivo de copia de seguridad de compose, solo se admite .tar.gz',
|
importComposeBackupTip: 'Importar archivo de copia de seguridad de compose, solo se admite .tar.gz',
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
reveal: 'نمایش کلید محرمانه',
|
|
||||||
hideSecret: 'پنهان کردن کلید محرمانه',
|
|
||||||
revealHelp: 'کلید را در جای امن نگه دارید. نمایش آن، کلید غیرفعال یا منقضیشده را قابل استفاده نمیکند.',
|
|
||||||
deleteConfirm:
|
|
||||||
'«{0}» حذف شود؟ کلید محرمانه نابود و از فهرست حذف میشود. درخواستهای جدید رد و اتصالهای ترمینال مرتبط بسته میشوند. سوابق حسابرسی حفظ و کارهای در حال اجرا لغو نمیشوند. این عملیات برگشتپذیر نیست.',
|
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: 'مدیریت کلیدهای API',
|
manage: 'مدیریت کلیدهای API',
|
||||||
personalEntry: 'کلیدها، شرایط دسترسی و اتصال برنامه را در تنظیمات مدیریت کنید.',
|
personalEntry: 'کلیدها، شرایط دسترسی و اتصال برنامه را در تنظیمات مدیریت کنید.',
|
||||||
@@ -28,6 +23,10 @@ const message = {
|
|||||||
allowAppBinding: 'اجازه اتصال برنامه با QR',
|
allowAppBinding: 'اجازه اتصال برنامه با QR',
|
||||||
bindingDisableHelp:
|
bindingDisableHelp:
|
||||||
'با غیرفعال کردن اتصال از طریق QR، اتصال دستگاههای جدید متوقف میشود. دستگاههای متصل میتوانند تا زمان غیرفعالسازی، لغو یا انقضای کلید به استفاده از آن ادامه دهند.',
|
'با غیرفعال کردن اتصال از طریق QR، اتصال دستگاههای جدید متوقف میشود. دستگاههای متصل میتوانند تا زمان غیرفعالسازی، لغو یا انقضای کلید به استفاده از آن ادامه دهند.',
|
||||||
|
saveSecret: 'اکنون کلید را کپی و ذخیره کنید. پس از بستن، مدیریت عادی نمیتواند کلید کامل را دوباره نشان دهد.',
|
||||||
|
saved: 'کلید را ذخیره کردم',
|
||||||
|
alreadyCreated:
|
||||||
|
'این کلید قبلاً ایجاد شده و دوباره نمایش داده نمیشود. اگر پاسخ اصلی گم شده است، کلید را لغو و دوباره ایجاد کنید یا در صورت اجازه از QR استفاده کنید.',
|
||||||
uncertain:
|
uncertain:
|
||||||
'نتیجه درخواست نامشخص است. برای جلوگیری از تکرار، با همان اطلاعات دوباره تلاش کنید یا ببندید و پیش از ایجاد کلید دیگر فهرست را بررسی کنید.',
|
'نتیجه درخواست نامشخص است. برای جلوگیری از تکرار، با همان اطلاعات دوباره تلاش کنید یا ببندید و پیش از ایجاد کلید دیگر فهرست را بررسی کنید.',
|
||||||
legacyHelp:
|
legacyHelp:
|
||||||
@@ -2094,6 +2093,19 @@ const message = {
|
|||||||
batchUpgradeWaiting: 'Queued',
|
batchUpgradeWaiting: 'Queued',
|
||||||
batchUpgradeRunning: 'Upgrading',
|
batchUpgradeRunning: 'Upgrading',
|
||||||
batchUpgradeSubmitted: 'Upgrade task submitted',
|
batchUpgradeSubmitted: 'Upgrade task submitted',
|
||||||
|
imageCheckUpdateProxyRequired:
|
||||||
|
'برای بررسی بهروزرسانی تصاویر در محیط فعلی، ابتدا یک سرور پروکسی پیکربندی کنید.',
|
||||||
|
imageCheckUpdateConfigureProxy: 'پیکربندی',
|
||||||
|
imageCheckUpdateAvailableVersion: 'نسخه قابل ارتقا «{version}»',
|
||||||
|
imageCheckUpdateContentChanged: '(محتوای تصویر تغییر کرده است)',
|
||||||
|
imageCheckUpdates: 'بررسی بهروزرسانی ایمیجها',
|
||||||
|
imageCheckUpdateNow: 'بررسی اکنون',
|
||||||
|
imageCheckUpdateSchedule: 'بررسی زمانبندیشده',
|
||||||
|
imageCheckUpdateDisable: 'غیرفعالکردن بررسی زمانبندیشده',
|
||||||
|
imageCheckUpdateStarted: 'در حال بررسی بهروزرسانی ایمیجهای همه کانتینرهای این گره',
|
||||||
|
imageCheckUpdateResult:
|
||||||
|
'بررسی پایان یافت: بررسیشده {checked}، قابل ارتقا {updates}، ناموفق {failed}، ردشده {skipped}',
|
||||||
|
|
||||||
createByCommand: 'ایجاد با دستور',
|
createByCommand: 'ایجاد با دستور',
|
||||||
importContainerBackupTip: 'واردات فایل پشتیبان کانتینر، فقط .tar.gz پشتیبانی میشود',
|
importContainerBackupTip: 'واردات فایل پشتیبان کانتینر، فقط .tar.gz پشتیبانی میشود',
|
||||||
importComposeBackupTip: 'واردات فایل پشتیبان compose، فقط .tar.gz پشتیبانی میشود',
|
importComposeBackupTip: 'واردات فایل پشتیبان compose، فقط .tar.gz پشتیبانی میشود',
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
reveal: 'シークレットを表示',
|
|
||||||
hideSecret: 'シークレットを隠す',
|
|
||||||
revealHelp: 'シークレットを安全に保管してください。表示しても無効または期限切れのキーは使用できません。',
|
|
||||||
deleteConfirm:
|
|
||||||
'「{0}」を削除しますか?シークレットは破棄され、キーは一覧から削除されます。新しいリクエストは拒否され、関連ターミナルは切断されます。監査記録は保持され、実行中のタスクは中止されません。この操作は元に戻せません。',
|
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: 'API Key を管理',
|
manage: 'API Key を管理',
|
||||||
personalEntry: '設定で自分のキー、アクセス条件、APP の QR 連携を管理します。',
|
personalEntry: '設定で自分のキー、アクセス条件、APP の QR 連携を管理します。',
|
||||||
@@ -28,6 +23,10 @@ const message = {
|
|||||||
allowAppBinding: 'APP の QR 連携を許可',
|
allowAppBinding: 'APP の QR 連携を許可',
|
||||||
bindingDisableHelp:
|
bindingDisableHelp:
|
||||||
'QR 連携を無効にすると、新規の連携を受け付けなくなります。連携済みの端末は、キーが無効化、失効、または期限切れになるまで引き続き利用できます。',
|
'QR 連携を無効にすると、新規の連携を受け付けなくなります。連携済みの端末は、キーが無効化、失効、または期限切れになるまで引き続き利用できます。',
|
||||||
|
saveSecret: '今すぐキーをコピーして保存してください。閉じると通常のキー管理から完全なキーを再表示できません。',
|
||||||
|
saved: 'キーを保存しました',
|
||||||
|
alreadyCreated:
|
||||||
|
'このキーは作成済みで、再表示できません。最初の応答を受け取れなかった場合は失効させて再作成するか、許可されていれば QR を使用してください。',
|
||||||
uncertain: 'リクエスト結果が不明です。重複を避けるため同じ設定で再試行するか、閉じて一覧を確認してください。',
|
uncertain: 'リクエスト結果が不明です。重複を避けるため同じ設定で再試行するか、閉じて一覧を確認してください。',
|
||||||
legacyHelp:
|
legacyHelp:
|
||||||
'旧版キーは従来の API 接続との互換性を維持します。有効化、無効化、リセットはこのキーにのみ適用されます。',
|
'旧版キーは従来の API 接続との互換性を維持します。有効化、無効化、リセットはこのキーにのみ適用されます。',
|
||||||
@@ -2101,6 +2100,18 @@ const message = {
|
|||||||
batchUpgradeWaiting: 'Queued',
|
batchUpgradeWaiting: 'Queued',
|
||||||
batchUpgradeRunning: 'Upgrading',
|
batchUpgradeRunning: 'Upgrading',
|
||||||
batchUpgradeSubmitted: 'Upgrade task submitted',
|
batchUpgradeSubmitted: 'Upgrade task submitted',
|
||||||
|
imageCheckUpdateProxyRequired:
|
||||||
|
'現在の実行環境でイメージの更新を確認するには、先にプロキシサーバーを設定してください。',
|
||||||
|
imageCheckUpdateConfigureProxy: '設定へ',
|
||||||
|
imageCheckUpdateAvailableVersion: '更新可能なバージョン「{version}」',
|
||||||
|
imageCheckUpdateContentChanged: '(イメージの内容が変更されています)',
|
||||||
|
imageCheckUpdates: 'イメージの更新を確認',
|
||||||
|
imageCheckUpdateNow: '今すぐ確認',
|
||||||
|
imageCheckUpdateSchedule: '定期チェック',
|
||||||
|
imageCheckUpdateDisable: '定期チェックを無効にする',
|
||||||
|
imageCheckUpdateStarted: 'このノードのすべてのコンテナで使用されているイメージの更新を確認しています',
|
||||||
|
imageCheckUpdateResult: '確認完了:成功 {checked}、更新可能 {updates}、失敗 {failed}、スキップ {skipped}',
|
||||||
|
|
||||||
createByCommand: 'コマンドで作成',
|
createByCommand: 'コマンドで作成',
|
||||||
importContainerBackupTip: 'コンテナバックアップファイルをインポートします。.tar.gz のみ対応です',
|
importContainerBackupTip: 'コンテナバックアップファイルをインポートします。.tar.gz のみ対応です',
|
||||||
importComposeBackupTip: 'Compose バックアップファイルをインポートします。.tar.gz のみ対応です',
|
importComposeBackupTip: 'Compose バックアップファイルをインポートします。.tar.gz のみ対応です',
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
reveal: '비밀 키 표시',
|
|
||||||
hideSecret: '비밀 키 숨기기',
|
|
||||||
revealHelp: '비밀 키를 안전하게 보관하세요. 표시해도 비활성화되거나 만료된 키는 사용할 수 없습니다.',
|
|
||||||
deleteConfirm:
|
|
||||||
'“{0}”을(를) 삭제하시겠습니까? 비밀 키가 파기되고 목록에서 제거됩니다. 새 요청은 거부되고 연결된 터미널은 종료됩니다. 감사 기록은 유지되며 실행 중인 작업은 취소되지 않습니다. 이 작업은 되돌릴 수 없습니다.',
|
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: 'API Key 관리',
|
manage: 'API Key 관리',
|
||||||
personalEntry: '설정에서 내 키, 접근 조건 및 APP QR 연결을 관리합니다.',
|
personalEntry: '설정에서 내 키, 접근 조건 및 APP QR 연결을 관리합니다.',
|
||||||
@@ -28,6 +23,10 @@ const message = {
|
|||||||
allowAppBinding: 'APP QR 연결 허용',
|
allowAppBinding: 'APP QR 연결 허용',
|
||||||
bindingDisableHelp:
|
bindingDisableHelp:
|
||||||
'QR 연결을 비활성화하면 새로운 기기를 연결할 수 없습니다. 이미 연결된 기기는 키가 비활성화, 폐기 또는 만료될 때까지 계속 사용할 수 있습니다.',
|
'QR 연결을 비활성화하면 새로운 기기를 연결할 수 없습니다. 이미 연결된 기기는 키가 비활성화, 폐기 또는 만료될 때까지 계속 사용할 수 있습니다.',
|
||||||
|
saveSecret: '지금 키를 복사하여 저장하세요. 닫으면 일반 키 관리에서 전체 키를 다시 표시할 수 없습니다.',
|
||||||
|
saved: '키를 저장했습니다',
|
||||||
|
alreadyCreated:
|
||||||
|
'이 키는 이미 생성되어 다시 표시할 수 없습니다. 원래 응답을 잃었다면 폐기 후 다시 만들거나 허용된 QR 연결을 사용하세요.',
|
||||||
uncertain:
|
uncertain:
|
||||||
'요청 결과를 확인할 수 없습니다. 중복 생성을 피하려면 같은 설정으로 재시도하거나 닫고 목록을 확인하세요.',
|
'요청 결과를 확인할 수 없습니다. 중복 생성을 피하려면 같은 설정으로 재시도하거나 닫고 목록을 확인하세요.',
|
||||||
legacyHelp:
|
legacyHelp:
|
||||||
@@ -2076,6 +2075,17 @@ const message = {
|
|||||||
batchUpgradeWaiting: 'Queued',
|
batchUpgradeWaiting: 'Queued',
|
||||||
batchUpgradeRunning: 'Upgrading',
|
batchUpgradeRunning: 'Upgrading',
|
||||||
batchUpgradeSubmitted: 'Upgrade task submitted',
|
batchUpgradeSubmitted: 'Upgrade task submitted',
|
||||||
|
imageCheckUpdateProxyRequired: '현재 실행 환경에서 이미지 업데이트를 확인하려면 먼저 프록시 서버를 설정하세요.',
|
||||||
|
imageCheckUpdateConfigureProxy: '설정하기',
|
||||||
|
imageCheckUpdateAvailableVersion: '업그레이드 가능한 버전 “{version}”',
|
||||||
|
imageCheckUpdateContentChanged: '(이미지 내용 변경)',
|
||||||
|
imageCheckUpdates: '이미지 업데이트 확인',
|
||||||
|
imageCheckUpdateNow: '지금 확인',
|
||||||
|
imageCheckUpdateSchedule: '정기 확인',
|
||||||
|
imageCheckUpdateDisable: '정기 확인 끄기',
|
||||||
|
imageCheckUpdateStarted: '현재 노드의 모든 컨테이너에서 사용하는 이미지의 업데이트를 확인하고 있습니다',
|
||||||
|
imageCheckUpdateResult: '확인 완료: 성공 {checked}, 업데이트 가능 {updates}, 실패 {failed}, 건너뜀 {skipped}',
|
||||||
|
|
||||||
createByCommand: '명령으로 생성',
|
createByCommand: '명령으로 생성',
|
||||||
importContainerBackupTip: '컨테이너 백업 파일 가져오기, .tar.gz 형식만 지원합니다',
|
importContainerBackupTip: '컨테이너 백업 파일 가져오기, .tar.gz 형식만 지원합니다',
|
||||||
importComposeBackupTip: 'Compose 백업 파일 가져오기, .tar.gz 형식만 지원합니다',
|
importComposeBackupTip: 'Compose 백업 파일 가져오기, .tar.gz 형식만 지원합니다',
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
reveal: 'ສະແດງຄີລັບ',
|
|
||||||
hideSecret: 'ເຊື່ອງຄີລັບ',
|
|
||||||
revealHelp: 'ກະລຸນາເກັບຄີລັບໃຫ້ປອດໄພ. ການສະແດງບໍ່ເຮັດໃຫ້ຄີທີ່ປິດໃຊ້ງານ ຫຼືໝົດອາຍຸກັບມາໃຊ້ໄດ້.',
|
|
||||||
deleteConfirm:
|
|
||||||
'ລຶບ “{0}” ຫຼືບໍ່? ຄີລັບຈະຖືກທຳລາຍ ແລະລຶບອອກຈາກລາຍການ. ຄຳຮ້ອງຂໍໃໝ່ຈະຖືກປະຕິເສດ ແລະປິດເທີມິນັລທີ່ກ່ຽວຂ້ອງ. ບັນທຶກການກວດສອບຈະຍັງຄົງຢູ່; ວຽກທີ່ກຳລັງເຮັດຈະບໍ່ຖືກຍົກເລີກ. ບໍ່ສາມາດກູ້ຄືນໄດ້.',
|
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: 'ຈັດການ API Key',
|
manage: 'ຈັດການ API Key',
|
||||||
personalEntry: 'ຈັດການກະແຈ, ເງື່ອນໄຂເຂົ້າໃຊ້ ແລະ ການຜູກ APP ໃນການຕັ້ງຄ່າ.',
|
personalEntry: 'ຈັດການກະແຈ, ເງື່ອນໄຂເຂົ້າໃຊ້ ແລະ ການຜູກ APP ໃນການຕັ້ງຄ່າ.',
|
||||||
@@ -28,6 +23,9 @@ const message = {
|
|||||||
allowAppBinding: 'ອະນຸຍາດຜູກ APP ຜ່ານ QR',
|
allowAppBinding: 'ອະນຸຍາດຜູກ APP ຜ່ານ QR',
|
||||||
bindingDisableHelp:
|
bindingDisableHelp:
|
||||||
'ເມື່ອປິດການຜູກມັດຜ່ານ QR, ລະບົບຈະບໍ່ຮັບການຜູກມັດໃໝ່. ອຸປະກອນທີ່ຜູກມັດແລ້ວຍັງສາມາດໃຊ້ງານໄດ້ ຈົນກວ່າກະແຈຈະຖືກປິດໃຊ້, ຖອນ ຫຼື ໝົດອາຍຸ.',
|
'ເມື່ອປິດການຜູກມັດຜ່ານ QR, ລະບົບຈະບໍ່ຮັບການຜູກມັດໃໝ່. ອຸປະກອນທີ່ຜູກມັດແລ້ວຍັງສາມາດໃຊ້ງານໄດ້ ຈົນກວ່າກະແຈຈະຖືກປິດໃຊ້, ຖອນ ຫຼື ໝົດອາຍຸ.',
|
||||||
|
saveSecret: 'ຄັດລອກ ແລະ ບັນທຶກກະແຈດຽວນີ້. ຫຼັງປິດແລ້ວ ການຈັດການປົກກະຕິຈະບໍ່ສະແດງກະແຈເຕັມອີກ.',
|
||||||
|
saved: 'ຂ້ອຍບັນທຶກກະແຈແລ້ວ',
|
||||||
|
alreadyCreated: 'ກະແຈນີ້ສ້າງແລ້ວ ແລະ ບໍ່ສະແດງອີກ. ຖ້າຄຳຕອບເກົ່າຫາຍ ໃຫ້ຖອນແລ້ວສ້າງໃໝ່ ຫຼື ໃຊ້ QR ຖ້າອະນຸຍາດ.',
|
||||||
uncertain: 'ຍັງບໍ່ແນ່ໃຈຜົນຄຳຮ້ອງ. ລອງໃໝ່ດ້ວຍຂໍ້ມູນເດີມເພື່ອບໍ່ສ້າງຊ້ຳ ຫຼື ປິດແລ້ວກວດລາຍການກ່ອນສ້າງອີກ.',
|
uncertain: 'ຍັງບໍ່ແນ່ໃຈຜົນຄຳຮ້ອງ. ລອງໃໝ່ດ້ວຍຂໍ້ມູນເດີມເພື່ອບໍ່ສ້າງຊ້ຳ ຫຼື ປິດແລ້ວກວດລາຍການກ່ອນສ້າງອີກ.',
|
||||||
legacyHelp:
|
legacyHelp:
|
||||||
'ກະແຈເກົ່າໃຊ້ເພື່ອຮອງຮັບການເຊື່ອມຕໍ່ API ເກົ່າ. ການເປີດໃຊ້, ປິດໃຊ້ ແລະ ຣີເຊັດ ມີຜົນສະເພາະກະແຈນີ້ເທົ່ານັ້ນ.',
|
'ກະແຈເກົ່າໃຊ້ເພື່ອຮອງຮັບການເຊື່ອມຕໍ່ API ເກົ່າ. ການເປີດໃຊ້, ປິດໃຊ້ ແລະ ຣີເຊັດ ມີຜົນສະເພາະກະແຈນີ້ເທົ່ານັ້ນ.',
|
||||||
@@ -2062,6 +2060,19 @@ const message = {
|
|||||||
batchUpgradeWaiting: 'Queued',
|
batchUpgradeWaiting: 'Queued',
|
||||||
batchUpgradeRunning: 'Upgrading',
|
batchUpgradeRunning: 'Upgrading',
|
||||||
batchUpgradeSubmitted: 'Upgrade task submitted',
|
batchUpgradeSubmitted: 'Upgrade task submitted',
|
||||||
|
imageCheckUpdateProxyRequired:
|
||||||
|
'ເພື່ອກວດສອບການອັບເດດອິມເມຈໃນສະພາບແວດລ້ອມປັດຈຸບັນ ກະລຸນາຕັ້ງຄ່າພຣັອກຊີເຊີບເວີກ່ອນ.',
|
||||||
|
imageCheckUpdateConfigureProxy: 'ໄປຕັ້ງຄ່າ',
|
||||||
|
imageCheckUpdateAvailableVersion: 'ເວີຊັນທີ່ອັບເກຣດໄດ້ “{version}”',
|
||||||
|
imageCheckUpdateContentChanged: '(ເນື້ອຫາອິມເມຈປ່ຽນແປງ)',
|
||||||
|
imageCheckUpdates: 'ກວດສອບການອັບເດດອິມເມຈ',
|
||||||
|
imageCheckUpdateNow: 'ກວດສອບຕອນນີ້',
|
||||||
|
imageCheckUpdateSchedule: 'ກວດສອບຕາມຕາຕະລາງ',
|
||||||
|
imageCheckUpdateDisable: 'ປິດການກວດສອບຕາມຕາຕະລາງ',
|
||||||
|
imageCheckUpdateStarted: 'ກຳລັງກວດສອບການອັບເດດອິມເມຈຂອງທຸກຄອນເທນເນີໃນໂນດນີ້',
|
||||||
|
imageCheckUpdateResult:
|
||||||
|
'ກວດສອບສຳເລັດ: ກວດແລ້ວ {checked}, ອັບເກຣດໄດ້ {updates}, ລົ້ມເຫຼວ {failed}, ຂ້າມ {skipped}',
|
||||||
|
|
||||||
createByCommand: 'ສ້າງດ້ວຍຄຳສັ່ງ',
|
createByCommand: 'ສ້າງດ້ວຍຄຳສັ່ງ',
|
||||||
importContainerBackupTip: 'ນຳເຂົ້າໄຟລ໌ສຳຮອງຄອນເທນເນີ, ຮອງຮັບສະເພາະ .tar.gz',
|
importContainerBackupTip: 'ນຳເຂົ້າໄຟລ໌ສຳຮອງຄອນເທນເນີ, ຮອງຮັບສະເພາະ .tar.gz',
|
||||||
importComposeBackupTip: 'ນຳເຂົ້າໄຟລ໌ສຳຮອງ compose, ຮອງຮັບສະເພາະ .tar.gz',
|
importComposeBackupTip: 'ນຳເຂົ້າໄຟລ໌ສຳຮອງ compose, ຮອງຮັບສະເພາະ .tar.gz',
|
||||||
|
|||||||
@@ -2,12 +2,6 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
reveal: 'Papar rahsia',
|
|
||||||
hideSecret: 'Sembunyikan rahsia',
|
|
||||||
revealHelp:
|
|
||||||
'Simpan rahsia ini dengan selamat. Memaparkannya tidak mengaktifkan semula kunci yang dinyahdayakan atau tamat tempoh.',
|
|
||||||
deleteConfirm:
|
|
||||||
'Padam “{0}”? Rahsianya akan dimusnahkan dan kunci dibuang daripada senarai. Permintaan baharu akan ditolak dan terminal berkaitan ditutup. Rekod audit dikekalkan; tugas yang sedang berjalan tidak dibatalkan. Tindakan ini tidak boleh dibuat asal.',
|
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: 'Urus kunci API',
|
manage: 'Urus kunci API',
|
||||||
personalEntry: 'Urus kunci, syarat akses dan pautan APP anda dalam Tetapan.',
|
personalEntry: 'Urus kunci, syarat akses dan pautan APP anda dalam Tetapan.',
|
||||||
@@ -29,6 +23,11 @@ const message = {
|
|||||||
allowAppBinding: 'Benarkan pautan APP melalui QR',
|
allowAppBinding: 'Benarkan pautan APP melalui QR',
|
||||||
bindingDisableHelp:
|
bindingDisableHelp:
|
||||||
'Menyahdayakan pautan QR menghalang pautan peranti baharu. Peranti yang telah dipautkan boleh terus digunakan sehingga kunci dinyahdayakan, dibatalkan atau tamat tempoh.',
|
'Menyahdayakan pautan QR menghalang pautan peranti baharu. Peranti yang telah dipautkan boleh terus digunakan sehingga kunci dinyahdayakan, dibatalkan atau tamat tempoh.',
|
||||||
|
saveSecret:
|
||||||
|
'Salin dan simpan kunci sekarang. Selepas ditutup, pengurusan biasa tidak dapat memaparkan kunci penuh lagi.',
|
||||||
|
saved: 'Saya telah menyimpan kunci',
|
||||||
|
alreadyCreated:
|
||||||
|
'Kunci ini telah dicipta dan tidak akan dipaparkan semula. Jika respons asal hilang, batalkan dan cipta semula atau gunakan QR jika dibenarkan.',
|
||||||
uncertain:
|
uncertain:
|
||||||
'Hasil permintaan belum pasti. Cuba semula dengan butiran sama untuk mengelakkan pendua, atau tutup dan semak senarai sebelum mencipta lagi.',
|
'Hasil permintaan belum pasti. Cuba semula dengan butiran sama untuk mengelakkan pendua, atau tutup dan semak senarai sebelum mencipta lagi.',
|
||||||
legacyHelp:
|
legacyHelp:
|
||||||
@@ -2132,6 +2131,19 @@ const message = {
|
|||||||
batchUpgradeWaiting: 'Queued',
|
batchUpgradeWaiting: 'Queued',
|
||||||
batchUpgradeRunning: 'Upgrading',
|
batchUpgradeRunning: 'Upgrading',
|
||||||
batchUpgradeSubmitted: 'Upgrade task submitted',
|
batchUpgradeSubmitted: 'Upgrade task submitted',
|
||||||
|
imageCheckUpdateProxyRequired:
|
||||||
|
'Untuk menyemak kemas kini imej dalam persekitaran semasa, sila konfigurasikan pelayan proksi terlebih dahulu.',
|
||||||
|
imageCheckUpdateConfigureProxy: 'Konfigurasi',
|
||||||
|
imageCheckUpdateAvailableVersion: 'Versi tersedia “{version}”',
|
||||||
|
imageCheckUpdateContentChanged: '(kandungan imej berubah)',
|
||||||
|
imageCheckUpdates: 'Semak kemas kini imej',
|
||||||
|
imageCheckUpdateNow: 'Semak sekarang',
|
||||||
|
imageCheckUpdateSchedule: 'Semakan berjadual',
|
||||||
|
imageCheckUpdateDisable: 'Matikan semakan berjadual',
|
||||||
|
imageCheckUpdateStarted: 'Menyemak kemas kini imej yang digunakan oleh semua kontena pada nod ini',
|
||||||
|
imageCheckUpdateResult:
|
||||||
|
'Semakan selesai: disemak {checked}, boleh dikemas kini {updates}, gagal {failed}, dilangkau {skipped}',
|
||||||
|
|
||||||
createByCommand: 'Cipta melalui perintah',
|
createByCommand: 'Cipta melalui perintah',
|
||||||
importContainerBackupTip: 'Import fail sandaran kontena, hanya format .tar.gz disokong',
|
importContainerBackupTip: 'Import fail sandaran kontena, hanya format .tar.gz disokong',
|
||||||
importComposeBackupTip: 'Import fail sandaran compose, hanya format .tar.gz disokong',
|
importComposeBackupTip: 'Import fail sandaran compose, hanya format .tar.gz disokong',
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
reveal: 'Mostrar segredo',
|
|
||||||
hideSecret: 'Ocultar segredo',
|
|
||||||
revealHelp: 'Guarde o segredo com segurança. Exibi-lo não reativa chaves desabilitadas ou expiradas.',
|
|
||||||
deleteConfirm:
|
|
||||||
'Excluir “{0}”? O segredo será destruído e a chave removida da lista. Novas solicitações serão rejeitadas e os terminais associados serão fechados. Os registros de auditoria serão mantidos; tarefas em execução não serão canceladas. Esta ação é irreversível.',
|
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: 'Gerenciar chaves API',
|
manage: 'Gerenciar chaves API',
|
||||||
personalEntry: 'Gerencie suas chaves, condições de acesso e vínculo do APP nas Configurações.',
|
personalEntry: 'Gerencie suas chaves, condições de acesso e vínculo do APP nas Configurações.',
|
||||||
@@ -28,6 +23,10 @@ const message = {
|
|||||||
allowAppBinding: 'Permitir vínculo do APP por QR',
|
allowAppBinding: 'Permitir vínculo do APP por QR',
|
||||||
bindingDisableHelp:
|
bindingDisableHelp:
|
||||||
'Desativar a vinculação por QR impede novas vinculações. Os dispositivos já vinculados podem continuar usando a chave até que ela seja desativada, revogada ou expire.',
|
'Desativar a vinculação por QR impede novas vinculações. Os dispositivos já vinculados podem continuar usando a chave até que ela seja desativada, revogada ou expire.',
|
||||||
|
saveSecret: 'Copie e salve a chave agora. Após fechar, o gerenciamento normal não poderá exibi-la novamente.',
|
||||||
|
saved: 'Salvei a chave',
|
||||||
|
alreadyCreated:
|
||||||
|
'Esta chave já foi criada e não será exibida novamente. Se a resposta original foi perdida, revogue e recrie a chave ou use o QR, se permitido.',
|
||||||
uncertain:
|
uncertain:
|
||||||
'O resultado é incerto. Tente novamente com os mesmos dados para evitar duplicações ou feche e verifique a lista antes de criar outra.',
|
'O resultado é incerto. Tente novamente com os mesmos dados para evitar duplicações ou feche e verifique a lista antes de criar outra.',
|
||||||
legacyHelp:
|
legacyHelp:
|
||||||
@@ -2135,6 +2134,19 @@ const message = {
|
|||||||
batchUpgradeWaiting: 'Queued',
|
batchUpgradeWaiting: 'Queued',
|
||||||
batchUpgradeRunning: 'Upgrading',
|
batchUpgradeRunning: 'Upgrading',
|
||||||
batchUpgradeSubmitted: 'Upgrade task submitted',
|
batchUpgradeSubmitted: 'Upgrade task submitted',
|
||||||
|
imageCheckUpdateProxyRequired:
|
||||||
|
'Para verificar atualizações de imagens no ambiente atual, configure primeiro um servidor proxy.',
|
||||||
|
imageCheckUpdateConfigureProxy: 'Configurar',
|
||||||
|
imageCheckUpdateAvailableVersion: 'Versão disponível “{version}”',
|
||||||
|
imageCheckUpdateContentChanged: '(conteúdo da imagem alterado)',
|
||||||
|
imageCheckUpdates: 'Verificar atualizações de imagens',
|
||||||
|
imageCheckUpdateNow: 'Verificar agora',
|
||||||
|
imageCheckUpdateSchedule: 'Verificação agendada',
|
||||||
|
imageCheckUpdateDisable: 'Desativar verificação agendada',
|
||||||
|
imageCheckUpdateStarted: 'Verificando atualizações das imagens de todos os contêineres deste nó',
|
||||||
|
imageCheckUpdateResult:
|
||||||
|
'Verificação concluída: verificados {checked}, atualizáveis {updates}, falhas {failed}, ignorados {skipped}',
|
||||||
|
|
||||||
createByCommand: 'Criar por comando',
|
createByCommand: 'Criar por comando',
|
||||||
importContainerBackupTip: 'Importar arquivo de backup de contêiner, apenas .tar.gz é suportado',
|
importContainerBackupTip: 'Importar arquivo de backup de contêiner, apenas .tar.gz é suportado',
|
||||||
importComposeBackupTip: 'Importar arquivo de backup de compose, apenas .tar.gz é suportado',
|
importComposeBackupTip: 'Importar arquivo de backup de compose, apenas .tar.gz é suportado',
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
reveal: 'Показать секрет',
|
|
||||||
hideSecret: 'Скрыть секрет',
|
|
||||||
revealHelp: 'Храните секрет в безопасности. Просмотр не активирует отключённые или просроченные ключи.',
|
|
||||||
deleteConfirm:
|
|
||||||
'Удалить «{0}»? Секрет будет уничтожен, а ключ удалён из списка. Новые запросы будут отклонены, связанные терминалы закрыты. Журнал аудита сохранится; выполняющиеся задачи не отменяются. Действие необратимо.',
|
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: 'Управление API-ключами',
|
manage: 'Управление API-ключами',
|
||||||
personalEntry: 'Управляйте своими ключами, условиями доступа и привязкой приложения в настройках.',
|
personalEntry: 'Управляйте своими ключами, условиями доступа и привязкой приложения в настройках.',
|
||||||
@@ -28,6 +23,11 @@ const message = {
|
|||||||
allowAppBinding: 'Разрешить привязку приложения по QR',
|
allowAppBinding: 'Разрешить привязку приложения по QR',
|
||||||
bindingDisableHelp:
|
bindingDisableHelp:
|
||||||
'Отключение привязки по QR-коду запрещает новые привязки. Уже привязанные устройства могут продолжать работу, пока ключ не будет отключён, отозван или не истечёт срок его действия.',
|
'Отключение привязки по QR-коду запрещает новые привязки. Уже привязанные устройства могут продолжать работу, пока ключ не будет отключён, отозван или не истечёт срок его действия.',
|
||||||
|
saveSecret:
|
||||||
|
'Скопируйте и сохраните ключ сейчас. После закрытия обычное управление не сможет показать полный ключ снова.',
|
||||||
|
saved: 'Ключ сохранён',
|
||||||
|
alreadyCreated:
|
||||||
|
'Ключ уже создан и не будет показан повторно. Если исходный ответ потерян, отзовите и создайте ключ заново либо используйте разрешённую привязку по QR.',
|
||||||
uncertain:
|
uncertain:
|
||||||
'Результат запроса неизвестен. Повторите с теми же данными, чтобы избежать дублей, или закройте и проверьте список.',
|
'Результат запроса неизвестен. Повторите с теми же данными, чтобы избежать дублей, или закройте и проверьте список.',
|
||||||
legacyHelp:
|
legacyHelp:
|
||||||
@@ -2127,6 +2127,19 @@ const message = {
|
|||||||
batchUpgradeWaiting: 'Queued',
|
batchUpgradeWaiting: 'Queued',
|
||||||
batchUpgradeRunning: 'Upgrading',
|
batchUpgradeRunning: 'Upgrading',
|
||||||
batchUpgradeSubmitted: 'Upgrade task submitted',
|
batchUpgradeSubmitted: 'Upgrade task submitted',
|
||||||
|
imageCheckUpdateProxyRequired:
|
||||||
|
'Для проверки обновлений образов в текущей среде сначала настройте прокси-сервер.',
|
||||||
|
imageCheckUpdateConfigureProxy: 'Настроить',
|
||||||
|
imageCheckUpdateAvailableVersion: 'Доступна версия «{version}»',
|
||||||
|
imageCheckUpdateContentChanged: '(содержимое образа изменилось)',
|
||||||
|
imageCheckUpdates: 'Проверить обновления образов',
|
||||||
|
imageCheckUpdateNow: 'Проверить сейчас',
|
||||||
|
imageCheckUpdateSchedule: 'Проверка по расписанию',
|
||||||
|
imageCheckUpdateDisable: 'Отключить проверку по расписанию',
|
||||||
|
imageCheckUpdateStarted: 'Проверка обновлений образов всех контейнеров на текущем узле',
|
||||||
|
imageCheckUpdateResult:
|
||||||
|
'Проверка завершена: проверено {checked}, доступны обновления {updates}, ошибок {failed}, пропущено {skipped}',
|
||||||
|
|
||||||
createByCommand: 'Создать с помощью команды',
|
createByCommand: 'Создать с помощью команды',
|
||||||
importContainerBackupTip: 'Импорт файла резервной копии контейнера, поддерживается только .tar.gz',
|
importContainerBackupTip: 'Импорт файла резервной копии контейнера, поддерживается только .tar.gz',
|
||||||
importComposeBackupTip: 'Импорт файла резервной копии compose, поддерживается только .tar.gz',
|
importComposeBackupTip: 'Импорт файла резервной копии compose, поддерживается только .tar.gz',
|
||||||
|
|||||||
@@ -2,12 +2,6 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
reveal: 'Gizli anahtarı göster',
|
|
||||||
hideSecret: 'Gizli anahtarı gizle',
|
|
||||||
revealHelp:
|
|
||||||
'Gizli anahtarı güvenli tutun. Görüntülemek, devre dışı veya süresi dolmuş anahtarı kullanılabilir yapmaz.',
|
|
||||||
deleteConfirm:
|
|
||||||
'“{0}” silinsin mi? Gizli anahtar yok edilecek ve listeden kaldırılacaktır. Yeni istekler reddedilecek ve ilgili terminaller kapatılacaktır. Denetim kayıtları korunur; çalışan işler iptal edilmez. Bu işlem geri alınamaz.',
|
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: 'API anahtarlarını yönet',
|
manage: 'API anahtarlarını yönet',
|
||||||
personalEntry: 'Ayarlar bölümünde anahtarlarınızı, erişim koşullarını ve uygulama QR bağlantısını yönetin.',
|
personalEntry: 'Ayarlar bölümünde anahtarlarınızı, erişim koşullarını ve uygulama QR bağlantısını yönetin.',
|
||||||
@@ -29,6 +23,11 @@ const message = {
|
|||||||
allowAppBinding: 'Uygulama QR bağlantısına izin ver',
|
allowAppBinding: 'Uygulama QR bağlantısına izin ver',
|
||||||
bindingDisableHelp:
|
bindingDisableHelp:
|
||||||
'QR eşleştirmesini devre dışı bırakmak yeni eşleştirmeleri engeller. Eşleştirilmiş cihazlar, anahtar devre dışı bırakılana, iptal edilene veya süresi dolana kadar kullanılmaya devam edebilir.',
|
'QR eşleştirmesini devre dışı bırakmak yeni eşleştirmeleri engeller. Eşleştirilmiş cihazlar, anahtar devre dışı bırakılana, iptal edilene veya süresi dolana kadar kullanılmaya devam edebilir.',
|
||||||
|
saveSecret:
|
||||||
|
'Anahtarı şimdi kopyalayıp kaydedin. Kapattıktan sonra normal anahtar yönetimi tam anahtarı yeniden gösteremez.',
|
||||||
|
saved: 'Anahtarı kaydettim',
|
||||||
|
alreadyCreated:
|
||||||
|
'Bu anahtar zaten oluşturuldu ve yeniden gösterilmez. İlk yanıt kaybolduysa iptal edip yeniden oluşturun veya izin veriliyorsa QR bağlantısını kullanın.',
|
||||||
uncertain:
|
uncertain:
|
||||||
'İstek sonucu belirsiz. Tekrarları önlemek için aynı bilgilerle yeniden deneyin veya kapatıp yeni anahtar oluşturmadan listeyi kontrol edin.',
|
'İstek sonucu belirsiz. Tekrarları önlemek için aynı bilgilerle yeniden deneyin veya kapatıp yeni anahtar oluşturmadan listeyi kontrol edin.',
|
||||||
legacyHelp:
|
legacyHelp:
|
||||||
@@ -2133,6 +2132,19 @@ const message = {
|
|||||||
batchUpgradeWaiting: 'Queued',
|
batchUpgradeWaiting: 'Queued',
|
||||||
batchUpgradeRunning: 'Upgrading',
|
batchUpgradeRunning: 'Upgrading',
|
||||||
batchUpgradeSubmitted: 'Upgrade task submitted',
|
batchUpgradeSubmitted: 'Upgrade task submitted',
|
||||||
|
imageCheckUpdateProxyRequired:
|
||||||
|
'Geçerli çalışma ortamında imaj güncellemelerini kontrol etmek için önce bir proxy sunucusu yapılandırın.',
|
||||||
|
imageCheckUpdateConfigureProxy: 'Yapılandır',
|
||||||
|
imageCheckUpdateAvailableVersion: 'Yükseltilebilir sürüm “{version}”',
|
||||||
|
imageCheckUpdateContentChanged: '(imaj içeriği değişti)',
|
||||||
|
imageCheckUpdates: 'İmaj güncellemelerini denetle',
|
||||||
|
imageCheckUpdateNow: 'Şimdi denetle',
|
||||||
|
imageCheckUpdateSchedule: 'Zamanlanmış denetim',
|
||||||
|
imageCheckUpdateDisable: 'Zamanlanmış denetimi kapat',
|
||||||
|
imageCheckUpdateStarted: 'Bu düğümdeki tüm konteynerlerin imaj güncellemeleri denetleniyor',
|
||||||
|
imageCheckUpdateResult:
|
||||||
|
'Denetim tamamlandı: denetlenen {checked}, güncellenebilir {updates}, başarısız {failed}, atlanan {skipped}',
|
||||||
|
|
||||||
createByCommand: 'Komutla oluştur',
|
createByCommand: 'Komutla oluştur',
|
||||||
importContainerBackupTip: 'Konteyner yedek dosyasını içe aktar, yalnızca .tar.gz desteklenir',
|
importContainerBackupTip: 'Konteyner yedek dosyasını içe aktar, yalnızca .tar.gz desteklenir',
|
||||||
importComposeBackupTip: 'Compose yedek dosyasını içe aktar, yalnızca .tar.gz desteklenir',
|
importComposeBackupTip: 'Compose yedek dosyasını içe aktar, yalnızca .tar.gz desteklenir',
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
reveal: '檢視密鑰',
|
|
||||||
hideSecret: '隱藏密鑰',
|
|
||||||
revealHelp: '請妥善保管密鑰。檢視不會恢復已停用或已過期密鑰的使用權限。',
|
|
||||||
deleteConfirm:
|
|
||||||
'確定刪除「{0}」嗎?密鑰將被銷毀並從列表移除,拒絕後續請求並關閉關聯終端。稽核記錄仍保留,執行中的工作不會取消,此操作無法復原。',
|
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: '管理 API Key',
|
manage: '管理 API Key',
|
||||||
personalEntry: '前往設定管理自己的金鑰、存取條件與 APP 掃碼綁定。',
|
personalEntry: '前往設定管理自己的金鑰、存取條件與 APP 掃碼綁定。',
|
||||||
@@ -27,6 +22,10 @@ const message = {
|
|||||||
futureExpiry: '請選擇晚於目前時間的到期時間。',
|
futureExpiry: '請選擇晚於目前時間的到期時間。',
|
||||||
allowAppBinding: '允許 APP 掃碼綁定',
|
allowAppBinding: '允許 APP 掃碼綁定',
|
||||||
bindingDisableHelp: '關閉掃碼綁定後,將停止接受新的綁定;已綁定裝置可繼續使用,直至金鑰被停用、撤銷或到期。',
|
bindingDisableHelp: '關閉掃碼綁定後,將停止接受新的綁定;已綁定裝置可繼續使用,直至金鑰被停用、撤銷或到期。',
|
||||||
|
saveSecret: '請立即複製並儲存金鑰。關閉後,一般金鑰管理無法再次顯示完整金鑰。',
|
||||||
|
saved: '我已儲存金鑰',
|
||||||
|
alreadyCreated:
|
||||||
|
'這把金鑰已建立,無法再次顯示明文。若原回應遺失,請撤銷後重新建立;允許掃碼綁定時,也可透過掃碼交付。',
|
||||||
uncertain: '請求結果尚不確定。請保持原設定重試,避免重複建立;或關閉並檢查清單後再建立其他金鑰。',
|
uncertain: '請求結果尚不確定。請保持原設定重試,避免重複建立;或關閉並檢查清單後再建立其他金鑰。',
|
||||||
legacyHelp: '舊版 Key 用於相容歷史 API 連線,啟用、停用及重設操作僅對該 Key 生效。',
|
legacyHelp: '舊版 Key 用於相容歷史 API 連線,啟用、停用及重設操作僅對該 Key 生效。',
|
||||||
showLegacy: '檢視舊版金鑰',
|
showLegacy: '檢視舊版金鑰',
|
||||||
@@ -1998,6 +1997,17 @@ const message = {
|
|||||||
batchUpgradeWaiting: '等待執行',
|
batchUpgradeWaiting: '等待執行',
|
||||||
batchUpgradeRunning: '正在升級',
|
batchUpgradeRunning: '正在升級',
|
||||||
batchUpgradeSubmitted: '已提交升級任務',
|
batchUpgradeSubmitted: '已提交升級任務',
|
||||||
|
imageCheckUpdateProxyRequired: '目前運行環境下,如需檢查映像是否更新,請先設定代理伺服器',
|
||||||
|
imageCheckUpdateConfigureProxy: '前往設定',
|
||||||
|
imageCheckUpdateAvailableVersion: '可升級版本「{version}」',
|
||||||
|
imageCheckUpdateContentChanged: '(映像內容變更)',
|
||||||
|
imageCheckUpdates: '檢查更新映像',
|
||||||
|
imageCheckUpdateNow: '立即檢查',
|
||||||
|
imageCheckUpdateSchedule: '定時檢查',
|
||||||
|
imageCheckUpdateDisable: '關閉定時檢查',
|
||||||
|
imageCheckUpdateStarted: '已開始檢查目前節點全部容器的映像更新',
|
||||||
|
imageCheckUpdateResult: '檢查完成:成功 {checked},可升級 {updates},失敗 {failed},略過 {skipped}',
|
||||||
|
|
||||||
createByCommand: '指令建立',
|
createByCommand: '指令建立',
|
||||||
importContainerBackupTip: '匯入容器備份檔案,僅支援 .tar.gz 格式',
|
importContainerBackupTip: '匯入容器備份檔案,僅支援 .tar.gz 格式',
|
||||||
importComposeBackupTip: '匯入編排備份檔案,僅支援 .tar.gz 格式',
|
importComposeBackupTip: '匯入編排備份檔案,僅支援 .tar.gz 格式',
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
|||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
apiKeyManagement: {
|
apiKeyManagement: {
|
||||||
reveal: '查看密钥',
|
|
||||||
hideSecret: '隐藏密钥',
|
|
||||||
revealHelp: '请妥善保管密钥。查看不会恢复已停用或已过期密钥的使用权限。',
|
|
||||||
deleteConfirm:
|
|
||||||
'确定删除“{0}”吗?密钥将被销毁并从列表移除,拒绝后续请求并关闭关联终端。审计记录仍保留,运行中的任务不会取消,此操作不可恢复。',
|
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
manage: '管理 API Key',
|
manage: '管理 API Key',
|
||||||
personalEntry: '前往设置管理自己的密钥、访问条件和 APP 扫码绑定。',
|
personalEntry: '前往设置管理自己的密钥、访问条件和 APP 扫码绑定。',
|
||||||
@@ -27,6 +22,10 @@ const message = {
|
|||||||
futureExpiry: '请选择晚于当前时间的到期时间。',
|
futureExpiry: '请选择晚于当前时间的到期时间。',
|
||||||
allowAppBinding: '允许 APP 扫码绑定',
|
allowAppBinding: '允许 APP 扫码绑定',
|
||||||
bindingDisableHelp: '关闭扫码绑定后,将停止接受新的绑定;已绑定设备可继续使用,直至密钥被停用、撤销或到期。',
|
bindingDisableHelp: '关闭扫码绑定后,将停止接受新的绑定;已绑定设备可继续使用,直至密钥被停用、撤销或到期。',
|
||||||
|
saveSecret: '请立即复制并保存密钥。关闭后,普通密钥管理无法再次显示完整密钥。',
|
||||||
|
saved: '我已保存密钥',
|
||||||
|
alreadyCreated:
|
||||||
|
'这把密钥已创建,无法再次展示明文。若原响应丢失,请撤销后重新创建;允许扫码绑定时,也可通过扫码交付。',
|
||||||
uncertain: '请求结果尚不确定。请保持原配置重试,避免重复创建;或关闭并检查列表后再创建其他密钥。',
|
uncertain: '请求结果尚不确定。请保持原配置重试,避免重复创建;或关闭并检查列表后再创建其他密钥。',
|
||||||
legacyHelp: '旧版 Key 用于兼容历史 API 连接,启用、停用和重置操作仅对该 Key 生效。',
|
legacyHelp: '旧版 Key 用于兼容历史 API 连接,启用、停用和重置操作仅对该 Key 生效。',
|
||||||
showLegacy: '查看旧版密钥',
|
showLegacy: '查看旧版密钥',
|
||||||
@@ -2007,6 +2006,17 @@ const message = {
|
|||||||
batchUpgradeWaiting: '等待执行',
|
batchUpgradeWaiting: '等待执行',
|
||||||
batchUpgradeRunning: '正在升级',
|
batchUpgradeRunning: '正在升级',
|
||||||
batchUpgradeSubmitted: '已提交升级任务',
|
batchUpgradeSubmitted: '已提交升级任务',
|
||||||
|
imageCheckUpdateProxyRequired: '当前运行环境下,如需检查镜像是否更新,请优先配置代理服务器',
|
||||||
|
imageCheckUpdateConfigureProxy: '去配置',
|
||||||
|
imageCheckUpdateAvailableVersion: '可升级版本 “{version}”',
|
||||||
|
imageCheckUpdateContentChanged: '(镜像内容变化)',
|
||||||
|
imageCheckUpdates: '检查更新镜像',
|
||||||
|
imageCheckUpdateNow: '立即检查',
|
||||||
|
imageCheckUpdateSchedule: '定时检查',
|
||||||
|
imageCheckUpdateDisable: '关闭定时检查',
|
||||||
|
imageCheckUpdateStarted: '已开始检查当前节点全部容器的镜像更新',
|
||||||
|
imageCheckUpdateResult: '检查完成:成功 {checked},可升级 {updates},失败 {failed},跳过 {skipped}',
|
||||||
|
|
||||||
createByCommand: '命令创建',
|
createByCommand: '命令创建',
|
||||||
importContainerBackupTip: '导入容器备份文件,仅支持 .tar.gz 格式',
|
importContainerBackupTip: '导入容器备份文件,仅支持 .tar.gz 格式',
|
||||||
importComposeBackupTip: '导入编排备份文件,仅支持 .tar.gz 格式',
|
importComposeBackupTip: '导入编排备份文件,仅支持 .tar.gz 格式',
|
||||||
|
|||||||
@@ -357,9 +357,7 @@ const deploymentButtons = [
|
|||||||
{
|
{
|
||||||
label: i18n.global.t('commons.button.delete'),
|
label: i18n.global.t('commons.button.delete'),
|
||||||
permission: true,
|
permission: true,
|
||||||
disabled: (row: Deployment) =>
|
disabled: (row: Deployment) => operationDisabled(row) && row.status !== 'Uninstalling',
|
||||||
deleteLoading.value ||
|
|
||||||
(operationDisabled(row) && !['Uninstalling', 'ReStarting', 'Restarting'].includes(row.status)),
|
|
||||||
click: (row: Deployment) => {
|
click: (row: Deployment) => {
|
||||||
deleting.value = row;
|
deleting.value = row;
|
||||||
forceDelete.value = false;
|
forceDelete.value = false;
|
||||||
|
|||||||
@@ -230,13 +230,8 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="$t('commons.table.status')" min-width="150">
|
<el-table-column :label="$t('commons.table.status')" min-width="150">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-dropdown placement="bottom" :disabled="statusDisabled(row, 'stop')">
|
<el-dropdown placement="bottom" :disabled="statusDisabled(row)">
|
||||||
<Status
|
<Status v-permission :status="row.status" operate :disabled="statusDisabled(row)" />
|
||||||
v-permission
|
|
||||||
:status="row.status"
|
|
||||||
operate
|
|
||||||
:disabled="statusDisabled(row, 'stop')"
|
|
||||||
/>
|
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item
|
<el-dropdown-item
|
||||||
@@ -247,8 +242,7 @@
|
|||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
<el-dropdown-item
|
<el-dropdown-item
|
||||||
:disabled="
|
:disabled="
|
||||||
statusDisabled(row, 'stop') ||
|
statusDisabled(row) || ['Stopped', 'Exited'].includes(row.status)
|
||||||
['Stopped', 'Exited'].includes(row.status)
|
|
||||||
"
|
"
|
||||||
@click="operate(row, 'stop')"
|
@click="operate(row, 'stop')"
|
||||||
>
|
>
|
||||||
@@ -562,6 +556,7 @@ const accountButtons = [
|
|||||||
{
|
{
|
||||||
label: i18n.global.t('commons.button.delete'),
|
label: i18n.global.t('commons.button.delete'),
|
||||||
permission: true,
|
permission: true,
|
||||||
|
show: (row: AI.AgentAccountItem) => !row.appInstallId || row.provider === 'ollama',
|
||||||
click: async (row: AI.AgentAccountItem) => {
|
click: async (row: AI.AgentAccountItem) => {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
i18n.global.t('commons.msg.delete', [row.name]),
|
i18n.global.t('commons.msg.delete', [row.name]),
|
||||||
@@ -575,23 +570,22 @@ const accountButtons = [
|
|||||||
];
|
];
|
||||||
const taskRef = ref();
|
const taskRef = ref();
|
||||||
const pending = ref('');
|
const pending = ref('');
|
||||||
const operationDisabled = (row: Deployment, operation = '') =>
|
const operationDisabled = (row: Deployment) =>
|
||||||
Boolean(pending.value) ||
|
Boolean(pending.value) ||
|
||||||
['Installing', 'Starting', 'Rebuilding', 'Waiting', 'Uninstalling'].includes(row.status) ||
|
['Installing', 'Starting', 'ReStarting', 'Restarting', 'Rebuilding', 'Waiting', 'Uninstalling'].includes(
|
||||||
(['ReStarting', 'Restarting'].includes(row.status) && !['stop', 'delete'].includes(operation));
|
row.status,
|
||||||
const statusDisabled = (row: Deployment, operation = '') =>
|
);
|
||||||
operationDisabled(row, operation) ||
|
const statusDisabled = (row: Deployment) =>
|
||||||
!hasManagePermissionAccess() ||
|
operationDisabled(row) || !hasManagePermissionAccess() || (row.engine === 'ollama' && !hasPermission('app_manage'));
|
||||||
(row.engine === 'ollama' && !hasPermission('app_manage'));
|
|
||||||
const operate = async (row: Deployment, operation: 'start' | 'stop' | 'restart') => {
|
const operate = async (row: Deployment, operation: 'start' | 'stop' | 'restart') => {
|
||||||
if (statusDisabled(row, operation)) return;
|
if (statusDisabled(row)) return;
|
||||||
const node = currentNode.value;
|
const node = currentNode.value;
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
i18n.global.t('app.operatorHelper', [i18n.global.t('commons.operate.' + operation)]),
|
i18n.global.t('app.operatorHelper', [i18n.global.t('commons.operate.' + operation)]),
|
||||||
i18n.global.t('commons.operate.' + operation),
|
i18n.global.t('commons.operate.' + operation),
|
||||||
{ type: 'warning' },
|
{ type: 'warning' },
|
||||||
);
|
);
|
||||||
if (node !== currentNode.value || statusDisabled(row, operation)) return;
|
if (node !== currentNode.value || statusDisabled(row)) return;
|
||||||
pending.value = `${row.engine}:${row.id}`;
|
pending.value = `${row.engine}:${row.id}`;
|
||||||
try {
|
try {
|
||||||
if (row.engine === 'ollama') {
|
if (row.engine === 'ollama') {
|
||||||
@@ -659,7 +653,7 @@ const deploymentButtons = [
|
|||||||
label: i18n.global.t('commons.button.delete'),
|
label: i18n.global.t('commons.button.delete'),
|
||||||
permission: true,
|
permission: true,
|
||||||
show: (row: Deployment) => row.engine === 'ollama' && hasPermission('app_manage'),
|
show: (row: Deployment) => row.engine === 'ollama' && hasPermission('app_manage'),
|
||||||
disabled: (row: Deployment) => operationDisabled(row, 'delete'),
|
disabled: operationDisabled,
|
||||||
click: async (row: Deployment) => {
|
click: async (row: Deployment) => {
|
||||||
const node = currentNode.value;
|
const node = currentNode.value;
|
||||||
const path = route.fullPath;
|
const path = route.fullPath;
|
||||||
@@ -689,7 +683,7 @@ const deploymentButtons = [
|
|||||||
label: i18n.global.t('commons.button.delete'),
|
label: i18n.global.t('commons.button.delete'),
|
||||||
permission: true,
|
permission: true,
|
||||||
show: (row: Deployment) => row.engine === 'vllm',
|
show: (row: Deployment) => row.engine === 'vllm',
|
||||||
disabled: (row: Deployment) => operationDisabled(row, 'delete') && row.status !== 'Uninstalling',
|
disabled: (row: Deployment) => operationDisabled(row) && row.status !== 'Uninstalling',
|
||||||
click: (row: Deployment) => manageRef.value.open(row, 'delete', true),
|
click: (row: Deployment) => manageRef.value.open(row, 'delete', true),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ const buttons = [
|
|||||||
label: i18n.global.t('commons.button.stop'),
|
label: i18n.global.t('commons.button.stop'),
|
||||||
permission: true,
|
permission: true,
|
||||||
disabled: (row: AI.TensorRTLLM) => {
|
disabled: (row: AI.TensorRTLLM) => {
|
||||||
return !['Running', 'ReStarting', 'Restarting'].includes(row.status);
|
return row.status !== 'Running';
|
||||||
},
|
},
|
||||||
click: (row: AI.TensorRTLLM) => {
|
click: (row: AI.TensorRTLLM) => {
|
||||||
operate(row, 'stop');
|
operate(row, 'stop');
|
||||||
|
|||||||
@@ -31,6 +31,34 @@
|
|||||||
<el-button v-permission type="primary" plain @click="onClean()">
|
<el-button v-permission type="primary" plain @click="onClean()">
|
||||||
{{ $t('container.containerPrune') }}
|
{{ $t('container.containerPrune') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-dropdown v-permission trigger="click" @command="onImageCheckUpdateCommand">
|
||||||
|
<el-button type="primary" plain :loading="imageCheckUpdateBusy">
|
||||||
|
{{ $t('container.imageCheckUpdates') }}
|
||||||
|
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item
|
||||||
|
command="check"
|
||||||
|
:disabled="imageCheckUpdateBusy || imageCheckUpdateStatus?.running"
|
||||||
|
>
|
||||||
|
{{ $t('container.imageCheckUpdateNow') }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item
|
||||||
|
command="schedule"
|
||||||
|
:disabled="imageCheckUpdateBusy || !imageCheckUpdateStatus"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
$t(
|
||||||
|
imageCheckUpdateStatus?.enabled
|
||||||
|
? 'container.imageCheckUpdateDisable'
|
||||||
|
: 'container.imageCheckUpdateSchedule',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
</template>
|
</template>
|
||||||
<template #rightToolBar>
|
<template #rightToolBar>
|
||||||
<TableViewSwitch v-model="viewMode" storage-key="container" />
|
<TableViewSwitch v-model="viewMode" storage-key="container" />
|
||||||
@@ -472,8 +500,15 @@ 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 { toRef, computed, onBeforeUnmount, onDeactivated, reactive, ref } from 'vue';
|
import { toRef, computed, onMounted, onActivated, onBeforeUnmount, onDeactivated, reactive, ref, watch } from 'vue';
|
||||||
|
import { ElMessageBox } from 'element-plus';
|
||||||
|
import { ArrowDown } from '@element-plus/icons-vue';
|
||||||
import {
|
import {
|
||||||
|
getContainerImageCheckUpdateStatus,
|
||||||
|
getImageRemoteCheckStatus,
|
||||||
|
runContainerImageCheckUpdate,
|
||||||
|
setContainerImageCheckUpdateSchedule,
|
||||||
|
type ContainerImageCheckUpdateStatus,
|
||||||
containerItemStats,
|
containerItemStats,
|
||||||
containerListStats,
|
containerListStats,
|
||||||
containerOperator,
|
containerOperator,
|
||||||
@@ -777,6 +812,125 @@ const loadContainers = async (background = false, column?: any) => {
|
|||||||
|
|
||||||
const search = (column?: any) => loadContainers(false, column);
|
const search = (column?: any) => loadContainers(false, column);
|
||||||
|
|
||||||
|
const imageCheckUpdateStatus = ref<ContainerImageCheckUpdateStatus>();
|
||||||
|
const imageCheckUpdateBusy = ref(false);
|
||||||
|
let imageCheckUpdateTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let imageCheckUpdateGeneration = 0;
|
||||||
|
let imageCheckUpdateActive = false;
|
||||||
|
let imageCheckUpdateController: AbortController | undefined;
|
||||||
|
const pauseImageCheckUpdatePolling = () => {
|
||||||
|
imageCheckUpdateGeneration++;
|
||||||
|
clearTimeout(imageCheckUpdateTimer);
|
||||||
|
imageCheckUpdateController?.abort();
|
||||||
|
imageCheckUpdateController = undefined;
|
||||||
|
};
|
||||||
|
const stopImageCheckUpdatePolling = () => {
|
||||||
|
imageCheckUpdateActive = false;
|
||||||
|
pauseImageCheckUpdatePolling();
|
||||||
|
};
|
||||||
|
const showImageCheckUpdateResult = (status: ContainerImageCheckUpdateStatus) => {
|
||||||
|
if (status.error) MsgWarning(status.error);
|
||||||
|
else {
|
||||||
|
const summary = i18n.global.t('container.imageCheckUpdateResult', { ...status });
|
||||||
|
if (status.failed) MsgWarning(summary);
|
||||||
|
else MsgSuccess(summary);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const refreshImageCheckUpdateStatus = async (generation: number) => {
|
||||||
|
if (!imageCheckUpdateActive || document.hidden || generation !== imageCheckUpdateGeneration) return;
|
||||||
|
const node = currentNode.value;
|
||||||
|
const controller = new AbortController();
|
||||||
|
imageCheckUpdateController = controller;
|
||||||
|
try {
|
||||||
|
const res = await getContainerImageCheckUpdateStatus(node, controller.signal);
|
||||||
|
if (generation !== imageCheckUpdateGeneration || node !== currentNode.value) return;
|
||||||
|
const previous = imageCheckUpdateStatus.value;
|
||||||
|
imageCheckUpdateStatus.value = res.data;
|
||||||
|
if (previous && res.data.finishedAt && previous.finishedAt !== res.data.finishedAt) {
|
||||||
|
await loadContainers();
|
||||||
|
if (generation !== imageCheckUpdateGeneration || node !== currentNode.value) return;
|
||||||
|
showImageCheckUpdateResult(res.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The HTTP layer displays errors. Retry on the next status poll.
|
||||||
|
} finally {
|
||||||
|
if (imageCheckUpdateController === controller) imageCheckUpdateController = undefined;
|
||||||
|
if (generation === imageCheckUpdateGeneration && imageCheckUpdateActive && !document.hidden) {
|
||||||
|
const interval = imageCheckUpdateStatus.value?.running ? 10000 : 60000;
|
||||||
|
imageCheckUpdateTimer = setTimeout(() => refreshImageCheckUpdateStatus(generation), interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const restartImageCheckUpdatePolling = () => {
|
||||||
|
pauseImageCheckUpdatePolling();
|
||||||
|
if (imageCheckUpdateActive && !document.hidden) void refreshImageCheckUpdateStatus(imageCheckUpdateGeneration);
|
||||||
|
};
|
||||||
|
const startImageCheckUpdatePolling = () => {
|
||||||
|
if (imageCheckUpdateActive) return;
|
||||||
|
imageCheckUpdateActive = true;
|
||||||
|
restartImageCheckUpdatePolling();
|
||||||
|
};
|
||||||
|
const onImageCheckUpdateCommand = async (command: string) => {
|
||||||
|
const node = currentNode.value;
|
||||||
|
const generation = imageCheckUpdateGeneration;
|
||||||
|
imageCheckUpdateBusy.value = true;
|
||||||
|
try {
|
||||||
|
const status = await getContainerImageCheckUpdateStatus(node);
|
||||||
|
if (generation !== imageCheckUpdateGeneration || node !== currentNode.value) return;
|
||||||
|
imageCheckUpdateStatus.value = status.data;
|
||||||
|
// Disabling an existing schedule must remain possible without a proxy.
|
||||||
|
const proxyStatus =
|
||||||
|
command === 'check' || !status.data.enabled ? await getImageRemoteCheckStatus(node) : undefined;
|
||||||
|
if (generation !== imageCheckUpdateGeneration || node !== currentNode.value) return;
|
||||||
|
if (proxyStatus?.data.proxyRequired) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(i18n.global.t('container.imageCheckUpdateProxyRequired'), '', {
|
||||||
|
confirmButtonText: i18n.global.t('container.imageCheckUpdateConfigureProxy'),
|
||||||
|
cancelButtonText: i18n.global.t('commons.button.cancel'),
|
||||||
|
type: 'warning',
|
||||||
|
});
|
||||||
|
if (node === currentNode.value) routerToName('Panel');
|
||||||
|
} catch {
|
||||||
|
// Cancellation leaves the check and schedule untouched.
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res =
|
||||||
|
command === 'check'
|
||||||
|
? await runContainerImageCheckUpdate(node)
|
||||||
|
: await setContainerImageCheckUpdateSchedule(!imageCheckUpdateStatus.value?.enabled, node);
|
||||||
|
if (generation !== imageCheckUpdateGeneration || node !== currentNode.value) return;
|
||||||
|
imageCheckUpdateStatus.value = res.data;
|
||||||
|
if (command === 'check' && res.data.taskID) openTaskLog(res.data.taskID);
|
||||||
|
if (command === 'check' && !res.data.running) {
|
||||||
|
showImageCheckUpdateResult(res.data);
|
||||||
|
await loadContainers();
|
||||||
|
} else {
|
||||||
|
MsgSuccess(
|
||||||
|
i18n.global.t(
|
||||||
|
command === 'check' ? 'container.imageCheckUpdateStarted' : 'commons.msg.operationSuccess',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (generation === imageCheckUpdateGeneration && node === currentNode.value) restartImageCheckUpdatePolling();
|
||||||
|
} catch {
|
||||||
|
// The HTTP layer reports failures; keep the last confirmed schedule state.
|
||||||
|
} finally {
|
||||||
|
if (node === currentNode.value) imageCheckUpdateBusy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
onMounted(startImageCheckUpdatePolling);
|
||||||
|
onMounted(() => document.addEventListener('visibilitychange', restartImageCheckUpdatePolling));
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('visibilitychange', restartImageCheckUpdatePolling));
|
||||||
|
onActivated(startImageCheckUpdatePolling);
|
||||||
|
onBeforeUnmount(stopImageCheckUpdatePolling);
|
||||||
|
onDeactivated(stopImageCheckUpdatePolling);
|
||||||
|
watch(currentNode, () => {
|
||||||
|
imageCheckUpdateStatus.value = undefined;
|
||||||
|
imageCheckUpdateBusy.value = false;
|
||||||
|
restartImageCheckUpdatePolling();
|
||||||
|
});
|
||||||
|
|
||||||
const searchWithStatus = (item: string) => {
|
const searchWithStatus = (item: string) => {
|
||||||
activeTag.value = item;
|
activeTag.value = item;
|
||||||
paginationConfig.state = activeTag.value;
|
paginationConfig.state = activeTag.value;
|
||||||
@@ -994,8 +1148,15 @@ const buttons = [
|
|||||||
{
|
{
|
||||||
label: i18n.global.t('commons.button.upgrade'),
|
label: i18n.global.t('commons.button.upgrade'),
|
||||||
permission: true,
|
permission: true,
|
||||||
|
dot: (row: Container.ContainerInfo) => Boolean(row.hasUpdate),
|
||||||
click: (row: Container.ContainerInfo) => {
|
click: (row: Container.ContainerInfo) => {
|
||||||
dialogUpgradeRef.value!.acceptParams({ container: row.name, image: row.imageName, fromApp: row.isFromApp });
|
dialogUpgradeRef.value!.acceptParams({
|
||||||
|
container: row.name,
|
||||||
|
image: row.imageName,
|
||||||
|
fromApp: row.isFromApp,
|
||||||
|
latestVersion: row.latestVersion,
|
||||||
|
hasUpdate: row.hasUpdate,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,6 +54,13 @@
|
|||||||
:disabled="loading || !group.names.length || !isGroupEditable(group)"
|
:disabled="loading || !group.names.length || !isGroupEditable(group)"
|
||||||
/>
|
/>
|
||||||
<span class="input-help">{{ $t('container.upgradeHelper') }}</span>
|
<span class="input-help">{{ $t('container.upgradeHelper') }}</span>
|
||||||
|
<div v-if="group.availableVersion" class="input-help">
|
||||||
|
{{ $t('container.imageCheckUpdateAvailableVersion', { version: group.availableVersion }) }}
|
||||||
|
<span v-if="imageTag(group.currentImage) === group.availableVersion">
|
||||||
|
{{ $t('container.imageCheckUpdateContentChanged') }}
|
||||||
|
</span>
|
||||||
|
<CopyButton :content="group.availableVersion" />
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<div v-if="group.state !== 'draft'" class="mb-3">
|
<div v-if="group.state !== 'draft'" class="mb-3">
|
||||||
<el-tag :type="statusType(group)">{{ statusLabel(group) }}</el-tag>
|
<el-tag :type="statusType(group)">{{ statusLabel(group) }}</el-tag>
|
||||||
@@ -100,6 +107,7 @@ import { GlobalStore } from '@/store';
|
|||||||
interface UpgradeGroup {
|
interface UpgradeGroup {
|
||||||
currentImage: string;
|
currentImage: string;
|
||||||
targetImage: string;
|
targetImage: string;
|
||||||
|
availableVersion: string;
|
||||||
names: string[];
|
names: string[];
|
||||||
appNames: string[];
|
appNames: string[];
|
||||||
options: Container.ContainerOption[];
|
options: Container.ContainerOption[];
|
||||||
@@ -112,7 +120,14 @@ interface DialogProps {
|
|||||||
container: string;
|
container: string;
|
||||||
image: string;
|
image: string;
|
||||||
fromApp: boolean;
|
fromApp: boolean;
|
||||||
|
latestVersion?: string;
|
||||||
|
hasUpdate?: boolean;
|
||||||
}
|
}
|
||||||
|
const imageTag = (image: string) => {
|
||||||
|
const name = image.slice(image.lastIndexOf('/') + 1);
|
||||||
|
const separator = name.lastIndexOf(':');
|
||||||
|
return separator < 0 ? 'latest' : name.slice(separator + 1);
|
||||||
|
};
|
||||||
const isBatch = ref(false);
|
const isBatch = ref(false);
|
||||||
const isGroupEditable = (group: UpgradeGroup) => ['draft', 'failed'].includes(group.state);
|
const isGroupEditable = (group: UpgradeGroup) => ['draft', 'failed'].includes(group.state);
|
||||||
const isGroupChecked = (group: UpgradeGroup) => group.options.length > 0 && group.names.length === group.options.length;
|
const isGroupChecked = (group: UpgradeGroup) => group.options.length > 0 && group.names.length === group.options.length;
|
||||||
@@ -137,7 +152,15 @@ const acceptParams = async (props: DialogProps | Container.ContainerInfo[]) => {
|
|||||||
isBatch.value = Array.isArray(props);
|
isBatch.value = Array.isArray(props);
|
||||||
const containers = Array.isArray(props)
|
const containers = Array.isArray(props)
|
||||||
? props
|
? props
|
||||||
: [{ name: props.container, imageName: props.image, isFromApp: props.fromApp }];
|
: [
|
||||||
|
{
|
||||||
|
name: props.container,
|
||||||
|
imageName: props.image,
|
||||||
|
isFromApp: props.fromApp,
|
||||||
|
latestVersion: props.latestVersion,
|
||||||
|
hasUpdate: props.hasUpdate,
|
||||||
|
},
|
||||||
|
];
|
||||||
operateNode = GlobalStore().currentNode;
|
operateNode = GlobalStore().currentNode;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
generation++;
|
generation++;
|
||||||
@@ -148,6 +171,7 @@ const acceptParams = async (props: DialogProps | Container.ContainerInfo[]) => {
|
|||||||
group = {
|
group = {
|
||||||
currentImage: item.imageName,
|
currentImage: item.imageName,
|
||||||
targetImage: item.imageName.includes('sha256:') ? '' : item.imageName,
|
targetImage: item.imageName.includes('sha256:') ? '' : item.imageName,
|
||||||
|
availableVersion: '',
|
||||||
names: [],
|
names: [],
|
||||||
appNames: [],
|
appNames: [],
|
||||||
options: [],
|
options: [],
|
||||||
@@ -158,6 +182,7 @@ const acceptParams = async (props: DialogProps | Container.ContainerInfo[]) => {
|
|||||||
};
|
};
|
||||||
groups.set(item.imageName, group);
|
groups.set(item.imageName, group);
|
||||||
}
|
}
|
||||||
|
if (item.hasUpdate && item.latestVersion) group.availableVersion = item.latestVersion;
|
||||||
if (!group.names.includes(item.name)) {
|
if (!group.names.includes(item.name)) {
|
||||||
group.names.push(item.name);
|
group.names.push(item.name);
|
||||||
group.options.push({ name: item.name, state: '' });
|
group.options.push({ name: item.name, state: '' });
|
||||||
@@ -165,7 +190,7 @@ const acceptParams = async (props: DialogProps | Container.ContainerInfo[]) => {
|
|||||||
if (item.isFromApp && !group.appNames.includes(item.name)) group.appNames.push(item.name);
|
if (item.isFromApp && !group.appNames.includes(item.name)) group.appNames.push(item.name);
|
||||||
}
|
}
|
||||||
form.groups = [...groups.values()];
|
form.groups = [...groups.values()];
|
||||||
form.forcePull = false;
|
form.forcePull = containers.some((item) => item.hasUpdate);
|
||||||
started.value = false;
|
started.value = false;
|
||||||
visible.value = true;
|
visible.value = true;
|
||||||
formRef.value?.clearValidate();
|
formRef.value?.clearValidate();
|
||||||
|
|||||||
@@ -72,14 +72,8 @@
|
|||||||
</template>
|
</template>
|
||||||
</CardWithHeader>
|
</CardWithHeader>
|
||||||
<CardWithHeader :header="$t('commons.table.status')" class="card-interval">
|
<CardWithHeader :header="$t('commons.table.status')" class="card-interval">
|
||||||
<template #header-r>
|
|
||||||
<el-radio-group v-if="hasGPU" v-model="statusView">
|
|
||||||
<el-radio-button value="system">{{ $t('menu.system') }}</el-radio-button>
|
|
||||||
<el-radio-button value="gpu">GPU</el-radio-button>
|
|
||||||
</el-radio-group>
|
|
||||||
</template>
|
|
||||||
<template #body>
|
<template #body>
|
||||||
<SystemStatus ref="statusRef" :view="statusView" style="margin-bottom: 33px" />
|
<SystemStatus ref="statusRef" style="margin-bottom: 33px" />
|
||||||
</template>
|
</template>
|
||||||
</CardWithHeader>
|
</CardWithHeader>
|
||||||
<CardWithHeader
|
<CardWithHeader
|
||||||
@@ -550,7 +544,6 @@ const welcomeOpen = ref();
|
|||||||
const shadowContainer = ref();
|
const shadowContainer = ref();
|
||||||
|
|
||||||
const chartOption = ref('network');
|
const chartOption = ref('network');
|
||||||
const statusOption = ref(localStorage.getItem('dashboard_status_view') === 'gpu' ? 'gpu' : 'system');
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
let initialSampleTimer: ReturnType<typeof setTimeout> | null = null;
|
let initialSampleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let isInit = ref<boolean>(true);
|
let isInit = ref<boolean>(true);
|
||||||
@@ -685,16 +678,6 @@ const currentInfo = ref<Dashboard.CurrentInfo>({
|
|||||||
|
|
||||||
shotTime: new Date(),
|
shotTime: new Date(),
|
||||||
});
|
});
|
||||||
const hasGPU = computed(() =>
|
|
||||||
[currentInfo.value.gpuData, currentInfo.value.npuData, currentInfo.value.xpuData].some((items) => items?.length),
|
|
||||||
);
|
|
||||||
const statusView = computed({
|
|
||||||
get: () => (hasGPU.value && statusOption.value === 'gpu' ? 'gpu' : 'system'),
|
|
||||||
set: (value: 'system' | 'gpu') => {
|
|
||||||
statusOption.value = value;
|
|
||||||
localStorage.setItem('dashboard_status_view', value);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const currentChartInfo = reactive({
|
const currentChartInfo = reactive({
|
||||||
ioReadBytes: 0,
|
ioReadBytes: 0,
|
||||||
ioWriteBytes: 0,
|
ioWriteBytes: 0,
|
||||||
|
|||||||
@@ -1,407 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="gpu-status">
|
|
||||||
<div class="gpu-summary">
|
|
||||||
<div class="gpu-summary-header">
|
|
||||||
<span class="metric-label">{{ $t('aiTools.gpu.deviceInfo') }}</span>
|
|
||||||
<el-button link type="primary" size="small" @click="$emit('open')">
|
|
||||||
{{ $t('aiTools.gpu.gpu') }}
|
|
||||||
<el-icon><ArrowRight /></el-icon>
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
<div class="summary-devices">
|
|
||||||
<div class="device-counts">
|
|
||||||
<strong v-for="group in deviceGroups" :key="group.type">
|
|
||||||
{{ group.type }} × {{ group.count }}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
<div class="model-list">
|
|
||||||
<span v-for="model in modelGroups" :key="model.name">{{ model.name }} × {{ model.count }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-memory">
|
|
||||||
<span class="metric-label">{{ $t('aiTools.gpu.memoryTotal') }}</span>
|
|
||||||
<strong>{{ formatMemory(totalMemory) }}</strong>
|
|
||||||
<span class="metric-label">{{ $t('home.used') }} {{ formatMemory(usedMemory) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="gpu-grid">
|
|
||||||
<article v-for="device in visibleDevices" :key="device.key" class="gpu-card">
|
|
||||||
<div class="device-header">
|
|
||||||
<span class="device-id">{{ device.id }}</span>
|
|
||||||
<el-popover :width="360" trigger="click" popper-class="dashboard-gpu-popover">
|
|
||||||
<el-descriptions :title="device.name" :column="1" size="small">
|
|
||||||
<el-descriptions-item
|
|
||||||
v-for="detail in device.details.filter((item) => isAvailable(item[1]))"
|
|
||||||
:key="detail[0]"
|
|
||||||
:label="detail[0]"
|
|
||||||
>
|
|
||||||
{{ detail[1] }}
|
|
||||||
</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
|
||||||
<template #reference>
|
|
||||||
<el-button link type="primary" size="small">{{ $t('commons.button.view') }}</el-button>
|
|
||||||
</template>
|
|
||||||
</el-popover>
|
|
||||||
</div>
|
|
||||||
<div class="device-identity">
|
|
||||||
<h3>{{ device.name }}</h3>
|
|
||||||
<span class="memory-capacity">{{ formatMemory(device.memoryTotal) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="device-metric">
|
|
||||||
<div class="metric-heading">
|
|
||||||
<span class="metric-label">
|
|
||||||
{{ device.type === 'NPU' ? $t('aiTools.gpu.aiCore') : $t('aiTools.gpu.gpuUtil') }}
|
|
||||||
</span>
|
|
||||||
<strong>{{ device.utilization === null ? '—' : `${device.utilization}%` }}</strong>
|
|
||||||
</div>
|
|
||||||
<el-progress :percentage="device.utilization ?? 0" :show-text="false" :stroke-width="6" />
|
|
||||||
</div>
|
|
||||||
<div class="device-metric">
|
|
||||||
<div class="metric-heading">
|
|
||||||
<span class="metric-label">{{ $t('aiTools.gpu.memoryUsed') }}</span>
|
|
||||||
<strong>
|
|
||||||
{{ formatMemory(device.memoryUsed) }}
|
|
||||||
<span class="metric-total">/ {{ formatMemory(device.memoryTotal) }}</span>
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
<el-progress
|
|
||||||
:percentage="device.memoryPercent ?? 0"
|
|
||||||
:show-text="false"
|
|
||||||
:stroke-width="6"
|
|
||||||
color="var(--el-color-success)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="device-footer">
|
|
||||||
<div>
|
|
||||||
<span class="metric-label">{{ $t('aiTools.gpu.temperature') }}</span>
|
|
||||||
<strong>{{ device.temperature }}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="metric-label">{{ $t('aiTools.gpu.powerUsage') }}</span>
|
|
||||||
<strong>{{ device.power }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
<div v-if="devices.length > 4" class="gpu-expand">
|
|
||||||
<el-button link type="primary" @click="showAll = !showAll">
|
|
||||||
{{ $t(showAll ? 'tabs.hide' : 'tabs.more') }}
|
|
||||||
<el-icon>
|
|
||||||
<ArrowUp v-if="showAll" />
|
|
||||||
<ArrowDown v-else />
|
|
||||||
</el-icon>
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { ArrowDown, ArrowRight, ArrowUp } from '@element-plus/icons-vue';
|
|
||||||
import { Dashboard } from '@/api/interface/dashboard';
|
|
||||||
import { computeSizeForDocker } from '@/utils/size';
|
|
||||||
import i18n from '@/lang';
|
|
||||||
|
|
||||||
const props = defineProps<{ current: Dashboard.CurrentInfo }>();
|
|
||||||
defineEmits<{ open: [] }>();
|
|
||||||
const showAll = ref(false);
|
|
||||||
const t = i18n.global.t;
|
|
||||||
|
|
||||||
const isAvailable = (value?: string) =>
|
|
||||||
Boolean(value?.trim() && !/^(N\/A|NA|NOT SUPPORTED|\[NOT SUPPORTED\])$/i.test(value.trim()));
|
|
||||||
|
|
||||||
const memoryBytes = (value?: string): number | null => {
|
|
||||||
const match = value?.trim().match(/^(\d+(?:\.\d+)?)\s*(B|bytes?|[KMGT]i?B)?$/i);
|
|
||||||
if (!match) return null;
|
|
||||||
const units: Record<string, number> = {
|
|
||||||
'': 1024 ** 2,
|
|
||||||
b: 1,
|
|
||||||
byte: 1,
|
|
||||||
bytes: 1,
|
|
||||||
kb: 1000,
|
|
||||||
kib: 1024,
|
|
||||||
mb: 1000 ** 2,
|
|
||||||
mib: 1024 ** 2,
|
|
||||||
gb: 1000 ** 3,
|
|
||||||
gib: 1024 ** 3,
|
|
||||||
tb: 1000 ** 4,
|
|
||||||
tib: 1024 ** 4,
|
|
||||||
};
|
|
||||||
const bytes = Number(match[1]) * units[(match[2] || '').toLowerCase()];
|
|
||||||
return Number.isFinite(bytes) ? bytes : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatMemory = (value: number | null) => (value === null ? '—' : computeSizeForDocker(value));
|
|
||||||
|
|
||||||
const devices = computed(() =>
|
|
||||||
[
|
|
||||||
...(props.current.gpuData || []).map((item) => ({
|
|
||||||
key: `gpu-${item.type}-${item.busID || item.index}`,
|
|
||||||
type: 'GPU',
|
|
||||||
card: `${item.type}-${item.busID || item.index}`,
|
|
||||||
id: `GPU ${item.index}`,
|
|
||||||
name: item.productName || 'GPU',
|
|
||||||
util: item.gpuUtil,
|
|
||||||
used: item.memUsed || item.memoryUsage?.split('/')[0],
|
|
||||||
total: item.memTotal || item.memoryUsage?.split('/')[1],
|
|
||||||
temperature: item.temperature,
|
|
||||||
power: item.powerUsage || item.powerDraw,
|
|
||||||
details: [
|
|
||||||
[t('aiTools.gpu.busID'), item.busID],
|
|
||||||
[t('aiTools.gpu.fanSpeed'), item.fanSpeed],
|
|
||||||
[t('aiTools.gpu.performanceState'), item.performanceState],
|
|
||||||
[t('aiTools.gpu.powerUsage'), item.powerUsage || item.powerDraw],
|
|
||||||
],
|
|
||||||
})),
|
|
||||||
...(props.current.npuData || []).map((item) => ({
|
|
||||||
key: `npu-${item.npuIndex}-${item.chipIndex}`,
|
|
||||||
type: 'NPU',
|
|
||||||
card: item.npuIndex,
|
|
||||||
id: `NPU ${item.npuIndex} · Chip ${item.chipIndex}`,
|
|
||||||
name: item.productName || 'NPU',
|
|
||||||
util: item.aiCore,
|
|
||||||
used: item.memUsed,
|
|
||||||
total: item.memTotal,
|
|
||||||
temperature: item.temperature,
|
|
||||||
power: item.powerDraw,
|
|
||||||
details: [
|
|
||||||
[t('aiTools.gpu.busID'), item.busID],
|
|
||||||
[t('commons.table.status'), item.health],
|
|
||||||
[t('aiTools.gpu.ddrUsage'), `${item.memoryUsed || '—'} / ${item.memoryTotal || '—'}`],
|
|
||||||
[t('aiTools.gpu.hbmUsage'), `${item.hbmUsed || '—'} / ${item.hbmTotal || '—'}`],
|
|
||||||
[t('aiTools.gpu.hugepagesUsage'), `${item.hugepagesUsed || '—'} / ${item.hugepagesTotal || '—'}`],
|
|
||||||
],
|
|
||||||
})),
|
|
||||||
...(props.current.xpuData || []).map((item) => ({
|
|
||||||
key: `xpu-${item.deviceID}`,
|
|
||||||
type: 'XPU',
|
|
||||||
card: item.deviceID,
|
|
||||||
id: `XPU ${item.deviceID}`,
|
|
||||||
name: item.deviceName || 'XPU',
|
|
||||||
util: item.gpuUtil,
|
|
||||||
used: item.memoryUsed,
|
|
||||||
total: item.memory,
|
|
||||||
temperature: item.temperature,
|
|
||||||
power: item.power,
|
|
||||||
details: [[t('aiTools.gpu.busID'), item.pciBdfAddress]],
|
|
||||||
})),
|
|
||||||
].map((item) => {
|
|
||||||
const memoryUsed = memoryBytes(item.used);
|
|
||||||
const memoryTotal = memoryBytes(item.total);
|
|
||||||
const utilization = item.util?.trim().match(/^(\d+(?:\.\d+)?)\s*%?$/);
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
memoryUsed,
|
|
||||||
memoryTotal,
|
|
||||||
utilization: utilization ? Math.min(100, Number(utilization[1])) : null,
|
|
||||||
memoryPercent:
|
|
||||||
memoryUsed !== null && memoryTotal !== null && memoryTotal > 0
|
|
||||||
? Math.min(100, Number(((memoryUsed / memoryTotal) * 100).toFixed(1)))
|
|
||||||
: null,
|
|
||||||
temperature: isAvailable(item.temperature) ? item.temperature.replace(/\s*°?C\b/, ' °C') : '—',
|
|
||||||
power: isAvailable(item.power) ? item.power : '—',
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const visibleDevices = computed(() => (showAll.value ? devices.value : devices.value.slice(0, 4)));
|
|
||||||
const deviceGroups = computed(() =>
|
|
||||||
['GPU', 'NPU', 'XPU']
|
|
||||||
.map((type) => ({
|
|
||||||
type,
|
|
||||||
count: new Set(devices.value.filter((item) => item.type === type).map((item) => item.card)).size,
|
|
||||||
}))
|
|
||||||
.filter((item) => item.count > 0),
|
|
||||||
);
|
|
||||||
const modelGroups = computed(() => {
|
|
||||||
const models = new Map<string, Set<string>>();
|
|
||||||
for (const device of devices.value) {
|
|
||||||
if (!models.has(device.name)) models.set(device.name, new Set());
|
|
||||||
models.get(device.name)!.add(`${device.type}-${device.card}`);
|
|
||||||
}
|
|
||||||
return Array.from(models, ([name, cards]) => ({ name, count: cards.size }));
|
|
||||||
});
|
|
||||||
const totalMemory = computed(() =>
|
|
||||||
devices.value.some((item) => item.memoryTotal === null)
|
|
||||||
? null
|
|
||||||
: devices.value.reduce((sum, item) => sum + item.memoryTotal!, 0),
|
|
||||||
);
|
|
||||||
const usedMemory = computed(() =>
|
|
||||||
devices.value.some((item) => item.memoryUsed === null)
|
|
||||||
? null
|
|
||||||
: devices.value.reduce((sum, item) => sum + item.memoryUsed!, 0),
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.gpu-status {
|
|
||||||
container-type: inline-size;
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
min-width: 0;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
}
|
|
||||||
.gpu-summary {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px 24px;
|
|
||||||
padding: 18px 20px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--el-fill-color-light);
|
|
||||||
}
|
|
||||||
.gpu-summary-header {
|
|
||||||
display: flex;
|
|
||||||
flex: 0 0 100%;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.metric-label {
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.summary-devices {
|
|
||||||
flex: 1 1 200px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.device-counts {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px 20px;
|
|
||||||
margin: 8px 0;
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
.model-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px 16px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-regular);
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.summary-memory {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding-left: 24px;
|
|
||||||
border-left: 1px solid var(--el-border-color-lighter);
|
|
||||||
}
|
|
||||||
.summary-memory > strong {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.gpu-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
.gpu-card {
|
|
||||||
padding: 16px;
|
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
|
||||||
border-radius: 8px;
|
|
||||||
min-width: 0;
|
|
||||||
background: var(--el-bg-color);
|
|
||||||
}
|
|
||||||
.device-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.device-id {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
.device-identity {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px 10px;
|
|
||||||
margin: 10px 0 20px;
|
|
||||||
}
|
|
||||||
.device-identity h3 {
|
|
||||||
flex: 1 1 150px;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-weight: 600;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.memory-capacity {
|
|
||||||
padding: 3px 7px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
background: var(--el-color-primary-light-9);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.device-metric + .device-metric {
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
.metric-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.metric-heading strong {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.metric-total {
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
.device-footer {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px 20px;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 18px;
|
|
||||||
padding-top: 12px;
|
|
||||||
border-top: 1px solid var(--el-border-color-extra-light);
|
|
||||||
}
|
|
||||||
.device-footer > div {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.device-footer strong {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.gpu-expand {
|
|
||||||
margin-top: 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
@container (max-width: 560px) {
|
|
||||||
.gpu-grid {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
.gpu-summary {
|
|
||||||
padding: 16px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
.summary-memory {
|
|
||||||
padding-left: 0;
|
|
||||||
border-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<style lang="scss">
|
|
||||||
.dashboard-gpu-popover.el-popover {
|
|
||||||
max-width: calc(100vw - 24px);
|
|
||||||
max-height: calc(100dvh - 24px);
|
|
||||||
overflow: auto;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="custom-row">
|
<div class="custom-row">
|
||||||
<el-col v-if="view === 'system'" :xs="6" :sm="6" :md="3" :lg="3" :xl="3" align="center">
|
<el-col :xs="6" :sm="6" :md="3" :lg="3" :xl="3" align="center">
|
||||||
<el-popover
|
<el-popover
|
||||||
:hide-after="20"
|
:hide-after="20"
|
||||||
:teleported="false"
|
:teleported="false"
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
</el-popover>
|
</el-popover>
|
||||||
<span class="input-help">{{ loadStatus(currentInfo.loadUsagePercent) }}</span>
|
<span class="input-help">{{ loadStatus(currentInfo.loadUsagePercent) }}</span>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col v-if="view === 'system'" :xs="6" :sm="6" :md="3" :lg="3" :xl="3">
|
<el-col :xs="6" :sm="6" :md="3" :lg="3" :xl="3">
|
||||||
<el-popover
|
<el-popover
|
||||||
:hide-after="20"
|
:hide-after="20"
|
||||||
:teleported="false"
|
:teleported="false"
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col v-if="view === 'system'" :xs="6" :sm="6" :md="3" :lg="3" :xl="3" align="center">
|
<el-col :xs="6" :sm="6" :md="3" :lg="3" :xl="3" align="center">
|
||||||
<el-popover
|
<el-popover
|
||||||
:hide-after="20"
|
:hide-after="20"
|
||||||
:teleported="false"
|
:teleported="false"
|
||||||
@@ -254,15 +254,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</el-col>
|
</el-col>
|
||||||
<template v-for="(item, index) of currentInfo.diskData" :key="index">
|
<template v-for="(item, index) of currentInfo.diskData" :key="index">
|
||||||
<el-col
|
<el-col :xs="6" :sm="6" :md="3" :lg="3" :xl="3" align="center" v-if="isShow('disk', index)">
|
||||||
:xs="6"
|
|
||||||
:sm="6"
|
|
||||||
:md="3"
|
|
||||||
:lg="3"
|
|
||||||
:xl="3"
|
|
||||||
align="center"
|
|
||||||
v-if="view === 'system' && (showMore || index < (currentInfo.diskData.length > 5 ? 4 : 5))"
|
|
||||||
>
|
|
||||||
<el-popover
|
<el-popover
|
||||||
:hide-after="20"
|
:hide-after="20"
|
||||||
:teleported="false"
|
:teleported="false"
|
||||||
@@ -318,16 +310,157 @@
|
|||||||
<span class="input-help">{{ computeSize(item.used) }} / {{ computeSize(item.total) }}</span>
|
<span class="input-help">{{ computeSize(item.used) }} / {{ computeSize(item.total) }}</span>
|
||||||
</el-col>
|
</el-col>
|
||||||
</template>
|
</template>
|
||||||
<GPUStatus v-if="view === 'gpu'" :current="currentInfo" @open="routerToName('GPU')" />
|
<template v-for="(item, index) of currentInfo.gpuData" :key="index">
|
||||||
<el-col
|
<el-col :xs="6" :sm="6" :md="3" :lg="3" :xl="3" align="center" v-if="isShow('gpu', index)">
|
||||||
:xs="6"
|
<el-popover
|
||||||
:sm="6"
|
:hide-after="20"
|
||||||
:md="3"
|
:teleported="false"
|
||||||
:lg="3"
|
:width="450"
|
||||||
:xl="3"
|
popper-class="dashboard-status-popover"
|
||||||
align="center"
|
v-if="chartsOption[`gpu${index}`]"
|
||||||
v-if="view === 'system' && currentInfo.diskData.length > 5"
|
|
||||||
>
|
>
|
||||||
|
<el-descriptions :title="item.productName" direction="vertical" :column="3" size="small">
|
||||||
|
<el-descriptions-item :label="$t('aiTools.gpu.gpuUtil')">
|
||||||
|
{{ item.gpuUtil }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item :label="$t('aiTools.gpu.temperature')">
|
||||||
|
{{ formatDashboardTemperature(item.temperature) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item :label="$t('aiTools.gpu.memoryUsage')">
|
||||||
|
{{ item.memoryUsage }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="hasField(item.busID)" :label="$t('aiTools.gpu.busID')">
|
||||||
|
{{ item.busID }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="hasField(item.fanSpeed)" :label="$t('aiTools.gpu.fanSpeed')">
|
||||||
|
{{ item.fanSpeed }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item
|
||||||
|
v-if="hasField(item.performanceState)"
|
||||||
|
:label="$t('aiTools.gpu.performanceState')"
|
||||||
|
>
|
||||||
|
{{ item.performanceState }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="hasField(item.powerDraw)" :label="$t('aiTools.gpu.powerUsage')">
|
||||||
|
{{ item.powerUsage }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<template #reference>
|
||||||
|
<v-charts
|
||||||
|
@click="goGPU()"
|
||||||
|
height="160px"
|
||||||
|
:id="`gpu${index}`"
|
||||||
|
type="pie"
|
||||||
|
:option="chartsOption[`gpu${index}`]"
|
||||||
|
v-if="chartsOption[`gpu${index}`]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-popover>
|
||||||
|
<el-tooltip :content="item.productName" v-if="item.productName.length > 25">
|
||||||
|
<span class="input-help">{{ item.productName.substring(0, 22) }}...</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<span class="input-help" v-else>{{ item.productName }}</span>
|
||||||
|
</el-col>
|
||||||
|
</template>
|
||||||
|
<template v-for="(item, index) of currentInfo.npuData" :key="index">
|
||||||
|
<el-col :xs="6" :sm="6" :md="3" :lg="3" :xl="3" align="center" v-if="isShow('npu', index)">
|
||||||
|
<el-popover
|
||||||
|
:hide-after="20"
|
||||||
|
:teleported="false"
|
||||||
|
:width="450"
|
||||||
|
popper-class="dashboard-status-popover"
|
||||||
|
v-if="chartsOption[`npu${index}`]"
|
||||||
|
>
|
||||||
|
<el-descriptions :title="item.productName" direction="vertical" :column="3" size="small">
|
||||||
|
<el-descriptions-item v-if="hasField(item.aiCore)" label="AICore(%)">
|
||||||
|
{{ item.aiCore }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="hasField(item.temperature)" :label="$t('aiTools.gpu.temperature')">
|
||||||
|
{{ formatDashboardTemperature(item.temperature) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item
|
||||||
|
v-if="hasMetricPair(item.memUsed, item.memTotal)"
|
||||||
|
:label="$t('aiTools.gpu.memoryUsage')"
|
||||||
|
>
|
||||||
|
{{ formatMetricPair(item.memUsed, item.memTotal) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="hasField(item.powerDraw)" :label="$t('aiTools.gpu.powerUsage')">
|
||||||
|
{{ item.powerDraw }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item
|
||||||
|
v-if="hasMetricPair(item.hugepagesUsed, item.hugepagesTotal)"
|
||||||
|
label="Hugepages-Usage(page)"
|
||||||
|
>
|
||||||
|
{{ formatMetricPair(item.hugepagesUsed, item.hugepagesTotal) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="hasField(item.busID)" :label="$t('aiTools.gpu.busID')">
|
||||||
|
{{ item.busID }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="hasMetricPair(item.hbmUsed, item.hbmTotal)" label="HBM-Usage">
|
||||||
|
{{ formatMetricPair(item.hbmUsed, item.hbmTotal) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<template #reference>
|
||||||
|
<v-charts
|
||||||
|
@click="goGPU()"
|
||||||
|
height="160px"
|
||||||
|
:id="`npu${index}`"
|
||||||
|
type="pie"
|
||||||
|
:option="chartsOption[`npu${index}`]"
|
||||||
|
v-if="chartsOption[`npu${index}`]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-popover>
|
||||||
|
<el-tooltip :content="item.productName" v-if="item.productName.length > 25">
|
||||||
|
<span class="input-help">{{ item.productName.substring(0, 22) }}...</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<span class="input-help" v-else>{{ item.productName }}</span>
|
||||||
|
</el-col>
|
||||||
|
</template>
|
||||||
|
<template v-for="(item, index) of currentInfo.xpuData" :key="index">
|
||||||
|
<el-col :xs="6" :sm="6" :md="3" :lg="3" :xl="3" align="center" v-if="isShow('xpu', index)">
|
||||||
|
<el-popover
|
||||||
|
:hide-after="20"
|
||||||
|
:teleported="false"
|
||||||
|
:width="400"
|
||||||
|
popper-class="dashboard-status-popover"
|
||||||
|
v-if="chartsOption[`xpu${index}`]"
|
||||||
|
>
|
||||||
|
<el-descriptions :title="item.deviceName" direction="vertical" :column="3" size="small">
|
||||||
|
<el-descriptions-item v-if="hasField(item.gpuUtil)" :label="$t('aiTools.gpu.gpuUtil')">
|
||||||
|
{{ item.gpuUtil }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="hasField(item.temperature)" :label="$t('aiTools.gpu.temperature')">
|
||||||
|
{{ item.temperature }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item :label="$t('aiTools.gpu.memoryUsage')">
|
||||||
|
{{ item.memoryUsed }}/{{ item.memory }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="hasField(item.pciBdfAddress)" :label="$t('aiTools.gpu.busID')">
|
||||||
|
{{ item.pciBdfAddress }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="hasField(item.power)" :label="$t('aiTools.gpu.powerUsage')">
|
||||||
|
{{ item.power }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<template #reference>
|
||||||
|
<v-charts
|
||||||
|
@click="goGPU()"
|
||||||
|
height="160px"
|
||||||
|
:id="`xpu${index}`"
|
||||||
|
type="pie"
|
||||||
|
:option="chartsOption[`xpu${index}`]"
|
||||||
|
v-if="chartsOption[`xpu${index}`]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-popover>
|
||||||
|
<el-tooltip :content="item.deviceName" v-if="item.deviceName.length > 25">
|
||||||
|
<span class="input-help">{{ item.deviceName.substring(0, 22) }}...</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<span class="input-help" v-else>{{ item.deviceName }}</span>
|
||||||
|
</el-col>
|
||||||
|
</template>
|
||||||
|
<el-col :xs="6" :sm="6" :md="3" :lg="3" :xl="3" align="center" v-if="totalCount > 5">
|
||||||
<el-button v-if="!showMore" link type="primary" @click="changeShowMore(true)" class="buttonClass">
|
<el-button v-if="!showMore" link type="primary" @click="changeShowMore(true)" class="buttonClass">
|
||||||
{{ $t('tabs.more') }}
|
{{ $t('tabs.more') }}
|
||||||
<el-icon><Bottom /></el-icon>
|
<el-icon><Bottom /></el-icon>
|
||||||
@@ -345,8 +478,7 @@
|
|||||||
import { Dashboard } from '@/api/interface/dashboard';
|
import { Dashboard } from '@/api/interface/dashboard';
|
||||||
import { computeSize } from '@/utils/size';
|
import { computeSize } from '@/utils/size';
|
||||||
import i18n from '@/lang';
|
import i18n from '@/lang';
|
||||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
import { nextTick, onBeforeUnmount, ref } from 'vue';
|
||||||
import GPUStatus from './gpu.vue';
|
|
||||||
import { useMediaQuery } from '@vueuse/core';
|
import { useMediaQuery } from '@vueuse/core';
|
||||||
import { routerToFileWithPath, routerToName } from '@/utils/router';
|
import { routerToFileWithPath, routerToName } from '@/utils/router';
|
||||||
import { stopProcess } from '@/api/modules/process';
|
import { stopProcess } from '@/api/modules/process';
|
||||||
@@ -354,8 +486,9 @@ import { loadTopCPU, loadTopMem } from '@/api/modules/dashboard';
|
|||||||
import { MsgSuccess } from '@/utils/message';
|
import { MsgSuccess } from '@/utils/message';
|
||||||
|
|
||||||
const hasFinePointer = useMediaQuery('(hover: hover) and (pointer: fine)');
|
const hasFinePointer = useMediaQuery('(hover: hover) and (pointer: fine)');
|
||||||
const props = defineProps<{ view: 'system' | 'gpu' }>();
|
|
||||||
const showMore = ref(false);
|
const showMore = ref(false);
|
||||||
|
const totalCount = ref();
|
||||||
|
|
||||||
let cpuPopoverTimer: ReturnType<typeof setTimeout> | null = null;
|
let cpuPopoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let memPopoverTimer: ReturnType<typeof setTimeout> | null = null;
|
let memPopoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let cpuLoading = false;
|
let cpuLoading = false;
|
||||||
@@ -473,10 +606,57 @@ const acceptParams = (current: Dashboard.CurrentInfo, base: Dashboard.BaseInfo):
|
|||||||
data: formatNumber(currentInfo.value.diskData[i].usedPercent),
|
data: formatNumber(currentInfo.value.diskData[i].usedPercent),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
currentInfo.value.gpuData = currentInfo.value.gpuData || [];
|
||||||
|
for (let i = 0; i < currentInfo.value.gpuData.length; i++) {
|
||||||
|
chartsOption.value['gpu' + i] = {
|
||||||
|
title: 'GPU-' + currentInfo.value.gpuData[i].index,
|
||||||
|
data: metricPercentage(currentInfo.value.gpuData[i].gpuUtil),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
currentInfo.value.npuData = currentInfo.value.npuData || [];
|
||||||
|
for (let i = 0; i < currentInfo.value.npuData.length; i++) {
|
||||||
|
chartsOption.value['npu' + i] = {
|
||||||
|
title: 'NPU-' + currentInfo.value.npuData[i].npuIndex + '/' + currentInfo.value.npuData[i].chipIndex,
|
||||||
|
data: metricPercentage(currentInfo.value.npuData[i].aiCore),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
currentInfo.value.xpuData = currentInfo.value.xpuData || [];
|
||||||
|
for (let i = 0; i < currentInfo.value.xpuData.length; i++) {
|
||||||
|
chartsOption.value['xpu' + i] = {
|
||||||
|
title: 'XPU-' + currentInfo.value.xpuData[i].deviceID,
|
||||||
|
data: metricPercentage(currentInfo.value.xpuData[i].gpuUtil || currentInfo.value.xpuData[i].memoryUtil),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
totalCount.value =
|
||||||
|
currentInfo.value.diskData.length +
|
||||||
|
currentInfo.value.gpuData.length +
|
||||||
|
currentInfo.value.npuData.length +
|
||||||
|
currentInfo.value.xpuData.length;
|
||||||
showMore.value = localStorage.getItem('dashboard_show') === 'more';
|
showMore.value = localStorage.getItem('dashboard_show') === 'more';
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isShow = (val: string, index: number) => {
|
||||||
|
let showCount = totalCount.value < 6 ? 5 : 4;
|
||||||
|
switch (val) {
|
||||||
|
case 'disk':
|
||||||
|
return showMore.value || index < showCount;
|
||||||
|
case 'gpu':
|
||||||
|
let gpuCount = showCount - currentInfo.value.diskData.length;
|
||||||
|
return showMore.value || index < gpuCount;
|
||||||
|
case 'npu':
|
||||||
|
let npuCount = showCount - currentInfo.value.diskData.length - currentInfo.value.gpuData.length;
|
||||||
|
return showMore.value || index < npuCount;
|
||||||
|
case 'xpu':
|
||||||
|
let xpuCount =
|
||||||
|
showCount -
|
||||||
|
currentInfo.value.diskData.length -
|
||||||
|
currentInfo.value.gpuData.length -
|
||||||
|
currentInfo.value.npuData.length;
|
||||||
|
return showMore.value || index < xpuCount;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const changeShowMore = (show: boolean) => {
|
const changeShowMore = (show: boolean) => {
|
||||||
showMore.value = show;
|
showMore.value = show;
|
||||||
localStorage.setItem('dashboard_show', show ? 'more' : 'hide');
|
localStorage.setItem('dashboard_show', show ? 'more' : 'hide');
|
||||||
@@ -510,10 +690,35 @@ function loadStatus(val: number) {
|
|||||||
return i18n.global.t('home.runJam');
|
return i18n.global.t('home.runJam');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goGPU = () => {
|
||||||
|
routerToName('GPU');
|
||||||
|
};
|
||||||
|
|
||||||
function formatNumber(val: number) {
|
function formatNumber(val: number) {
|
||||||
return Number(val.toFixed(2));
|
return Number(val.toFixed(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasField = (value?: string) => {
|
||||||
|
return typeof value === 'string' && value.trim() !== '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasMetricPair = (used?: string, total?: string) => {
|
||||||
|
return hasField(used) || hasField(total);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatMetricPair = (used?: string, total?: string) => {
|
||||||
|
return `${used || 'N/A'} / ${total || 'N/A'}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDashboardTemperature = (value: string) => {
|
||||||
|
return value.replace(/\s*°?C\b/, ' °C');
|
||||||
|
};
|
||||||
|
|
||||||
|
const metricPercentage = (value?: string) => {
|
||||||
|
const matched = value?.match(/[0-9]+(?:\.[0-9]+)?/);
|
||||||
|
return matched ? formatNumber(Number.parseFloat(matched[0])) : 0;
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeDashboardAccelerators = (current: Dashboard.CurrentInfo) => {
|
const normalizeDashboardAccelerators = (current: Dashboard.CurrentInfo) => {
|
||||||
const legacyNPUs = (current.gpuData || [])
|
const legacyNPUs = (current.gpuData || [])
|
||||||
.filter((item) => item.type === 'ascend')
|
.filter((item) => item.type === 'ascend')
|
||||||
@@ -615,14 +820,6 @@ const loadTopMemData = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.view,
|
|
||||||
() => {
|
|
||||||
onCpuPopoverHide();
|
|
||||||
onMemPopoverHide();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (cpuPopoverTimer) {
|
if (cpuPopoverTimer) {
|
||||||
clearInterval(Number(cpuPopoverTimer));
|
clearInterval(Number(cpuPopoverTimer));
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
/>
|
/>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-[var(--el-text-color-secondary)] break-all">{{ row.keyHint }}</div>
|
<div class="text-xs text-gray-500 break-all">{{ row.keyHint }}</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="$t('commons.table.status')" min-width="100" prop="status">
|
<el-table-column :label="$t('commons.table.status')" min-width="100" prop="status">
|
||||||
@@ -137,50 +137,26 @@
|
|||||||
v-model="detailVisible"
|
v-model="detailVisible"
|
||||||
:header="$t('apiKeyManagement.details')"
|
:header="$t('apiKeyManagement.details')"
|
||||||
size="min(640px, 100vw)"
|
size="min(640px, 100vw)"
|
||||||
@close="clearSecret"
|
@close="clearLegacySecret"
|
||||||
>
|
>
|
||||||
<APIKeySummary v-if="detail" :item="detail" />
|
<APIKeySummary v-if="detail" :item="detail" />
|
||||||
<div v-if="detail && detail.status !== 'Revoked'" class="mt-5 flex flex-col gap-3">
|
<div v-if="detail?.kind === 'legacy' && detail.status !== 'Revoked'" class="mt-5 flex flex-col gap-3">
|
||||||
<el-alert
|
<el-alert :title="$t('apiKeyManagement.legacyHelp')" type="info" :closable="false" show-icon />
|
||||||
:title="
|
<el-space wrap>
|
||||||
$t(detail.kind === 'legacy' ? 'apiKeyManagement.legacyHelp' : 'apiKeyManagement.revealHelp')
|
<el-button type="primary" plain :loading="legacyBusy" @click="revealLegacy">
|
||||||
"
|
{{ $t('apiKeyManagement.showLegacy') }}
|
||||||
type="info"
|
|
||||||
:closable="false"
|
|
||||||
show-icon
|
|
||||||
/>
|
|
||||||
<el-space v-if="detail.kind === 'legacy'" wrap>
|
|
||||||
<el-button :disabled="secretBusy" @click="resetLegacy">
|
|
||||||
{{ $t('setting.apiKeyReset') }}
|
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button :disabled="legacyBusy" @click="resetLegacy">{{ $t('setting.apiKeyReset') }}</el-button>
|
||||||
</el-space>
|
</el-space>
|
||||||
<el-input
|
<el-input
|
||||||
:model-value="secret || '********'"
|
v-if="legacySecret"
|
||||||
:type="secret ? 'text' : 'password'"
|
:model-value="legacySecret"
|
||||||
:aria-label="$t('setting.apiKey')"
|
:aria-label="$t('setting.apiKey')"
|
||||||
readonly
|
readonly
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
>
|
>
|
||||||
<template #suffix>
|
|
||||||
<el-button
|
|
||||||
link
|
|
||||||
:icon="secret ? Hide : View"
|
|
||||||
:loading="secretBusy && secretAction === 'reveal'"
|
|
||||||
:disabled="loading || secretBusy"
|
|
||||||
:aria-label="$t(secret ? 'apiKeyManagement.hideSecret' : 'apiKeyManagement.reveal')"
|
|
||||||
:title="$t(secret ? 'apiKeyManagement.hideSecret' : 'apiKeyManagement.reveal')"
|
|
||||||
:aria-pressed="!!secret"
|
|
||||||
@click="toggleSecret"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template #append>
|
<template #append>
|
||||||
<el-button
|
<CopyButton :content="legacySecret" :is-icon="false" />
|
||||||
:loading="secretBusy && secretAction === 'copy'"
|
|
||||||
:disabled="loading || secretBusy"
|
|
||||||
@click="copySecret"
|
|
||||||
>
|
|
||||||
{{ $t('commons.button.copy') }}
|
|
||||||
</el-button>
|
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
</div>
|
</div>
|
||||||
@@ -189,11 +165,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, reactive, ref } from 'vue';
|
||||||
import { ElMessageBox } from 'element-plus';
|
import { ElMessageBox } from 'element-plus';
|
||||||
import { Hide, View } from '@element-plus/icons-vue';
|
|
||||||
import type { APIKey } from '@/api/interface/api-key';
|
import type { APIKey } from '@/api/interface/api-key';
|
||||||
import { deleteAPIKey, revealAPIKey, revokeAPIKey, searchAPIKeys, setAPIKeyStatus } from '@/api/modules/api-key';
|
import { revokeAPIKey, searchAPIKeys, setAPIKeyStatus } from '@/api/modules/api-key';
|
||||||
import { generateApiKey, getUserInfo } from '@/api/modules/auth';
|
import { generateApiKey, getUserInfo } from '@/api/modules/auth';
|
||||||
import APIKeyEditor from '@/components/api-key-management/editor.vue';
|
import APIKeyEditor from '@/components/api-key-management/editor.vue';
|
||||||
import APIKeySummary from '@/components/api-key-management/summary.vue';
|
import APIKeySummary from '@/components/api-key-management/summary.vue';
|
||||||
@@ -201,38 +176,35 @@ import DrawerPro from '@/components/drawer-pro/index.vue';
|
|||||||
import type { FuTableOperationButton } from '@/components/table/shared';
|
import type { FuTableOperationButton } from '@/components/table/shared';
|
||||||
import { isAnyAPIKeyIP, normalizeAPIKeyIPs } from '@/utils/api-key';
|
import { isAnyAPIKeyIP, normalizeAPIKeyIPs } from '@/utils/api-key';
|
||||||
import { MsgSuccess, MsgWarning } from '@/utils/message';
|
import { MsgSuccess, MsgWarning } from '@/utils/message';
|
||||||
import { copyText } from '@/utils/clipboard';
|
|
||||||
import i18n from '@/lang';
|
import i18n from '@/lang';
|
||||||
import { useGlobalStore } from '@/composables/useGlobalStore';
|
import { useGlobalStore } from '@/composables/useGlobalStore';
|
||||||
|
|
||||||
const { isMobile, globalStore } = useGlobalStore();
|
const { isMobile } = useGlobalStore();
|
||||||
|
|
||||||
const items = ref<APIKey.Item[]>([]);
|
const items = ref<APIKey.Item[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const editor = ref<InstanceType<typeof APIKeyEditor>>();
|
const editor = ref<InstanceType<typeof APIKeyEditor>>();
|
||||||
const detail = ref<APIKey.Item>();
|
const detail = ref<APIKey.Item>();
|
||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
const secret = ref('');
|
const legacySecret = ref('');
|
||||||
const secretBusy = ref(false);
|
const legacyBusy = ref(false);
|
||||||
const secretAction = ref<'reveal' | 'copy'>('reveal');
|
|
||||||
const pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 });
|
const pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 });
|
||||||
let searchVersion = 0;
|
let searchVersion = 0;
|
||||||
let secretRequestVersion = 0;
|
let secretRequestVersion = 0;
|
||||||
let active = false;
|
let active = false;
|
||||||
let operationPending = false;
|
|
||||||
|
|
||||||
const whitelistEntries = (value: string) =>
|
const whitelistEntries = (value: string) =>
|
||||||
normalizeAPIKeyIPs(value || '')
|
normalizeAPIKeyIPs(value || '')
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const clearSecret = () => {
|
const clearLegacySecret = () => {
|
||||||
secretRequestVersion++;
|
secretRequestVersion++;
|
||||||
secret.value = '';
|
legacySecret.value = '';
|
||||||
secretBusy.value = false;
|
legacyBusy.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const search = async (preserveSecretRequest?: number) => {
|
const search = async (preserveLegacyRequest?: number) => {
|
||||||
const ticket = ++searchVersion;
|
const ticket = ++searchVersion;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -241,7 +213,7 @@ const search = async (preserveSecretRequest?: number) => {
|
|||||||
const lastPage = Math.max(1, Math.ceil(response.data.total / pagination.pageSize));
|
const lastPage = Math.max(1, Math.ceil(response.data.total / pagination.pageSize));
|
||||||
if (pagination.currentPage > lastPage) {
|
if (pagination.currentPage > lastPage) {
|
||||||
pagination.currentPage = lastPage;
|
pagination.currentPage = lastPage;
|
||||||
await search(preserveSecretRequest);
|
await search(preserveLegacyRequest);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
items.value = response.data.items || [];
|
items.value = response.data.items || [];
|
||||||
@@ -249,8 +221,8 @@ const search = async (preserveSecretRequest?: number) => {
|
|||||||
if (detail.value) {
|
if (detail.value) {
|
||||||
const previousRevision = detail.value.revision;
|
const previousRevision = detail.value.revision;
|
||||||
detail.value = items.value.find((entry) => entry.id === detail.value?.id);
|
detail.value = items.value.find((entry) => entry.id === detail.value?.id);
|
||||||
if (detail.value?.revision !== previousRevision && preserveSecretRequest !== secretRequestVersion)
|
if (detail.value?.revision !== previousRevision && preserveLegacyRequest !== secretRequestVersion)
|
||||||
clearSecret();
|
clearLegacySecret();
|
||||||
if (!detail.value) detailVisible.value = false;
|
if (!detail.value) detailVisible.value = false;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -258,7 +230,7 @@ const search = async (preserveSecretRequest?: number) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const showDetail = (entry: APIKey.Item) => {
|
const showDetail = (entry: APIKey.Item) => {
|
||||||
clearSecret();
|
clearLegacySecret();
|
||||||
detail.value = entry;
|
detail.value = entry;
|
||||||
detailVisible.value = true;
|
detailVisible.value = true;
|
||||||
};
|
};
|
||||||
@@ -267,15 +239,11 @@ const operate = async (entry: APIKey.Item, action: string) => {
|
|||||||
editor.value?.open(entry);
|
editor.value?.open(entry);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!active || operationPending || !['status', 'revoke', 'delete'].includes(action)) return;
|
if (action !== 'status' && action !== 'revoke') return;
|
||||||
operationPending = true;
|
|
||||||
const requestVersion = searchVersion;
|
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
i18n.global.t(
|
i18n.global.t(
|
||||||
action === 'delete'
|
action === 'revoke'
|
||||||
? 'apiKeyManagement.deleteConfirm'
|
|
||||||
: action === 'revoke'
|
|
||||||
? 'apiKeyManagement.revokeConfirm'
|
? 'apiKeyManagement.revokeConfirm'
|
||||||
: entry.status === 'Enable'
|
: entry.status === 'Enable'
|
||||||
? 'apiKeyManagement.disableConfirm'
|
? 'apiKeyManagement.disableConfirm'
|
||||||
@@ -290,29 +258,20 @@ const operate = async (entry: APIKey.Item, action: string) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
operationPending = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!active || requestVersion !== searchVersion) {
|
|
||||||
operationPending = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearSecret();
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const reference = { id: entry.id, revision: entry.revision };
|
const reference = { id: entry.id, revision: entry.revision };
|
||||||
const response =
|
const response =
|
||||||
action === 'delete'
|
action === 'revoke'
|
||||||
? await deleteAPIKey(reference)
|
|
||||||
: action === 'revoke'
|
|
||||||
? await revokeAPIKey(reference)
|
? await revokeAPIKey(reference)
|
||||||
: await setAPIKeyStatus({ ...reference, status: entry.status === 'Enable' ? 'Disable' : 'Enable' });
|
: await setAPIKeyStatus({ ...reference, status: entry.status === 'Enable' ? 'Disable' : 'Enable' });
|
||||||
clearSecret();
|
clearLegacySecret();
|
||||||
if (response.data?.terminalClosePending) MsgWarning(i18n.global.t('apiKeyManagement.closePending'));
|
if (response.data?.terminalClosePending) MsgWarning(i18n.global.t('apiKeyManagement.closePending'));
|
||||||
else MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
else MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||||
} finally {
|
} finally {
|
||||||
operationPending = false;
|
await search();
|
||||||
if (active) await search();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const buttons = computed<FuTableOperationButton<APIKey.Item>[]>(() => [
|
const buttons = computed<FuTableOperationButton<APIKey.Item>[]>(() => [
|
||||||
@@ -341,59 +300,21 @@ const buttons = computed<FuTableOperationButton<APIKey.Item>[]>(() => [
|
|||||||
show: (row) => row.status !== 'Revoked',
|
show: (row) => row.status !== 'Revoked',
|
||||||
click: (row) => operate(row, 'revoke'),
|
click: (row) => operate(row, 'revoke'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: i18n.global.t('commons.button.delete'),
|
|
||||||
show: (row) => row.kind === 'apiKey',
|
|
||||||
click: (row) => operate(row, 'delete'),
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
const toggleSecret = async () => {
|
const revealLegacy = async () => {
|
||||||
if (secretBusy.value || loading.value) return;
|
if (legacyBusy.value || !detailVisible.value || detail.value?.kind !== 'legacy') return;
|
||||||
if (secret.value) clearSecret();
|
|
||||||
else await revealSecret();
|
|
||||||
};
|
|
||||||
const accessSecret = async (action: 'reveal' | 'copy') => {
|
|
||||||
if (secretBusy.value || loading.value || !active || !detailVisible.value || !detail.value) return;
|
|
||||||
if (detail.value.status === 'Revoked') return;
|
|
||||||
const reference = { id: detail.value.id, revision: detail.value.revision };
|
|
||||||
const kind = detail.value.kind;
|
|
||||||
const ticket = ++secretRequestVersion;
|
const ticket = ++secretRequestVersion;
|
||||||
secretAction.value = action;
|
legacyBusy.value = true;
|
||||||
secretBusy.value = true;
|
|
||||||
try {
|
try {
|
||||||
if (action === 'copy' && secret.value) {
|
const response = await getUserInfo();
|
||||||
await copyText(secret.value);
|
if (ticket === secretRequestVersion && detailVisible.value && active && detail.value?.kind === 'legacy')
|
||||||
return;
|
legacySecret.value = response.data.apiKey;
|
||||||
}
|
|
||||||
secret.value = '';
|
|
||||||
const data =
|
|
||||||
kind === 'legacy'
|
|
||||||
? { ...reference, apiKey: (await getUserInfo()).data.apiKey }
|
|
||||||
: (await revealAPIKey(reference)).data;
|
|
||||||
if (
|
|
||||||
ticket === secretRequestVersion &&
|
|
||||||
active &&
|
|
||||||
detailVisible.value &&
|
|
||||||
detail.value?.id === reference.id &&
|
|
||||||
detail.value?.kind === kind &&
|
|
||||||
detail.value?.revision === reference.revision &&
|
|
||||||
data.id === reference.id &&
|
|
||||||
data.revision === reference.revision &&
|
|
||||||
data.apiKey
|
|
||||||
) {
|
|
||||||
if (action === 'copy') await copyText(data.apiKey);
|
|
||||||
else secret.value = data.apiKey;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (ticket === secretRequestVersion && active) await search();
|
|
||||||
} finally {
|
} finally {
|
||||||
if (ticket === secretRequestVersion) secretBusy.value = false;
|
if (ticket === secretRequestVersion) legacyBusy.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const revealSecret = () => accessSecret('reveal');
|
|
||||||
const copySecret = () => accessSecret('copy');
|
|
||||||
const resetLegacy = async () => {
|
const resetLegacy = async () => {
|
||||||
if (secretBusy.value || !detailVisible.value || detail.value?.kind !== 'legacy') return;
|
if (legacyBusy.value || !detailVisible.value || detail.value?.kind !== 'legacy') return;
|
||||||
const ticket = ++secretRequestVersion;
|
const ticket = ++secretRequestVersion;
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(i18n.global.t('setting.apiKeyResetHelper'), i18n.global.t('setting.apiKeyReset'));
|
await ElMessageBox.confirm(i18n.global.t('setting.apiKeyResetHelper'), i18n.global.t('setting.apiKeyReset'));
|
||||||
@@ -401,18 +322,17 @@ const resetLegacy = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ticket !== secretRequestVersion || !detailVisible.value || !active) return;
|
if (ticket !== secretRequestVersion || !detailVisible.value || !active) return;
|
||||||
secretAction.value = 'reveal';
|
legacyBusy.value = true;
|
||||||
secretBusy.value = true;
|
legacySecret.value = '';
|
||||||
secret.value = '';
|
|
||||||
try {
|
try {
|
||||||
const response = await generateApiKey();
|
const response = await generateApiKey();
|
||||||
await search(ticket);
|
await search(ticket);
|
||||||
// A reset changes the revision and deliberately clears any older reveal.
|
// A reset changes the revision and deliberately clears any older reveal.
|
||||||
// Never restore a secret after the original details drawer was closed.
|
// Never restore a secret after the original details drawer was closed.
|
||||||
if (ticket === secretRequestVersion && detailVisible.value && active && detail.value?.kind === 'legacy')
|
if (ticket === secretRequestVersion && detailVisible.value && active && detail.value?.kind === 'legacy')
|
||||||
secret.value = response.data;
|
legacySecret.value = response.data;
|
||||||
} finally {
|
} finally {
|
||||||
if (ticket === secretRequestVersion) secretBusy.value = false;
|
if (ticket === secretRequestVersion) legacyBusy.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const activate = () => {
|
const activate = () => {
|
||||||
@@ -423,16 +343,9 @@ const activate = () => {
|
|||||||
const deactivate = () => {
|
const deactivate = () => {
|
||||||
active = false;
|
active = false;
|
||||||
searchVersion++;
|
searchVersion++;
|
||||||
clearSecret();
|
clearLegacySecret();
|
||||||
detailVisible.value = false;
|
detailVisible.value = false;
|
||||||
};
|
};
|
||||||
watch(
|
|
||||||
() => globalStore.isLogin,
|
|
||||||
(loggedIn) => {
|
|
||||||
if (!loggedIn) deactivate();
|
|
||||||
},
|
|
||||||
{ flush: 'sync' },
|
|
||||||
);
|
|
||||||
onMounted(activate);
|
onMounted(activate);
|
||||||
onActivated(activate);
|
onActivated(activate);
|
||||||
onDeactivated(deactivate);
|
onDeactivated(deactivate);
|
||||||
|
|||||||
Reference in New Issue
Block a user