mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-10-28 16:00:18 +00:00
feat(container): add image update checks with scheduling and task logs
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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,379 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"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 {
|
||||
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
|
||||
repositories map[string]*imageCheckUpdateRepository
|
||||
targets map[imageCheckUpdateTargetKey]imageCheckUpdateTargetResult
|
||||
}
|
||||
|
||||
func runContainerImageCheckUpdate(ctx context.Context, result *ImageCheckUpdateStatus, logger func(string)) error {
|
||||
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{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.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
|
||||
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) (imageCheckUpdateHubClient, error) {
|
||||
return dockerhub.New(ctx, repository, username, password)
|
||||
}
|
||||
@@ -152,6 +152,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))
|
||||
}
|
||||
|
||||
+5
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -800,3 +800,14 @@ ErrFeishuUpgradeBackup: "A full application backup is required for the Feishu co
|
||||
ErrFeishuUpgradeMounts: "These mounts cannot be fully backed up or restored. Use standard data mounts inside the application directory."
|
||||
|
||||
AgentFeishuRecoveryDisabled: "Gateway recovered with Feishu disabled; the original configuration backup is retained."
|
||||
|
||||
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"
|
||||
|
||||
@@ -800,3 +800,14 @@ ErrFeishuUpgradeBackup: "La actualización de compatibilidad de Feishu requiere
|
||||
ErrFeishuUpgradeMounts: "Estos montajes no permiten una copia y restauración completas. Use los montajes de datos estándar dentro del directorio de la aplicación."
|
||||
|
||||
AgentFeishuRecoveryDisabled: "Puerta de enlace recuperada con Feishu desactivado; se conserva la copia de configuración original."
|
||||
|
||||
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"
|
||||
|
||||
@@ -801,3 +801,14 @@ ErrFeishuUpgradeBackup: "ارتقای سازگاری Feishu به پشتیبان
|
||||
ErrFeishuUpgradeMounts: "این اتصالهای ذخیرهسازی کاملاً قابل پشتیبانگیری و بازیابی نیستند. از مسیرهای استاندارد داده در پوشه برنامه استفاده کنید."
|
||||
|
||||
AgentFeishuRecoveryDisabled: "دروازه بازیابی شد و Feishu غیرفعال است؛ پشتیبان تنظیمات اصلی حفظ شد."
|
||||
|
||||
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 }} کانتینر ناموفق بود؛ گزارش وظیفه را مشاهده کنید"
|
||||
|
||||
@@ -800,3 +800,14 @@ ErrFeishuUpgradeBackup: "Feishu 互換更新にはアプリ全体のバックア
|
||||
ErrFeishuUpgradeMounts: "現在のマウントは完全なバックアップ・復元に対応しません。アプリディレクトリ内の標準データマウントを使用してください。"
|
||||
|
||||
AgentFeishuRecoveryDisabled: "ゲートウェイを復旧しました。Feishu は無効のままで、元の設定のバックアップは保持しています。"
|
||||
|
||||
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 }} コンテナの確認に失敗しました。タスクログを確認してください"
|
||||
|
||||
@@ -800,3 +800,14 @@ ErrFeishuUpgradeBackup: "Feishu 호환 업그레이드에는 전체 앱 백업
|
||||
ErrFeishuUpgradeMounts: "현재 마운트는 전체 백업 및 복원을 지원하지 않습니다. 앱 디렉터리 내부의 표준 데이터 마운트를 사용하세요."
|
||||
|
||||
AgentFeishuRecoveryDisabled: "게이트웨이가 복구되었습니다. Feishu는 비활성 상태이며 원본 설정 백업은 유지됩니다."
|
||||
|
||||
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 }}개 확인에 실패했습니다. 작업 로그를 확인하세요"
|
||||
|
||||
@@ -791,3 +791,14 @@ ErrFeishuUpgradeBackup: "ການອັບເກຣດ Feishu ຕ້ອງສ
|
||||
ErrFeishuUpgradeMounts: "ຈຸດເຊື່ອມຂໍ້ມູນນີ້ບໍ່ສາມາດສຳຮອງແລະກູ້ຄືນທັງໝົດໄດ້. ໃຊ້ຈຸດເຊື່ອມມາດຕະຖານພາຍໃນໂຟນເດີແອັບ."
|
||||
|
||||
AgentFeishuRecoveryDisabled: "ເກດເວກູ້ຄືນແລ້ວ ແຕ່ Feishu ຍັງປິດ; ສຳຮອງການຕັ້ງຄ່າເດີມຍັງຖືກເກັບໄວ້."
|
||||
|
||||
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 }} ຄອນເທນເນີລົ້ມເຫຼວ; ກະລຸນາເບິ່ງບັນທຶກວຽກ"
|
||||
|
||||
@@ -800,3 +800,14 @@ ErrFeishuUpgradeBackup: "Naik taraf keserasian Feishu memerlukan sandaran aplika
|
||||
ErrFeishuUpgradeMounts: "Lekapan ini tidak boleh disandar dan dipulihkan sepenuhnya. Gunakan lekapan data standard dalam direktori aplikasi."
|
||||
|
||||
AgentFeishuRecoveryDisabled: "Gerbang dipulihkan dengan Feishu dinyahaktifkan; sandaran konfigurasi asal dikekalkan."
|
||||
|
||||
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"
|
||||
|
||||
@@ -800,3 +800,14 @@ ErrFeishuUpgradeBackup: "A atualização de compatibilidade do Feishu exige back
|
||||
ErrFeishuUpgradeMounts: "Estas montagens não permitem backup e restauração completos. Use montagens de dados padrão dentro do diretório do aplicativo."
|
||||
|
||||
AgentFeishuRecoveryDisabled: "Gateway recuperado com Feishu desativado; o backup da configuração original foi mantido."
|
||||
|
||||
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"
|
||||
|
||||
@@ -800,3 +800,14 @@ ErrFeishuUpgradeBackup: "Для обновления совместимости
|
||||
ErrFeishuUpgradeMounts: "Эти монтирования не поддерживают полное резервное копирование и восстановление. Используйте стандартные данные внутри каталога приложения."
|
||||
|
||||
AgentFeishuRecoveryDisabled: "Шлюз восстановлен, Feishu отключён; исходная резервная копия конфигурации сохранена."
|
||||
|
||||
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 }}. Подробности в журнале задачи"
|
||||
|
||||
@@ -800,3 +800,14 @@ ErrFeishuUpgradeBackup: "Feishu uyumluluk yükseltmesi tam uygulama yedeği gere
|
||||
ErrFeishuUpgradeMounts: "Bu bağlamalar tam yedeklenip geri yüklenemiyor. Uygulama dizinindeki standart veri bağlamalarını kullanın."
|
||||
|
||||
AgentFeishuRecoveryDisabled: "Ağ geçidi kurtarıldı, Feishu kapalı; özgün yapılandırma yedeği korundu."
|
||||
|
||||
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"
|
||||
|
||||
@@ -800,3 +800,14 @@ ErrFeishuUpgradeBackup: "飛書相容升級必須開啟完整應用程式備份
|
||||
ErrFeishuUpgradeMounts: "目前掛載無法完整備份或還原,請使用應用程式目錄內的標準資料掛載。"
|
||||
|
||||
AgentFeishuRecoveryDisabled: "閘道已恢復,飛書保持停用;原設定備份已保留。"
|
||||
|
||||
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 }} 個容器檢查失敗,請查看任務日誌"
|
||||
|
||||
@@ -800,3 +800,14 @@ ErrFeishuUpgradeBackup: "飞书兼容升级必须开启完整应用备份。"
|
||||
ErrFeishuUpgradeMounts: "当前挂载无法完整备份或恢复,请使用应用目录内的标准数据挂载后重试。"
|
||||
|
||||
AgentFeishuRecoveryDisabled: "网关已恢复,飞书保持停用;原配置备份已保留。"
|
||||
|
||||
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 }} 个容器检查失败,请查看任务日志"
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
// Package dockerhub checks Docker Hub metadata without downloading image layers.
|
||||
package dockerhub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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) (*Client, error) {
|
||||
c := &Client{HTTP: &http.Client{Timeout: 30 * time.Second}, 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 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.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
|
||||
}
|
||||
@@ -136,6 +136,9 @@ export namespace Container {
|
||||
mode: string;
|
||||
}
|
||||
export interface ContainerInfo {
|
||||
latestVersion?: string;
|
||||
targetImage?: string;
|
||||
hasUpdate?: boolean;
|
||||
containerID: string;
|
||||
name: string;
|
||||
imageName: string;
|
||||
|
||||
@@ -285,3 +285,33 @@ 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 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 } },
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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-';
|
||||
|
||||
@@ -2095,6 +2095,14 @@ const message = {
|
||||
missing: 'Missing',
|
||||
},
|
||||
container: {
|
||||
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',
|
||||
|
||||
@@ -2113,6 +2113,14 @@ const message = {
|
||||
missing: 'No existe',
|
||||
},
|
||||
container: {
|
||||
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',
|
||||
|
||||
@@ -2074,6 +2074,14 @@ const message = {
|
||||
missing: 'ناموجود',
|
||||
},
|
||||
container: {
|
||||
imageCheckUpdates: 'بررسی بهروزرسانی ایمیجها',
|
||||
imageCheckUpdateNow: 'بررسی اکنون',
|
||||
imageCheckUpdateSchedule: 'بررسی زمانبندیشده',
|
||||
imageCheckUpdateDisable: 'غیرفعالکردن بررسی زمانبندیشده',
|
||||
imageCheckUpdateStarted: 'در حال بررسی بهروزرسانی ایمیجهای همه کانتینرهای این گره',
|
||||
imageCheckUpdateResult:
|
||||
'بررسی پایان یافت: بررسیشده {checked}، قابل ارتقا {updates}، ناموفق {failed}، ردشده {skipped}',
|
||||
|
||||
createByCommand: 'ایجاد با دستور',
|
||||
importContainerBackupTip: 'واردات فایل پشتیبان کانتینر، فقط .tar.gz پشتیبانی میشود',
|
||||
importComposeBackupTip: 'واردات فایل پشتیبان compose، فقط .tar.gz پشتیبانی میشود',
|
||||
|
||||
@@ -2079,6 +2079,13 @@ const message = {
|
||||
missing: '存在しない',
|
||||
},
|
||||
container: {
|
||||
imageCheckUpdates: 'イメージの更新を確認',
|
||||
imageCheckUpdateNow: '今すぐ確認',
|
||||
imageCheckUpdateSchedule: '定期チェック',
|
||||
imageCheckUpdateDisable: '定期チェックを無効にする',
|
||||
imageCheckUpdateStarted: 'このノードのすべてのコンテナで使用されているイメージの更新を確認しています',
|
||||
imageCheckUpdateResult: '確認完了:成功 {checked}、更新可能 {updates}、失敗 {failed}、スキップ {skipped}',
|
||||
|
||||
createByCommand: 'コマンドで作成',
|
||||
importContainerBackupTip: 'コンテナバックアップファイルをインポートします。.tar.gz のみ対応です',
|
||||
importComposeBackupTip: 'Compose バックアップファイルをインポートします。.tar.gz のみ対応です',
|
||||
|
||||
@@ -2055,6 +2055,13 @@ const message = {
|
||||
missing: '없음',
|
||||
},
|
||||
container: {
|
||||
imageCheckUpdates: '이미지 업데이트 확인',
|
||||
imageCheckUpdateNow: '지금 확인',
|
||||
imageCheckUpdateSchedule: '정기 확인',
|
||||
imageCheckUpdateDisable: '정기 확인 끄기',
|
||||
imageCheckUpdateStarted: '현재 노드의 모든 컨테이너에서 사용하는 이미지의 업데이트를 확인하고 있습니다',
|
||||
imageCheckUpdateResult: '확인 완료: 성공 {checked}, 업데이트 가능 {updates}, 실패 {failed}, 건너뜀 {skipped}',
|
||||
|
||||
createByCommand: '명령으로 생성',
|
||||
importContainerBackupTip: '컨테이너 백업 파일 가져오기, .tar.gz 형식만 지원합니다',
|
||||
importComposeBackupTip: 'Compose 백업 파일 가져오기, .tar.gz 형식만 지원합니다',
|
||||
|
||||
@@ -2041,6 +2041,13 @@ const message = {
|
||||
missing: 'ບໍ່ມີ',
|
||||
},
|
||||
container: {
|
||||
imageCheckUpdates: 'ກວດສອບການອັບເດດອິມເມຈ',
|
||||
imageCheckUpdateNow: 'ກວດສອບຕອນນີ້',
|
||||
imageCheckUpdateSchedule: 'ກວດສອບຕາມຕາຕະລາງ',
|
||||
imageCheckUpdateDisable: 'ປິດການກວດສອບຕາມຕາຕະລາງ',
|
||||
imageCheckUpdateStarted: 'ກຳລັງກວດສອບການອັບເດດອິມເມຈຂອງທຸກຄອນເທນເນີໃນໂນດນີ້',
|
||||
imageCheckUpdateResult: 'ກວດສອບສຳເລັດ: ກວດແລ້ວ {checked}, ອັບເກຣດໄດ້ {updates}, ລົ້ມເຫຼວ {failed}, ຂ້າມ {skipped}',
|
||||
|
||||
createByCommand: 'ສ້າງດ້ວຍຄຳສັ່ງ',
|
||||
importContainerBackupTip: 'ນຳເຂົ້າໄຟລ໌ສຳຮອງຄອນເທນເນີ, ຮອງຮັບສະເພາະ .tar.gz',
|
||||
importComposeBackupTip: 'ນຳເຂົ້າໄຟລ໌ສຳຮອງ compose, ຮອງຮັບສະເພາະ .tar.gz',
|
||||
|
||||
@@ -2110,6 +2110,14 @@ const message = {
|
||||
missing: 'Tiada',
|
||||
},
|
||||
container: {
|
||||
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',
|
||||
|
||||
@@ -2112,6 +2112,14 @@ const message = {
|
||||
missing: 'Não existe',
|
||||
},
|
||||
container: {
|
||||
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',
|
||||
|
||||
@@ -2105,6 +2105,14 @@ const message = {
|
||||
missing: 'Отсутствует',
|
||||
},
|
||||
container: {
|
||||
imageCheckUpdates: 'Проверить обновления образов',
|
||||
imageCheckUpdateNow: 'Проверить сейчас',
|
||||
imageCheckUpdateSchedule: 'Проверка по расписанию',
|
||||
imageCheckUpdateDisable: 'Отключить проверку по расписанию',
|
||||
imageCheckUpdateStarted: 'Проверка обновлений образов всех контейнеров на текущем узле',
|
||||
imageCheckUpdateResult:
|
||||
'Проверка завершена: проверено {checked}, доступны обновления {updates}, ошибок {failed}, пропущено {skipped}',
|
||||
|
||||
createByCommand: 'Создать с помощью команды',
|
||||
importContainerBackupTip: 'Импорт файла резервной копии контейнера, поддерживается только .tar.gz',
|
||||
importComposeBackupTip: 'Импорт файла резервной копии compose, поддерживается только .tar.gz',
|
||||
|
||||
@@ -2110,6 +2110,14 @@ const message = {
|
||||
missing: 'Mevcut değil',
|
||||
},
|
||||
container: {
|
||||
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',
|
||||
|
||||
@@ -1979,6 +1979,13 @@ const message = {
|
||||
missing: '不存在',
|
||||
},
|
||||
container: {
|
||||
imageCheckUpdates: '檢查更新映像',
|
||||
imageCheckUpdateNow: '立即檢查',
|
||||
imageCheckUpdateSchedule: '定時檢查',
|
||||
imageCheckUpdateDisable: '關閉定時檢查',
|
||||
imageCheckUpdateStarted: '已開始檢查目前節點全部容器的映像更新',
|
||||
imageCheckUpdateResult: '檢查完成:成功 {checked},可升級 {updates},失敗 {failed},略過 {skipped}',
|
||||
|
||||
createByCommand: '指令建立',
|
||||
importContainerBackupTip: '匯入容器備份檔案,僅支援 .tar.gz 格式',
|
||||
importComposeBackupTip: '匯入編排備份檔案,僅支援 .tar.gz 格式',
|
||||
|
||||
@@ -1988,6 +1988,13 @@ const message = {
|
||||
missing: '不存在',
|
||||
},
|
||||
container: {
|
||||
imageCheckUpdates: '检查更新镜像',
|
||||
imageCheckUpdateNow: '立即检查',
|
||||
imageCheckUpdateSchedule: '定时检查',
|
||||
imageCheckUpdateDisable: '关闭定时检查',
|
||||
imageCheckUpdateStarted: '已开始检查当前节点全部容器的镜像更新',
|
||||
imageCheckUpdateResult: '检查完成:成功 {checked},可升级 {updates},失败 {failed},跳过 {skipped}',
|
||||
|
||||
createByCommand: '命令创建',
|
||||
importContainerBackupTip: '导入容器备份文件,仅支持 .tar.gz 格式',
|
||||
importComposeBackupTip: '导入编排备份文件,仅支持 .tar.gz 格式',
|
||||
|
||||
@@ -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" />
|
||||
@@ -471,8 +499,13 @@ 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 { ArrowDown } from '@element-plus/icons-vue';
|
||||
import {
|
||||
getContainerImageCheckUpdateStatus,
|
||||
runContainerImageCheckUpdate,
|
||||
setContainerImageCheckUpdateSchedule,
|
||||
type ContainerImageCheckUpdateStatus,
|
||||
containerItemStats,
|
||||
containerListStats,
|
||||
containerOperator,
|
||||
@@ -776,6 +809,105 @@ 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 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;
|
||||
@@ -989,8 +1121,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,
|
||||
targetImage: row.targetImage,
|
||||
hasUpdate: row.hasUpdate,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -99,15 +99,18 @@ interface DialogProps {
|
||||
container: string;
|
||||
image: string;
|
||||
fromApp: boolean;
|
||||
targetImage?: string;
|
||||
hasUpdate?: boolean;
|
||||
}
|
||||
const acceptParams = (props: DialogProps): void => {
|
||||
form.names = [props.container];
|
||||
isIndeterminate.value = true;
|
||||
form.oldImageName = props.image;
|
||||
form.fromApp = props.fromApp;
|
||||
form.forcePull = Boolean(props.hasUpdate);
|
||||
form.hasName = props.image.indexOf('sha256:') === -1;
|
||||
if (form.hasName) {
|
||||
form.newImageName = props.image;
|
||||
form.newImageName = props.hasUpdate && props.targetImage ? props.targetImage : props.image;
|
||||
} else {
|
||||
form.newImageName = '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user