Compare commits

..
72 changed files with 2185 additions and 3250 deletions
@@ -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)
}
+16
View File
@@ -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)
}
+10 -7
View File
@@ -21,13 +21,16 @@ type InspectReq struct {
} }
type ContainerInfo struct { type ContainerInfo struct {
ContainerID string `json:"containerID"` LatestVersion string `json:"latestVersion"`
Name string `json:"name"` TargetImage string `json:"targetImage"`
ImageId string `json:"imageID"` HasUpdate bool `json:"hasUpdate"`
ImageName string `json:"imageName"` ContainerID string `json:"containerID"`
CreateTime string `json:"createTime"` Name string `json:"name"`
State string `json:"state"` ImageId string `json:"imageID"`
RunTime string `json:"runTime"` ImageName string `json:"imageName"`
CreateTime string `json:"createTime"`
State string `json:"state"`
RunTime string `json:"runTime"`
Network []string `json:"network"` Network []string `json:"network"`
Ports []string `json:"ports"` Ports []string `json:"ports"`
@@ -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" }
+10 -1
View File
@@ -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
+5 -32
View File
@@ -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
+23
View File
@@ -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)
}
+5 -1
View File
@@ -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
+29
View File
@@ -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
}
-2
View File
@@ -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
View File
@@ -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)
}
}
}
+12
View File
@@ -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."
+12
View File
@@ -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."
+12
View File
@@ -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: "برای بررسی به‌روزرسانی تصاویر در محیط فعلی، ابتدا یک سرور پروکسی پیکربندی کنید."
+12
View File
@@ -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: "現在の実行環境でイメージの更新を確認するには、先にプロキシサーバーを設定してください。"
+12
View File
@@ -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: "현재 실행 환경에서 이미지 업데이트를 확인하려면 먼저 프록시 서버를 설정하세요."
+12
View File
@@ -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: "ເພື່ອກວດສອບການອັບເດດອິມເມຈໃນສະພາບແວດລ້ອມປັດຈຸບັນ ກະລຸນາຕັ້ງຄ່າພຣັອກຊີເຊີບເວີກ່ອນ."
+12
View File
@@ -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."
+12
View File
@@ -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."
+12
View File
@@ -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: "Для проверки обновлений образов в текущей среде сначала настройте прокси-сервер."
+12
View File
@@ -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."
+12
View File
@@ -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: "目前運行環境下,如需檢查映像是否更新,請先設定代理伺服器"
+12
View File
@@ -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: "当前运行环境下,如需检查镜像是否更新,请优先配置代理服务器"
+1
View File
@@ -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
},
}
+4
View File
@@ -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)
+323
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+1 -16
View File
@@ -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{}
+3 -23
View File
@@ -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
View File
@@ -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"`
} }
-1
View File
@@ -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 {
+2 -2
View File
@@ -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")
} }
+9 -89
View File
@@ -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
+263 -2139
View File
File diff suppressed because it is too large Load Diff
+2 -5
View File
@@ -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
-1
View File
@@ -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{}) },
}
-4
View File
@@ -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
-2
View File
@@ -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)
-13
View File
@@ -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
-3
View File
@@ -30,7 +30,4 @@ export namespace APIKey {
id: string; id: string;
revision: number; revision: number;
} }
export interface Revealed extends Reference {
apiKey: string;
}
} }
+3
View File
@@ -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;
+1 -7
View File
@@ -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);
};
+40
View File
@@ -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>
<span>{{ button.label }}</span> <el-badge is-dot :hidden="!hasDot(button)">
<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>
<span>{{ button.label }}</span> <el-badge is-dot :hidden="!hasDot(button)">
<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);
+1
View File
@@ -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-';
+17 -5
View File
@@ -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',
+17 -5
View File
@@ -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',
+17 -5
View File
@@ -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 پشتیبانی می‌شود',
+16 -5
View File
@@ -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 のみ対応です',
+15 -5
View File
@@ -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 형식만 지원합니다',
+16 -5
View File
@@ -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',
+18 -6
View File
@@ -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',
+17 -5
View File
@@ -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',
+18 -5
View File
@@ -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',
+18 -6
View File
@@ -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',
+15 -5
View File
@@ -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 格式',
+15 -5
View File
@@ -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;
+14 -20
View File
@@ -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();
+1 -18
View File
@@ -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,
-407
View File
@@ -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>
+230 -33
View File
@@ -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));
+48 -135
View File
@@ -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,19 +239,15 @@ 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' ? 'apiKeyManagement.revokeConfirm'
: action === 'revoke' : entry.status === 'Enable'
? 'apiKeyManagement.revokeConfirm' ? 'apiKeyManagement.disableConfirm'
: entry.status === 'Enable' : 'apiKeyManagement.enableConfirm',
? 'apiKeyManagement.disableConfirm'
: 'apiKeyManagement.enableConfirm',
[entry.name], [entry.name],
), ),
i18n.global.t('apiKeyManagement.title'), i18n.global.t('apiKeyManagement.title'),
@@ -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) ? await revokeAPIKey(reference)
: action === 'revoke' : await setAPIKeyStatus({ ...reference, status: entry.status === 'Enable' ? 'Disable' : 'Enable' });
? await revokeAPIKey(reference) clearLegacySecret();
: await setAPIKeyStatus({ ...reference, status: entry.status === 'Enable' ? 'Disable' : 'Enable' });
clearSecret();
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);