Compare commits

...
51 changed files with 1511 additions and 45 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 {
ContainerID string `json:"containerID"`
Name string `json:"name"`
ImageId string `json:"imageID"`
ImageName string `json:"imageName"`
CreateTime string `json:"createTime"`
State string `json:"state"`
RunTime string `json:"runTime"`
LatestVersion string `json:"latestVersion"`
TargetImage string `json:"targetImage"`
HasUpdate bool `json:"hasUpdate"`
ContainerID string `json:"containerID"`
Name string `json:"name"`
ImageId string `json:"imageID"`
ImageName string `json:"imageName"`
CreateTime string `json:"createTime"`
State string `json:"state"`
RunTime string `json:"runTime"`
Network []string `json:"network"`
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" }
+23
View File
@@ -140,6 +140,29 @@ func (u *ContainerService) Page(ctx context.Context, req dto.PageContainer) (int
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++ {
install, _ := appInstallRepo.GetFirst(appInstallRepo.WithContainerName(backData[i].Name))
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)
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 {
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)
}
const (
containerUpgradePullTimeout = 30 * 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
}
+5 -1
View File
@@ -77,7 +77,11 @@ func Run() {
global.LOG.Errorf("generate random hour failed: %v", err)
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())
}
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."
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."
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 غیرفعال است؛ پشتیبان تنظیمات اصلی حفظ شد."
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 は無効のままで、元の設定のバックアップは保持しています。"
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는 비활성 상태이며 원본 설정 백업은 유지됩니다."
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 ຍັງປິດ; ສຳຮອງການຕັ້ງຄ່າເດີມຍັງຖືກເກັບໄວ້."
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."
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."
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 отключён; исходная резервная копия конфигурации сохранена."
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."
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: "閘道已恢復,飛書保持停用;原設定備份已保留。"
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: "网关已恢复,飞书保持停用;原配置备份已保留。"
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.AddLocalModelAccountLink,
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("/update", baseApi.ContainerUpdate)
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("/search", baseApi.SearchContainer)
baRouter.POST("/list", baseApi.ListContainer)
@@ -64,6 +67,7 @@ func (s *ContainerRouter) InitRouter(Router *gin.RouterGroup) {
baRouter.POST("/template", baseApi.CreateComposeTemplate)
baRouter.POST("/template/del", baseApi.DeleteComposeTemplate)
baRouter.GET("/image/remote/check", baseApi.GetImageRemoteCheckStatus)
baRouter.GET("/image", baseApi.ListImage)
baRouter.GET("/image/all", baseApi.ListAllImage)
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
}
+3
View File
@@ -136,6 +136,9 @@ export namespace Container {
mode: string;
}
export interface ContainerInfo {
latestVersion?: string;
targetImage?: string;
hasUpdate?: boolean;
containerID: string;
name: string;
imageName: string;
+40
View File
@@ -290,3 +290,43 @@ export const updateIpv6Option = (fixedCidrV6: string, ip6Tables: boolean, experi
export const updateDaemonJsonByfile = (params: Container.DaemonJsonUpdateByFile) => {
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 } },
);
+12 -4
View File
@@ -1,9 +1,9 @@
@font-face {
font-family: "iconfont"; /* Project id 4776196 */
src: url('iconfont.woff2?t=1786526991803') format('woff2'),
url('iconfont.woff?t=1786526991803') format('woff'),
url('iconfont.ttf?t=1786526991803') format('truetype'),
url('iconfont.svg?t=1786526991803#iconfont') format('svg');
src: url('iconfont.woff2?t=1793239167475') format('woff2'),
url('iconfont.woff?t=1793239167475') format('woff'),
url('iconfont.ttf?t=1793239167475') format('truetype'),
url('iconfont.svg?t=1793239167475#iconfont') format('svg');
}
.iconfont {
@@ -14,6 +14,14 @@
-moz-osx-font-smoothing: grayscale;
}
.p-huggingface1:before {
content: "\e656";
}
.p-modelscope:before {
content: "\e6be";
}
.p-shuju:before {
content: "\e634";
}
File diff suppressed because one or more lines are too long
@@ -5,6 +5,20 @@
"css_prefix_text": "p-",
"description": "",
"glyphs": [
{
"icon_id": "38704691",
"name": "huggingface",
"font_class": "huggingface1",
"unicode": "e656",
"unicode_decimal": 58966
},
{
"icon_id": "45461892",
"name": "modelscope",
"font_class": "modelscope",
"unicode": "e6be",
"unicode_decimal": 59070
},
{
"icon_id": "12903469",
"name": "本地数据",
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -13,14 +13,16 @@
@click.stop="handleButtonClick(button)"
>
<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>
</span>
<span v-if="actions.more.length" class="fu-table-operations__action">
<el-dropdown class="fu-table-more-button" :trigger="trigger" @command="handleButtonClick">
<span class="fu-table-operations__dropdown-trigger">
<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>
</span>
<template #dropdown>
@@ -33,7 +35,9 @@
:divided="button.divided"
>
<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-menu>
</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) => {
if (!isOperationDisabled(button, props.row)) {
button.click?.(props.row);
+1
View File
@@ -23,6 +23,7 @@ export interface FuTableOperationButton<Row = any> {
type?: string;
icon?: any;
divided?: boolean;
dot?: boolean | ((row: Row) => boolean);
}
export const FU_TABLE_STORAGE_PREFIX = 'FU-T-';
+15 -2
View File
@@ -1086,7 +1086,7 @@ const message = {
'Container address is for agent containers; 127.0.0.1, system IP, or custom address is for AI Gateway or external services.',
containerAddress: 'Container address',
customAddress: 'Custom address',
downloader: 'Downloader',
downloader: 'Downloads',
modelDir: 'Model directory',
modelSource: 'Model source',
repoID: 'Repository ID',
@@ -2116,6 +2116,19 @@ const message = {
batchUpgradeWaiting: 'Queued',
batchUpgradeRunning: 'Upgrading',
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',
importContainerBackupTip: 'Import container backup file, only .tar.gz is supported',
importComposeBackupTip: 'Import compose backup file, only .tar.gz is supported',
@@ -6992,7 +7005,7 @@ const message = {
'Search HuggingFace model repositories directly and view downloads, likes, model details, and file lists.',
modelDownloaderTitle2: 'Accelerated Downloads',
modelDownloaderContent2:
'Configure a HuggingFace acceleration endpoint and HF Token for the official site, mirrors, and private models.',
'Configure a HuggingFace acceleration endpoint and HuggingFace Token for the official site, mirrors, and private models.',
modelDownloaderTitle3: 'Task Tracking',
modelDownloaderContent3:
'Review download queues, task status, progress, and errors, with support for canceling, retrying, and clearing records.',
+15 -2
View File
@@ -1088,7 +1088,7 @@ const message = {
'La dirección del contenedor es para contenedores de agentes; 127.0.0.1, IP del sistema o dirección personalizada es para AI Gateway o servicios externos.',
containerAddress: 'Dirección del contenedor',
customAddress: 'Dirección personalizada',
downloader: 'Descargador',
downloader: 'Gestión de descargas',
modelDir: 'Directorio del modelo',
modelSource: 'Fuente del modelo',
repoID: 'ID del repositorio',
@@ -2135,6 +2135,19 @@ const message = {
batchUpgradeWaiting: 'Queued',
batchUpgradeRunning: 'Upgrading',
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',
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',
@@ -7093,7 +7106,7 @@ const message = {
'Busca repositorios de modelos de HuggingFace directamente y consulta descargas, me gusta, detalles y archivos.',
modelDownloaderTitle2: 'Descargas aceleradas',
modelDownloaderContent2:
'Configura un endpoint de aceleración de HuggingFace y HF Token para el sitio oficial, espejos y modelos privados.',
'Configura un endpoint de aceleración de HuggingFace y HuggingFace Token para el sitio oficial, espejos y modelos privados.',
modelDownloaderTitle3: 'Seguimiento de tareas',
modelDownloaderContent3:
'Consulta la cola, el estado, el progreso y los errores de descarga, con soporte para cancelar, reintentar y limpiar registros.',
+15 -2
View File
@@ -1071,7 +1071,7 @@ const message = {
'آدرس کانتینر برای کانتینرهای عامل است؛ 127.0.0.1، IP سیستم یا آدرس سفارشی برای دروازه AI یا خدمات خارجی است.',
containerAddress: 'آدرس کانتینر',
customAddress: 'آدرس سفارشی',
downloader: 'دانلودکننده',
downloader: 'مدیریت دانلودها',
modelDir: 'دایرکتوری مدل',
modelSource: 'منبع مدل',
repoID: 'شناسه مخزن',
@@ -2093,6 +2093,19 @@ const message = {
batchUpgradeWaiting: 'Queued',
batchUpgradeRunning: 'Upgrading',
batchUpgradeSubmitted: 'Upgrade task submitted',
imageCheckUpdateProxyRequired:
'برای بررسی به‌روزرسانی تصاویر در محیط فعلی، ابتدا یک سرور پروکسی پیکربندی کنید.',
imageCheckUpdateConfigureProxy: 'پیکربندی',
imageCheckUpdateAvailableVersion: 'نسخه قابل ارتقا «{version}»',
imageCheckUpdateContentChanged: '(محتوای تصویر تغییر کرده است)',
imageCheckUpdates: 'بررسی به‌روزرسانی ایمیج‌ها',
imageCheckUpdateNow: 'بررسی اکنون',
imageCheckUpdateSchedule: 'بررسی زمان‌بندی‌شده',
imageCheckUpdateDisable: 'غیرفعال‌کردن بررسی زمان‌بندی‌شده',
imageCheckUpdateStarted: 'در حال بررسی به‌روزرسانی ایمیج‌های همه کانتینرهای این گره',
imageCheckUpdateResult:
'بررسی پایان یافت: بررسی‌شده {checked}، قابل ارتقا {updates}، ناموفق {failed}، ردشده {skipped}',
createByCommand: 'ایجاد با دستور',
importContainerBackupTip: 'واردات فایل پشتیبان کانتینر، فقط .tar.gz پشتیبانی می‌شود',
importComposeBackupTip: 'واردات فایل پشتیبان compose، فقط .tar.gz پشتیبانی می‌شود',
@@ -6945,7 +6958,7 @@ const message = {
'مستقیماً مخازن مدل HuggingFace را جستجو کنید و دانلودها، لایک‌ها، جزئیات مدل و لیست فایل‌ها را مشاهده کنید.',
modelDownloaderTitle2: 'دانلود شتاب‌دهنده',
modelDownloaderContent2:
'یک نقطه پایانی شتاب HuggingFace و توکن HF را برای سایت رسمی، آینه‌ها و مدل‌های خصوصی پیکربندی کنید.',
'یک نقطه پایانی شتاب HuggingFace و توکن HuggingFace را برای سایت رسمی، آینه‌ها و مدل‌های خصوصی پیکربندی کنید.',
modelDownloaderTitle3: 'ردیابی وظایف',
modelDownloaderContent3:
'صف‌های دانلود، وضعیت وظایف، پیشرفت و خطاها را مرور کنید و از لغو، تلاش مجدد و پاک‌سازی رکوردها پشتیبانی کنید.',
+14 -2
View File
@@ -1075,7 +1075,7 @@ const message = {
'コンテナアドレスはエージェントコンテナ向けです。127.0.0.1、システム IP、カスタムアドレスは AI Gateway または外部サービス向けです。',
containerAddress: 'コンテナアドレス',
customAddress: 'カスタムアドレス',
downloader: 'ダウンローダー',
downloader: 'ダウンロード管理',
modelDir: 'モデルディレクトリ',
modelSource: 'モデルソース',
repoID: 'リポジトリ ID',
@@ -2100,6 +2100,18 @@ const message = {
batchUpgradeWaiting: 'Queued',
batchUpgradeRunning: 'Upgrading',
batchUpgradeSubmitted: 'Upgrade task submitted',
imageCheckUpdateProxyRequired:
'現在の実行環境でイメージの更新を確認するには、先にプロキシサーバーを設定してください。',
imageCheckUpdateConfigureProxy: '設定へ',
imageCheckUpdateAvailableVersion: '更新可能なバージョン「{version}」',
imageCheckUpdateContentChanged: '(イメージの内容が変更されています)',
imageCheckUpdates: 'イメージの更新を確認',
imageCheckUpdateNow: '今すぐ確認',
imageCheckUpdateSchedule: '定期チェック',
imageCheckUpdateDisable: '定期チェックを無効にする',
imageCheckUpdateStarted: 'このノードのすべてのコンテナで使用されているイメージの更新を確認しています',
imageCheckUpdateResult: '確認完了:成功 {checked}、更新可能 {updates}、失敗 {failed}、スキップ {skipped}',
createByCommand: 'コマンドで作成',
importContainerBackupTip: 'コンテナバックアップファイルをインポートします。.tar.gz のみ対応です',
importComposeBackupTip: 'Compose バックアップファイルをインポートします。.tar.gz のみ対応です',
@@ -6967,7 +6979,7 @@ const message = {
'HuggingFace のモデルリポジトリを直接検索し、ダウンロード数、いいね、モデル詳細、ファイル一覧を確認できます。',
modelDownloaderTitle2: '高速ダウンロード',
modelDownloaderContent2:
'HuggingFace アクセラレーションエンドポイントと HF Token を設定し、公式サイト、ミラー、プライベートモデルに対応します。',
'HuggingFace アクセラレーションエンドポイントと HuggingFace Token を設定し、公式サイト、ミラー、プライベートモデルに対応します。',
modelDownloaderTitle3: 'タスク追跡',
modelDownloaderContent3:
'ダウンロードキュー、タスク状態、進捗、エラーを一元確認し、キャンセル、再試行、記録の削除を行えます。',
+13 -2
View File
@@ -1064,7 +1064,7 @@ const message = {
'컨테이너 주소는 에이전트 컨테이너 호출에 적합합니다. 127.0.0.1, 시스템 IP 또는 사용자 지정 주소는 AI Gateway 또는 외부 서비스 호출에 적합합니다.',
containerAddress: '컨테이너 주소',
customAddress: '사용자 지정 주소',
downloader: '다운로더',
downloader: '다운로드 관리',
modelDir: '모델 디렉토리',
modelSource: '모델 소스',
repoID: '저장소 ID',
@@ -2075,6 +2075,17 @@ const message = {
batchUpgradeWaiting: 'Queued',
batchUpgradeRunning: 'Upgrading',
batchUpgradeSubmitted: 'Upgrade task submitted',
imageCheckUpdateProxyRequired: '현재 실행 환경에서 이미지 업데이트를 확인하려면 먼저 프록시 서버를 설정하세요.',
imageCheckUpdateConfigureProxy: '설정하기',
imageCheckUpdateAvailableVersion: '업그레이드 가능한 버전 “{version}”',
imageCheckUpdateContentChanged: '(이미지 내용 변경)',
imageCheckUpdates: '이미지 업데이트 확인',
imageCheckUpdateNow: '지금 확인',
imageCheckUpdateSchedule: '정기 확인',
imageCheckUpdateDisable: '정기 확인 끄기',
imageCheckUpdateStarted: '현재 노드의 모든 컨테이너에서 사용하는 이미지의 업데이트를 확인하고 있습니다',
imageCheckUpdateResult: '확인 완료: 성공 {checked}, 업데이트 가능 {updates}, 실패 {failed}, 건너뜀 {skipped}',
createByCommand: '명령으로 생성',
importContainerBackupTip: '컨테이너 백업 파일 가져오기, .tar.gz 형식만 지원합니다',
importComposeBackupTip: 'Compose 백업 파일 가져오기, .tar.gz 형식만 지원합니다',
@@ -6824,7 +6835,7 @@ const message = {
'HuggingFace 모델 저장소를 직접 검색하고 다운로드 수, 좋아요, 모델 상세 정보, 파일 목록을 확인합니다.',
modelDownloaderTitle2: '가속 다운로드',
modelDownloaderContent2:
'HuggingFace 가속 엔드포인트와 HF Token을 설정하여 공식 사이트, 미러, 비공개 모델에 대응합니다.',
'HuggingFace 가속 엔드포인트와 HuggingFace Token을 설정하여 공식 사이트, 미러, 비공개 모델에 대응합니다.',
modelDownloaderTitle3: '작업 추적',
modelDownloaderContent3:
'다운로드 대기열, 작업 상태, 진행률, 오류를 한곳에서 확인하고 취소, 재시도, 기록 정리를 지원합니다.',
+16 -2
View File
@@ -1060,7 +1060,7 @@ const message = {
'ທີ່ຢູ່ຄອນເທນເນີແມ່ນສຳລັບຄອນເທນເນີຕົວແທນ; 127.0.0.1, IP ລະບົບ, ຫຼື ທີ່ຢູ່ກຳນົດເອງແມ່ນສຳລັບ AI Gateway ຫຼື ບໍລິການພາຍນອກ.',
containerAddress: 'ທີ່ຢູ່ຄອນເທນເນີ',
customAddress: 'ທີ່ຢູ່ກຳນົດເອງ',
downloader: 'ຕົວດາວໂຫຼດ',
downloader: 'ຈັດການການດາວໂຫຼດ',
modelDir: 'ໄດເຣັກທໍຣີໂມເດວ',
modelSource: 'ແຫຼ່ງທີ່ມາໂມເດວ',
repoID: 'ID ຄັງເກັບ',
@@ -2060,6 +2060,19 @@ const message = {
batchUpgradeWaiting: 'Queued',
batchUpgradeRunning: 'Upgrading',
batchUpgradeSubmitted: 'Upgrade task submitted',
imageCheckUpdateProxyRequired:
'ເພື່ອກວດສອບການອັບເດດອິມເມຈໃນສະພາບແວດລ້ອມປັດຈຸບັນ ກະລຸນາຕັ້ງຄ່າພຣັອກຊີເຊີບເວີກ່ອນ.',
imageCheckUpdateConfigureProxy: 'ໄປຕັ້ງຄ່າ',
imageCheckUpdateAvailableVersion: 'ເວີຊັນທີ່ອັບເກຣດໄດ້ “{version}”',
imageCheckUpdateContentChanged: '(ເນື້ອຫາອິມເມຈປ່ຽນແປງ)',
imageCheckUpdates: 'ກວດສອບການອັບເດດອິມເມຈ',
imageCheckUpdateNow: 'ກວດສອບຕອນນີ້',
imageCheckUpdateSchedule: 'ກວດສອບຕາມຕາຕະລາງ',
imageCheckUpdateDisable: 'ປິດການກວດສອບຕາມຕາຕະລາງ',
imageCheckUpdateStarted: 'ກຳລັງກວດສອບການອັບເດດອິມເມຈຂອງທຸກຄອນເທນເນີໃນໂນດນີ້',
imageCheckUpdateResult:
'ກວດສອບສຳເລັດ: ກວດແລ້ວ {checked}, ອັບເກຣດໄດ້ {updates}, ລົ້ມເຫຼວ {failed}, ຂ້າມ {skipped}',
createByCommand: 'ສ້າງດ້ວຍຄຳສັ່ງ',
importContainerBackupTip: 'ນຳເຂົ້າໄຟລ໌ສຳຮອງຄອນເທນເນີ, ຮອງຮັບສະເພາະ .tar.gz',
importComposeBackupTip: 'ນຳເຂົ້າໄຟລ໌ສຳຮອງ compose, ຮອງຮັບສະເພາະ .tar.gz',
@@ -6767,7 +6780,8 @@ const message = {
modelDownloaderTitle1: 'ຄົ້ນຫາມໍເດລ',
modelDownloaderContent1: 'ຄົ້ນຫາຄັງມໍເດລ HuggingFace ໂດຍກົງ ແລະ ເບິ່ງຍອດດາວໂຫຼດ, ໄລ້ ແລະ ລາຍລະອຽດໄຟລ໌.',
modelDownloaderTitle2: 'ດາວໂຫຼດເລັ່ງດ່ວນ',
modelDownloaderContent2: 'ຕັ້ງຄ່າ HuggingFace acceleration endpoint ແລະ Token ສຳລັບມໍເດລສ່ວນຕົວ.',
modelDownloaderContent2:
'ຕັ້ງຄ່າ ຈຸດເຊື່ອມຕໍ່ເລັ່ງຄວາມໄວ HuggingFace ແລະ HuggingFace Token ສຳລັບມໍເດລສ່ວນຕົວ.',
modelDownloaderTitle3: 'ຕິດຕາມງານ',
modelDownloaderContent3: 'ກວດສອບຄິວດາວໂຫຼດ, ສະຖານະ, ຄວາມຄືບໜ້າ ແລະ ຂໍ້ຜິດພາດ.',
modelDownloaderTitle4: 'ຈັດການໃນເຄື່ອງ',
+15 -2
View File
@@ -1097,7 +1097,7 @@ const message = {
'Alamat kontena sesuai untuk kontena agen; 127.0.0.1, IP sistem, atau alamat tersuai sesuai untuk AI Gateway atau perkhidmatan luaran.',
containerAddress: 'Alamat kontena',
customAddress: 'Alamat tersuai',
downloader: 'Pemuat Turun',
downloader: 'Pengurusan muat turun',
modelDir: 'Direktori Model',
modelSource: 'Sumber model',
repoID: 'ID Repositori',
@@ -2131,6 +2131,19 @@ const message = {
batchUpgradeWaiting: 'Queued',
batchUpgradeRunning: 'Upgrading',
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',
importContainerBackupTip: 'Import fail sandaran kontena, hanya format .tar.gz disokong',
importComposeBackupTip: 'Import fail sandaran compose, hanya format .tar.gz disokong',
@@ -7074,7 +7087,7 @@ const message = {
'Cari repositori model HuggingFace secara langsung dan lihat muat turun, suka, butiran model dan senarai fail.',
modelDownloaderTitle2: 'Muat Turun Dipercepat',
modelDownloaderContent2:
'Konfigurasikan titik akhir pecutan HuggingFace dan HF Token untuk laman rasmi, cermin dan model persendirian.',
'Konfigurasikan titik akhir pecutan HuggingFace dan HuggingFace Token untuk laman rasmi, cermin dan model persendirian.',
modelDownloaderTitle3: 'Penjejakan Tugas',
modelDownloaderContent3:
'Semak baris gilir muat turun, status tugas, kemajuan dan ralat, dengan sokongan batal, cuba semula dan bersihkan rekod.',
+15 -2
View File
@@ -1092,7 +1092,7 @@ const message = {
'Use o endereço do contêiner para agentes; 127.0.0.1, IP do sistema ou endereço personalizado para AI Gateway ou serviços externos.',
containerAddress: 'Endereço do contêiner',
customAddress: 'Endereço personalizado',
downloader: 'Baixador',
downloader: 'Gerenciamento de downloads',
modelDir: 'Diretório do modelo',
modelSource: 'Fonte do modelo',
repoID: 'ID do repositório',
@@ -2134,6 +2134,19 @@ const message = {
batchUpgradeWaiting: 'Queued',
batchUpgradeRunning: 'Upgrading',
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',
importContainerBackupTip: 'Importar arquivo de backup de contêiner, apenas .tar.gz é suportado',
importComposeBackupTip: 'Importar arquivo de backup de compose, apenas .tar.gz é suportado',
@@ -7117,7 +7130,7 @@ const message = {
'Pesquise repositórios de modelos do HuggingFace diretamente e veja downloads, curtidas, detalhes do modelo e lista de arquivos.',
modelDownloaderTitle2: 'Downloads acelerados',
modelDownloaderContent2:
'Configure um endpoint de aceleração do HuggingFace e HF Token para o site oficial, espelhos e modelos privados.',
'Configure um endpoint de aceleração do HuggingFace e HuggingFace Token para o site oficial, espelhos e modelos privados.',
modelDownloaderTitle3: 'Acompanhamento de tarefas',
modelDownloaderContent3:
'Veja filas de download, status, progresso e erros, com suporte para cancelar, tentar novamente e limpar registros.',
+15 -2
View File
@@ -1085,7 +1085,7 @@ const message = {
'Адрес контейнера подходит для контейнеров агентов; 127.0.0.1, системный IP или пользовательский адрес подходит для AI Gateway или внешних сервисов.',
containerAddress: 'Адрес контейнера',
customAddress: 'Пользовательский адрес',
downloader: 'Загрузчик',
downloader: 'Управление загрузками',
modelDir: 'Каталог модели',
modelSource: 'Источник модели',
repoID: 'ID репозитория',
@@ -2127,6 +2127,19 @@ const message = {
batchUpgradeWaiting: 'Queued',
batchUpgradeRunning: 'Upgrading',
batchUpgradeSubmitted: 'Upgrade task submitted',
imageCheckUpdateProxyRequired:
'Для проверки обновлений образов в текущей среде сначала настройте прокси-сервер.',
imageCheckUpdateConfigureProxy: 'Настроить',
imageCheckUpdateAvailableVersion: 'Доступна версия «{version}»',
imageCheckUpdateContentChanged: '(содержимое образа изменилось)',
imageCheckUpdates: 'Проверить обновления образов',
imageCheckUpdateNow: 'Проверить сейчас',
imageCheckUpdateSchedule: 'Проверка по расписанию',
imageCheckUpdateDisable: 'Отключить проверку по расписанию',
imageCheckUpdateStarted: 'Проверка обновлений образов всех контейнеров на текущем узле',
imageCheckUpdateResult:
'Проверка завершена: проверено {checked}, доступны обновления {updates}, ошибок {failed}, пропущено {skipped}',
createByCommand: 'Создать с помощью команды',
importContainerBackupTip: 'Импорт файла резервной копии контейнера, поддерживается только .tar.gz',
importComposeBackupTip: 'Импорт файла резервной копии compose, поддерживается только .tar.gz',
@@ -7086,7 +7099,7 @@ const message = {
'Ищите репозитории моделей HuggingFace напрямую и просматривайте загрузки, лайки, сведения о модели и список файлов.',
modelDownloaderTitle2: 'Ускоренные загрузки',
modelDownloaderContent2:
'Настройте endpoint ускорения HuggingFace и HF Token для официального сайта, зеркал и приватных моделей.',
'Настройте endpoint ускорения HuggingFace и HuggingFace Token для официального сайта, зеркал и приватных моделей.',
modelDownloaderTitle3: 'Отслеживание задач',
modelDownloaderContent3:
'Просматривайте очередь загрузок, статус, прогресс и ошибки, отменяйте, повторяйте и очищайте записи.',
+15 -2
View File
@@ -1096,7 +1096,7 @@ const message = {
'Konteyner adresi ajan konteynerleri içindir; 127.0.0.1, sistem IP veya özel adres AI Gateway ya da harici servisler içindir.',
containerAddress: 'Konteyner adresi',
customAddress: 'Özel adres',
downloader: 'İndirici',
downloader: 'İndirme yönetimi',
modelDir: 'Model dizini',
modelSource: 'Model kaynağı',
repoID: 'Depo ID',
@@ -2132,6 +2132,19 @@ const message = {
batchUpgradeWaiting: 'Queued',
batchUpgradeRunning: 'Upgrading',
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',
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',
@@ -7094,7 +7107,7 @@ const message = {
'HuggingFace model depolarını doğrudan arayın; indirme sayısı, beğeni, model ayrıntıları ve dosya listesini görüntüleyin.',
modelDownloaderTitle2: 'Hızlandırılmış İndirme',
modelDownloaderContent2:
'Resmi site, aynalar ve özel modeller için HuggingFace hızlandırma endpointi ve HF Token yapılandırın.',
'Resmi site, aynalar ve özel modeller için HuggingFace hızlandırma endpointi ve HuggingFace Token yapılandırın.',
modelDownloaderTitle3: 'Görev Takibi',
modelDownloaderContent3:
'İndirme kuyruğunu, görev durumunu, ilerlemeyi ve hataları görüntüleyin; iptal, yeniden deneme ve kayıt temizlemeyi destekler.',
+14 -2
View File
@@ -1025,7 +1025,7 @@ const message = {
'容器地址適合智能體容器呼叫;127.0.0.1、本機 IP 或自訂地址適合 AI 閘道或外部服務呼叫。',
containerAddress: '容器地址',
customAddress: '自訂地址',
downloader: '下載器',
downloader: '下載管理',
modelDir: '模型目錄',
modelSource: '模型來源',
repoID: '倉庫 ID',
@@ -1997,6 +1997,17 @@ const message = {
batchUpgradeWaiting: '等待執行',
batchUpgradeRunning: '正在升級',
batchUpgradeSubmitted: '已提交升級任務',
imageCheckUpdateProxyRequired: '目前運行環境下,如需檢查映像是否更新,請先設定代理伺服器',
imageCheckUpdateConfigureProxy: '前往設定',
imageCheckUpdateAvailableVersion: '可升級版本「{version}」',
imageCheckUpdateContentChanged: '(映像內容變更)',
imageCheckUpdates: '檢查更新映像',
imageCheckUpdateNow: '立即檢查',
imageCheckUpdateSchedule: '定時檢查',
imageCheckUpdateDisable: '關閉定時檢查',
imageCheckUpdateStarted: '已開始檢查目前節點全部容器的映像更新',
imageCheckUpdateResult: '檢查完成:成功 {checked},可升級 {updates},失敗 {failed},略過 {skipped}',
createByCommand: '指令建立',
importContainerBackupTip: '匯入容器備份檔案,僅支援 .tar.gz 格式',
importComposeBackupTip: '匯入編排備份檔案,僅支援 .tar.gz 格式',
@@ -6484,7 +6495,8 @@ const message = {
modelDownloaderTitle1: '模型發現',
modelDownloaderContent1: '直接搜尋 HuggingFace 模型倉庫,檢視下載量、點讚數、模型詳情與檔案列表。',
modelDownloaderTitle2: '加速下載',
modelDownloaderContent2: '支援設定 HuggingFace 加速地址與 HF Token,適配官方站、鏡像站和私有模型存取。',
modelDownloaderContent2:
'支援設定 HuggingFace 加速地址與 HuggingFace Token,適配官方站、鏡像站和私有模型存取。',
modelDownloaderTitle3: '任務追蹤',
modelDownloaderContent3: '統一檢視下載佇列、任務狀態、進度、錯誤資訊,並支援取消、重試和記錄清理。',
modelDownloaderTitle4: '本地管理',
+14 -2
View File
@@ -1038,7 +1038,7 @@ const message = {
'容器地址适合智能体容器调用;127.0.0.1、本机 IP 或自定义地址适合 AI 网关或外部服务调用。',
containerAddress: '容器地址',
customAddress: '自定义地址',
downloader: '下载器',
downloader: '下载管理',
modelDir: '模型目录',
modelSource: '模型来源',
repoID: '仓库 ID',
@@ -2006,6 +2006,17 @@ const message = {
batchUpgradeWaiting: '等待执行',
batchUpgradeRunning: '正在升级',
batchUpgradeSubmitted: '已提交升级任务',
imageCheckUpdateProxyRequired: '当前运行环境下,如需检查镜像是否更新,请优先配置代理服务器',
imageCheckUpdateConfigureProxy: '去配置',
imageCheckUpdateAvailableVersion: '可升级版本 “{version}”',
imageCheckUpdateContentChanged: '(镜像内容变化)',
imageCheckUpdates: '检查更新镜像',
imageCheckUpdateNow: '立即检查',
imageCheckUpdateSchedule: '定时检查',
imageCheckUpdateDisable: '关闭定时检查',
imageCheckUpdateStarted: '已开始检查当前节点全部容器的镜像更新',
imageCheckUpdateResult: '检查完成:成功 {checked},可升级 {updates},失败 {failed},跳过 {skipped}',
createByCommand: '命令创建',
importContainerBackupTip: '导入容器备份文件,仅支持 .tar.gz 格式',
importComposeBackupTip: '导入编排备份文件,仅支持 .tar.gz 格式',
@@ -5442,7 +5453,8 @@ const message = {
modelDownloaderTitle1: '模型发现',
modelDownloaderContent1: '直接搜索 HuggingFace 模型仓库,查看下载量、点赞数、模型详情与文件列表。',
modelDownloaderTitle2: '加速下载',
modelDownloaderContent2: '支持配置 HuggingFace 加速地址与 HF Token,适配官方站、镜像站和私有模型访问。',
modelDownloaderContent2:
'支持配置 HuggingFace 加速地址与 HuggingFace Token,适配官方站、镜像站和私有模型访问。',
modelDownloaderTitle3: '任务追踪',
modelDownloaderContent3: '统一查看下载队列、任务状态、进度、错误信息,并支持取消、重试和记录清理。',
modelDownloaderTitle4: '本地管理',
@@ -31,6 +31,34 @@
<el-button v-permission type="primary" plain @click="onClean()">
{{ $t('container.containerPrune') }}
</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 #rightToolBar>
<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 ContainerLogDialog from '@/components/log/container-drawer/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 {
getContainerImageCheckUpdateStatus,
getImageRemoteCheckStatus,
runContainerImageCheckUpdate,
setContainerImageCheckUpdateSchedule,
type ContainerImageCheckUpdateStatus,
containerItemStats,
containerListStats,
containerOperator,
@@ -777,6 +812,125 @@ const loadContainers = async (background = false, column?: any) => {
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) => {
activeTag.value = item;
paginationConfig.state = activeTag.value;
@@ -994,8 +1148,15 @@ const buttons = [
{
label: i18n.global.t('commons.button.upgrade'),
permission: true,
dot: (row: Container.ContainerInfo) => Boolean(row.hasUpdate),
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)"
/>
<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>
<div v-if="group.state !== 'draft'" class="mb-3">
<el-tag :type="statusType(group)">{{ statusLabel(group) }}</el-tag>
@@ -100,6 +107,7 @@ import { GlobalStore } from '@/store';
interface UpgradeGroup {
currentImage: string;
targetImage: string;
availableVersion: string;
names: string[];
appNames: string[];
options: Container.ContainerOption[];
@@ -112,7 +120,14 @@ interface DialogProps {
container: string;
image: string;
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 isGroupEditable = (group: UpgradeGroup) => ['draft', 'failed'].includes(group.state);
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);
const containers = Array.isArray(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;
clearTimeout(timer);
generation++;
@@ -148,6 +171,7 @@ const acceptParams = async (props: DialogProps | Container.ContainerInfo[]) => {
group = {
currentImage: item.imageName,
targetImage: item.imageName.includes('sha256:') ? '' : item.imageName,
availableVersion: '',
names: [],
appNames: [],
options: [],
@@ -158,6 +182,7 @@ const acceptParams = async (props: DialogProps | Container.ContainerInfo[]) => {
};
groups.set(item.imageName, group);
}
if (item.hasUpdate && item.latestVersion) group.availableVersion = item.latestVersion;
if (!group.names.includes(item.name)) {
group.names.push(item.name);
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);
}
form.groups = [...groups.values()];
form.forcePull = false;
form.forcePull = containers.some((item) => item.hasUpdate);
started.value = false;
visible.value = true;
formRef.value?.clearValidate();