feat(panel): 重写自升级流程并精简面板备份 (close #1674)

升级改为覆盖式:只替换程序文件,storage 原地不动,中断安全无需 fix;新增 WS 实时进度,前端等待重启自动刷新。面板备份改为仅打包核心文件(ace/panel.db/config.yml/cert/cli),排除统计/扫描大库,体积大幅下降。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
耗子
2026-07-03 02:12:52 +08:00
parent 65e2750102
commit 00a5acbc45
10 changed files with 452 additions and 235 deletions
+1 -1
View File
@@ -198,7 +198,7 @@ func initAce() (*app.Ace, error) {
aggregator := websitestat.NewAggregator()
websiteStatService := service.NewWebsiteStatService(settingRepo, websiteStatRepo, websiteRepo, aggregator)
http := route.NewHttp(config, userService, userPasskeyService, userTokenService, homeService, taskService, websiteService, projectService, databaseService, databaseServerService, databaseRedisService, databaseElasticsearchService, databaseUserService, backupService, backupStorageService, certService, certDNSService, certAccountService, appService, environmentService, environmentGoService, environmentJavaService, environmentNodejsService, environmentPHPService, environmentPythonService, environmentDotnetService, cronService, processService, safeService, firewallService, firewallScanService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, logService, monitorService, settingService, systemctlService, toolboxNetworkService, toolboxSystemService, toolboxBenchmarkService, toolboxSSHService, toolboxDiskService, toolboxLogService, toolboxMigrationService, webHookService, templateService, websiteStatService, loader)
wsService := service.NewWsService(locale, config, logger, sshRepo, settingRepo, certRepo)
wsService := service.NewWsService(locale, config, logger, sshRepo, settingRepo, certRepo, backupRepo, taskRepo)
ws := route.NewWs(wsService, toolboxMigrationService)
mux, err := bootstrap.NewRouter(locale, middlewares, http, ws)
if err != nil {
+1 -1
View File
@@ -31,5 +31,5 @@ type BackupRepo interface {
CutoffUpload(account uint, typ BackupType, name string, files []string) error
GetDefaultPath(typ BackupType) string
FixPanel() error
UpdatePanel(version, url, checksum string) error
UpdatePanel(version, url, checksum string, progress func(string)) error
}
+171 -153
View File
@@ -9,6 +9,7 @@ import (
"path/filepath"
"slices"
"strings"
"sync/atomic"
"time"
"github.com/leonelquinteros/gotext"
@@ -28,13 +29,14 @@ import (
)
type backupRepo struct {
hr string
t *gotext.Locale
conf *config.Config
db *gorm.DB
log *slog.Logger
setting biz.SettingRepo
website biz.WebsiteRepo
hr string
t *gotext.Locale
conf *config.Config
db *gorm.DB
log *slog.Logger
setting biz.SettingRepo
website biz.WebsiteRepo
updating atomic.Bool // 面板升级进行中标志,防止并发触发
}
func NewBackupRepo(t *gotext.Locale, conf *config.Config, db *gorm.DB, log *slog.Logger, setting biz.SettingRepo, website biz.WebsiteRepo) biz.BackupRepo {
@@ -178,26 +180,34 @@ func (r *backupRepo) Create(ctx context.Context, typ biz.BackupType, target stri
func (r *backupRepo) CreatePanel() error {
start := time.Now()
backup := filepath.Join(r.GetDefaultPath(biz.BackupTypePanel), fmt.Sprintf("panel_%s%s", time.Now().Format("20060102150405"), r.backupExt()))
backup := filepath.Join(r.GetDefaultPath(biz.BackupTypePanel), fmt.Sprintf("panel_%s.tar.xz", time.Now().Format("20060102150405")))
temp, err := os.MkdirTemp("", "ace-backup-*")
if err != nil {
return err
}
defer func(path string) { _ = os.RemoveAll(path) }(temp)
// 备份前 checkpoint 主库,尽量减少 -wal 中未落盘数据(热备份一致性)
_ = r.db.Exec("PRAGMA wal_checkpoint(TRUNCATE);").Error
if err = io.Cp(filepath.Join(app.Root, "panel"), temp); err != nil {
return err
}
if err = io.Cp("/usr/local/sbin/acepanel", temp); err != nil {
return err
// 只备份恢复面板运行所必需的核心文件;checkpoint 后 -wal 已并入 panel.db、
// -shm 为纯临时共享内存,均无需备份
var files []string
for _, f := range []string{
"panel/ace",
"panel/storage/panel.db",
"panel/storage/config.yml",
"panel/storage/cert.pem",
"panel/storage/cert.key",
} {
if io.Exists(filepath.Join(app.Root, f)) {
files = append(files, f)
}
}
_ = io.Chmod(temp, 0600)
if err = io.Compress(temp, nil, backup); err != nil {
// 两个 -C 把 panel 内的核心文件与 panel 外的 cli 二进制收进同一个包
if _, err := shell.Execf(
"tar -cJf '%s' -C '%s' %s -C /usr/local/sbin acepanel",
backup, app.Root, strings.Join(files, " "),
); err != nil {
return err
}
if err = io.Chmod(backup, 0600); err != nil {
if err := io.Chmod(backup, 0600); err != nil {
return err
}
@@ -1200,8 +1210,7 @@ func (r *backupRepo) FixPanel() error {
// 检查关键文件是否正常
panelBroken := !io.Exists(filepath.Join(app.Root, "panel", "ace")) ||
!io.Exists(filepath.Join(app.Root, "panel", "storage", "config.yml")) ||
!io.Exists(filepath.Join(app.Root, "panel", "storage", "panel.db")) ||
io.Exists("/tmp/panel-storage.zip")
!io.Exists(filepath.Join(app.Root, "panel", "storage", "panel.db"))
// 检查主数据库连接
if err := r.db.Exec("VACUUM").Error; err != nil {
panelBroken = true
@@ -1251,22 +1260,6 @@ func (r *backupRepo) FixPanel() error {
return nil
}
// 再次确认是否需要修复
if io.Exists("/tmp/panel-storage.zip") {
// 文件齐全情况下只移除临时文件
if io.Exists(filepath.Join(app.Root, "panel", "ace")) &&
io.Exists(filepath.Join(app.Root, "panel", "storage", "config.yml")) &&
io.Exists(filepath.Join(app.Root, "panel", "storage", "panel.db")) {
if err := io.Remove("/tmp/panel-storage.zip"); err != nil {
return errors.New(r.t.Get("failed to clean temporary files: %v", err))
}
if app.IsCli {
fmt.Println(r.t.Get("|-Cleaned up temporary files, please run acepanel update to update the panel"))
}
return nil
}
}
// 从备份目录中找最新的备份文件
files, err := os.ReadDir(r.GetDefaultPath(biz.BackupTypePanel))
if err != nil {
@@ -1324,19 +1317,6 @@ func (r *backupRepo) FixPanel() error {
}
}
// tmp 目录下如果有 storage 备份,则解压回去
if app.IsCli {
fmt.Println(r.t.Get("|-Restore panel data..."))
}
if io.Exists("/tmp/panel-storage.zip") {
if err = io.UnCompress("/tmp/panel-storage.zip", filepath.Join(app.Root, "panel")); err != nil {
return errors.New(r.t.Get("Unzip panel data failed: %v", err))
}
if err = io.Remove("/tmp/panel-storage.zip"); err != nil {
return errors.New(r.t.Get("Cleaning temporary file failed: %v", err))
}
}
// 下载服务文件
if !io.Exists("/etc/systemd/system/acepanel.service") {
if _, err = shell.Execf(`wget -O /etc/systemd/system/acepanel.service https://%s/acepanel.service && sed -i "s|/opt/ace|%s|g" /etc/systemd/system/acepanel.service`, r.conf.App.DownloadEndpoint, app.Root); err != nil {
@@ -1376,132 +1356,170 @@ func (r *backupRepo) FixPanel() error {
return nil
}
func (r *backupRepo) UpdatePanel(version, url, checksum string) error {
// 预先优化数据库
if err := r.db.Exec("VACUUM").Error; err != nil {
// UpdatePanel 升级面板
func (r *backupRepo) UpdatePanel(version, url, checksum string, progress func(string)) error {
if progress == nil {
progress = func(string) {}
}
// 进程级升级锁
if !r.updating.CompareAndSwap(false, true) {
return errors.New(r.t.Get("panel is already updating, please try again later"))
}
defer r.updating.Store(false)
panelDir := filepath.Join(app.Root, "panel")
workDir := filepath.Join(panelDir, ".update-work") // staging 目录固定在 panel 内,绝不用 /tmp(可能跨分区)
newDir := filepath.Join(workDir, "new")
name := filepath.Base(url)
// 失败回滚
rollback := func(err error) error {
_ = io.Remove(workDir)
app.Status = app.StatusNormal
return err
}
app.Status = app.StatusUpgrade
progress(r.t.Get("Preparing to update to %s...", version))
if err := io.Remove(workDir); err != nil {
return rollback(errors.New(r.t.Get("Failed to clean up temporary directory: %v", err)))
}
if err := r.db.Exec("PRAGMA wal_checkpoint(TRUNCATE);").Error; err != nil {
return err
return rollback(errors.New(r.t.Get("Failed to optimize database: %v", err)))
}
name := filepath.Base(url)
if app.IsCli {
fmt.Println(r.t.Get("|-Target version: %s", version))
fmt.Println(r.t.Get("|-Download link: %s", url))
fmt.Println(r.t.Get("|-File name: %s", name))
}
if app.IsCli {
fmt.Println(r.t.Get("|-Downloading..."))
}
if _, err := shell.Execf("aria2c -c --file-allocation=falloc --allow-overwrite=true --auto-file-renaming=false --retry-wait=5 --max-tries=5 -x 16 -s 16 -k 1M -d /tmp -o %s %s", name, url); err != nil {
return errors.New(r.t.Get("Download failed: %v", err))
}
if _, err := shell.Execf("aria2c -c --file-allocation=falloc --allow-overwrite=true --auto-file-renaming=false --retry-wait=5 --max-tries=5 -x 1 -s 1 -k 1M -d /tmp -o %s %s", name+".sha256", checksum); err != nil {
return errors.New(r.t.Get("Download failed: %v", err))
}
if !io.Exists(filepath.Join("/tmp", name)) || !io.Exists(filepath.Join("/tmp", name+".sha256")) {
return errors.New(r.t.Get("Download file check failed"))
}
if app.IsCli {
fmt.Println(r.t.Get("|-Verify download file..."))
}
if check, err := shell.Execf("cd /tmp && sha256sum -c %s --ignore-missing", name+".sha256"); check != name+": OK" || err != nil {
return errors.New(r.t.Get("Verify download file failed: %v", err))
}
if err := io.Remove(filepath.Join("/tmp", name+".sha256")); err != nil {
return errors.New(r.t.Get("|-Clean up verification file failed: %v", err))
}
if io.Exists("/tmp/panel-storage.zip") {
return errors.New(r.t.Get("Temporary file detected in /tmp, this may be caused by the last update failure, please run acepanel fix to repair and try again"))
}
if app.IsCli {
fmt.Println(r.t.Get("|-Backup panel data..."))
}
// 备份面板
// 备份留档
progress(r.t.Get("Backing up panel data..."))
if err := r.CreatePanel(); err != nil {
return errors.New(r.t.Get("|-Backup panel data failed: %v", err))
}
if err := io.Compress(filepath.Join(app.Root, "panel/storage"), nil, "/tmp/panel-storage.zip"); err != nil {
return errors.New(r.t.Get("|-Backup panel data failed: %v", err))
}
if !io.Exists("/tmp/panel-storage.zip") {
return errors.New(r.t.Get("|-Backup panel data failed, missing file"))
r.log.Warn("failed to backup panel before update", slog.Any("err", err))
}
if app.IsCli {
fmt.Println(r.t.Get("|-Cleaning old version..."))
// 下载新版本
if err := os.MkdirAll(workDir, 0700); err != nil {
return rollback(errors.New(r.t.Get("Failed to create temporary directory: %v", err)))
}
if _, err := shell.Execf("rm -rf %s/panel/*", app.Root); err != nil {
return errors.New(r.t.Get("|-Cleaning old version failed: %v", err))
progress(r.t.Get("Downloading new version..."))
if _, err := shell.Execf("aria2c -c --file-allocation=falloc --allow-overwrite=true --auto-file-renaming=false --retry-wait=5 --max-tries=5 -x 16 -s 16 -k 1M -d %s -o %s %s", workDir, name, url); err != nil {
return rollback(errors.New(r.t.Get("Download failed: %v", err)))
}
if _, err := shell.Execf("aria2c -c --file-allocation=falloc --allow-overwrite=true --auto-file-renaming=false --retry-wait=5 --max-tries=5 -x 1 -s 1 -k 1M -d %s -o %s %s", workDir, name+".sha256", checksum); err != nil {
return rollback(errors.New(r.t.Get("Download failed: %v", err)))
}
if !io.Exists(filepath.Join(workDir, name)) || !io.Exists(filepath.Join(workDir, name+".sha256")) {
return rollback(errors.New(r.t.Get("Download file check failed")))
}
if app.IsCli {
fmt.Println(r.t.Get("|-Unzip new version..."))
}
if err := io.UnCompress(filepath.Join("/tmp", name), filepath.Join(app.Root, "panel")); err != nil {
return errors.New(r.t.Get("|-Unzip new version failed: %v", err))
}
if !io.Exists(filepath.Join(app.Root, "panel", "ace")) {
return errors.New(r.t.Get("|-Unzip new version failed, missing file"))
}
if err := io.Remove(filepath.Join("/tmp", name)); err != nil {
return errors.New(r.t.Get("|-Clean up temporary file failed: %v", err))
// 校验 sha256
progress(r.t.Get("Verifying download file..."))
if check, err := shell.ExecfWithDir(workDir, "sha256sum -c %s --ignore-missing", name+".sha256"); check != name+": OK" || err != nil {
return rollback(errors.New(r.t.Get("Verify download file failed: %v", err)))
}
if app.IsCli {
fmt.Println(r.t.Get("|-Restore panel data..."))
// 解压
progress(r.t.Get("Extracting new version..."))
if err := io.UnCompress(filepath.Join(workDir, name), newDir); err != nil {
return rollback(errors.New(r.t.Get("Unzip new version failed: %v", err)))
}
if err := io.UnCompress("/tmp/panel-storage.zip", filepath.Join(app.Root, "panel", "storage")); err != nil {
return errors.New(r.t.Get("|-Restore panel data failed: %v", err))
}
if !io.Exists(filepath.Join(app.Root, "panel/storage/panel.db")) {
return errors.New(r.t.Get("|-Restore panel data failed, missing file"))
if !io.Exists(filepath.Join(newDir, "ace")) {
return rollback(errors.New(r.t.Get("Unzip new version failed, missing file")))
}
if app.IsCli {
fmt.Println(r.t.Get("|-Run post-update script..."))
}
if _, err := shell.Execf("curl -sSLm 10 https://%s/auto_update.sh | bash", r.conf.App.DownloadEndpoint); err != nil {
return errors.New(r.t.Get("|-Run post-update script failed: %v", err))
}
if _, err := shell.Execf(
`wget -O /etc/systemd/system/acepanel.service https://%s/acepanel.service && sed -i "s|/www|%s|g" /etc/systemd/system/acepanel.service`,
r.conf.App.DownloadEndpoint, app.Root,
); err != nil {
return errors.New(r.t.Get("|-Download panel service file failed: %v", err))
}
if _, err := shell.Execf("acepanel setting write version %s", version); err != nil {
return errors.New(r.t.Get("|-Write new panel version failed: %v", err))
}
if err := io.Mv(filepath.Join(app.Root, "panel/cli"), "/usr/local/sbin/acepanel"); err != nil {
return errors.New(r.t.Get("|-Move acepanel tool failed: %v", err))
// 应用
progress(r.t.Get("Applying update..."))
if err := r.applyUpdate(newDir); err != nil {
return rollback(errors.New(r.t.Get("Applying update failed: %v", err)))
}
if app.IsCli {
fmt.Println(r.t.Get("|-Set key file permissions..."))
}
_ = io.Chmod("/usr/local/sbin/acepanel", 0700)
_ = io.Chmod("/etc/systemd/system/acepanel.service", 0644)
_ = io.Chmod(filepath.Join(app.Root, "panel"), 0700)
if app.IsCli {
fmt.Println(r.t.Get("|-Update completed"))
// 收尾
progress(r.t.Get("Finishing up..."))
if err := r.finishUpdate(version); err != nil {
return rollback(errors.New(r.t.Get("Finishing update failed: %v", err)))
}
_ = io.Remove(workDir)
r.log.Info("panel updated", slog.String("version", version))
progress(r.t.Get("Update completed"))
_, _ = shell.Execf("systemctl daemon-reload")
_ = io.Remove("/tmp/panel-storage.zip")
_ = io.Remove(filepath.Join(app.Root, "panel/config.example.yml"))
// 由调用方重启面板
if sqlDB, err := r.db.DB(); err == nil {
_ = sqlDB.Close()
}
tools.RestartPanel()
return nil
}
// applyUpdate 用 newDir 下的新版本文件替换 panel/ 中的程序文件
func (r *backupRepo) applyUpdate(newDir string) error {
entries, err := os.ReadDir(newDir)
if err != nil {
return err
}
panelDir := filepath.Join(app.Root, "panel")
for _, e := range entries {
name := e.Name()
if name == "storage" {
continue
}
src := filepath.Join(newDir, name)
if name == "cli" {
// 先 cp 保证原子替换
tmp := "/usr/local/sbin/.acepanel.new"
if err = io.Cp(src, tmp); err != nil {
return err
}
if err = io.Mv(tmp, "/usr/local/sbin/acepanel"); err != nil {
return err
}
continue
}
// 其余程序文件
if err = io.Mv(src, filepath.Join(panelDir, name)); err != nil {
return err
}
}
return nil
}
// finishUpdate 升级收尾
func (r *backupRepo) finishUpdate(version string) error {
panelDir := filepath.Join(app.Root, "panel")
serviceFile := "/etc/systemd/system/acepanel.service"
// 更新 service 文件
tmpService := serviceFile + ".new"
if _, err := shell.Execf(`wget -O %s https://%s/acepanel.service`, tmpService, r.conf.App.DownloadEndpoint); err == nil {
_, _ = shell.Execf(`sed -i "s|/opt/ace|%s|g" %s`, app.Root, tmpService)
if out, _ := shell.Execf("grep -c ExecStart %s", tmpService); strings.TrimSpace(out) != "0" {
_ = io.Mv(tmpService, serviceFile) // 同在 /etc/systemd/system → 同分区 rename
}
}
_ = io.Remove(tmpService)
if !io.Exists(serviceFile) {
return errors.New(r.t.Get("panel service file is missing"))
}
// 校验 unit 指向的主程序确实存在,避免 daemon-reload 后起不来
if !io.Exists(filepath.Join(panelDir, "ace")) {
return errors.New(r.t.Get("panel binary is missing after update"))
}
_, _ = shell.Execf("systemctl daemon-reload")
// 执行后置脚本
_, _ = shell.Execf("curl -sSLm 10 --fail --retry 3 https://%s/auto_update.sh | bash", r.conf.App.DownloadEndpoint)
// 后置脚本之后再写版本号
if err := r.setting.Set(biz.SettingKeyVersion, version); err != nil {
return err
}
// 设置权限
_ = io.Chmod(filepath.Join(panelDir, "ace"), 0700)
_ = io.Chmod("/usr/local/sbin/acepanel", 0700)
_ = io.Chmod(serviceFile, 0644)
_ = io.Remove(filepath.Join(panelDir, "config.example.yml"))
// 修正可能从 staging 继承的错误 SELinux 上下文
_, _ = shell.Execf("restorecon %s /usr/local/sbin/acepanel %s", filepath.Join(panelDir, "ace"), serviceFile)
return nil
}
+7 -2
View File
@@ -20,6 +20,7 @@ import (
"github.com/acepanel/panel/v3/pkg/api"
"github.com/acepanel/panel/v3/pkg/config"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/tools"
)
// PanelTask 面板每日任务
@@ -170,10 +171,14 @@ func (r *PanelTask) updatePanel() {
if download := collect.First(panel.Downloads); download != nil {
url := fmt.Sprintf("https://%s%s", r.conf.App.DownloadEndpoint, download.URL)
checksum := fmt.Sprintf("https://%s%s", r.conf.App.DownloadEndpoint, download.Checksum)
if err = r.backupRepo.UpdatePanel(panel.Version, url, checksum); err != nil {
if err = r.backupRepo.UpdatePanel(panel.Version, url, checksum, func(msg string) {
r.log.Info("panel updating", slog.String("type", biz.OperationTypePanel), slog.String("msg", msg))
}); err != nil {
r.log.Warn("failed to update panel", slog.String("type", biz.OperationTypePanel), slog.Uint64("operator_id", 0), slog.Any("err", err))
_ = r.backupRepo.FixPanel()
return
}
// 新流程非破坏性、storage 原地不动,失败无需 FixPanel;成功后由本入口负责重启
tools.RestartPanel()
}
})
}
+1
View File
@@ -29,5 +29,6 @@ func (route *Ws) Register(r *chi.Mux) {
r.Get("/migration/progress", route.toolboxMigration.Progress)
r.Get("/cert/obtain", route.ws.CertObtain)
r.Get("/cert/renew", route.ws.CertRenew)
r.Get("/panel/update", route.ws.PanelUpdate)
})
}
+7 -1
View File
@@ -133,7 +133,13 @@ func (s *CliService) Update(ctx context.Context, cmd *cli.Command) error {
url := fmt.Sprintf("https://%s%s", s.conf.App.DownloadEndpoint, download.URL)
checksum := fmt.Sprintf("https://%s%s", s.conf.App.DownloadEndpoint, download.Checksum)
return s.backupRepo.UpdatePanel(panel.Version, url, checksum)
if err = s.backupRepo.UpdatePanel(panel.Version, url, checksum, func(msg string) {
fmt.Println("|-" + msg)
}); err != nil {
return err
}
tools.RestartPanel()
return nil
}
func (s *CliService) Sync(ctx context.Context, cmd *cli.Command) error {
+2 -4
View File
@@ -388,14 +388,12 @@ func (s *HomeService) Update(w http.ResponseWriter, r *http.Request) {
url := fmt.Sprintf("https://%s%s", s.conf.App.DownloadEndpoint, download.URL)
checksum := fmt.Sprintf("https://%s%s", s.conf.App.DownloadEndpoint, download.Checksum)
app.Status = app.StatusUpgrade
if err = s.backupRepo.UpdatePanel(panel.Version, url, checksum); err != nil {
app.Status = app.StatusFailed
// UpdatePanel 内部管理升级锁与 app.Status(失败自动恢复 Normal,不锁死 UI)
if err = s.backupRepo.UpdatePanel(panel.Version, url, checksum, nil); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
app.Status = app.StatusNormal
Success(w, nil)
tools.RestartPanel()
}
+65 -1
View File
@@ -14,35 +14,45 @@ import (
"github.com/coder/websocket"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/utils/collect"
"github.com/moby/moby/api/types/registry"
"github.com/moby/moby/client"
stdssh "golang.org/x/crypto/ssh"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/biz"
"github.com/acepanel/panel/v3/internal/http/request"
"github.com/acepanel/panel/v3/pkg/api"
"github.com/acepanel/panel/v3/pkg/config"
"github.com/acepanel/panel/v3/pkg/docker"
"github.com/acepanel/panel/v3/pkg/shell"
"github.com/acepanel/panel/v3/pkg/ssh"
"github.com/acepanel/panel/v3/pkg/tools"
)
type WsService struct {
t *gotext.Locale
conf *config.Config
log *slog.Logger
api *api.API
sshRepo biz.SSHRepo
settingRepo biz.SettingRepo
certRepo biz.CertRepo
backupRepo biz.BackupRepo
taskRepo biz.TaskRepo
}
func NewWsService(t *gotext.Locale, conf *config.Config, log *slog.Logger, ssh biz.SSHRepo, settingRepo biz.SettingRepo, certRepo biz.CertRepo) *WsService {
func NewWsService(t *gotext.Locale, conf *config.Config, log *slog.Logger, ssh biz.SSHRepo, settingRepo biz.SettingRepo, certRepo biz.CertRepo, backup biz.BackupRepo, task biz.TaskRepo) *WsService {
return &WsService{
t: t,
conf: conf,
log: log,
api: api.NewAPI(app.Version, app.Locale),
sshRepo: ssh,
settingRepo: settingRepo,
certRepo: certRepo,
backupRepo: backup,
taskRepo: task,
}
}
@@ -388,6 +398,60 @@ func (s *WsService) CertRenew(w http.ResponseWriter, r *http.Request) {
})
}
// PanelUpdate 通过 WebSocket 升级面板并实时推送进度
func (s *WsService) PanelUpdate(w http.ResponseWriter, r *http.Request) {
// 前置检查在建连前完成(此时 app.Status 仍为 Normal,握手可过状态中间件)
if offline, _ := s.settingRepo.GetBool(biz.SettingKeyOfflineMode); offline {
Error(w, http.StatusForbidden, s.t.Get("unable to update in offline mode"))
return
}
if s.taskRepo.HasRunningTask() {
Error(w, http.StatusInternalServerError, s.t.Get("background task is running, updating is prohibited, please try again later"))
return
}
channel, _ := s.settingRepo.Get(biz.SettingKeyChannel)
panel, err := s.api.LatestVersion(channel)
if err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to get the latest version: %v", err))
return
}
download := collect.First(panel.Downloads)
if download == nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to get the latest version download link"))
return
}
url := fmt.Sprintf("https://%s%s", s.conf.App.DownloadEndpoint, download.URL)
checksum := fmt.Sprintf("https://%s%s", s.conf.App.DownloadEndpoint, download.Checksum)
ws, err := s.upgrade(w, r)
if err != nil {
s.log.Warn("upgrade panel update ws error", slog.Any("err", err))
return
}
defer func(ws *websocket.Conn) { _ = ws.CloseNow() }(ws)
// 写入用带超时的独立 context:与请求/WS 生命周期解耦,
// 用户关闭页面导致连接断开也不会中断升级(升级内部 shell 执行不带 ctx)
write := func(status, msg string) {
data, _ := json.Marshal(map[string]any{"status": status, "msg": msg})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = ws.Write(ctx, websocket.MessageText, data)
}
if err = s.backupRepo.UpdatePanel(panel.Version, url, checksum, func(msg string) { write("progress", msg) }); err != nil {
write("error", err.Error())
_ = ws.Close(websocket.StatusNormalClosure, "")
return
}
write("success", "success")
_ = ws.Close(websocket.StatusNormalClosure, "")
// 升级成功,由本入口负责重启面板(唯一一次重启)
tools.RestartPanel()
}
// handleCertWs 证书操作的公共 WebSocket 处理逻辑
func (s *WsService) handleCertWs(w http.ResponseWriter, r *http.Request, action string, fn func(ctx context.Context, id uint, cb func(string)) error) {
ws, err := s.upgrade(w, r)
+8
View File
@@ -100,4 +100,12 @@ export default {
ws.onerror = (e) => reject(e)
})
},
// 面板升级进度
panelUpdate: (): Promise<WebSocket> => {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`${base}/panel/update`)
ws.onopen = () => resolve(ws)
ws.onerror = (e) => reject(e)
})
},
}
+189 -72
View File
@@ -3,24 +3,103 @@ defineOptions({
name: 'home-update',
})
import type { MessageReactive } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
import home from '@/api/panel/home'
import { router } from '@/router'
import { formatDateTime } from '@/utils'
import ws from '@/api/ws'
import { formatDateTime, http } from '@/utils'
const { $gettext } = useGettext()
const { data: versions } = useRequest(home.updateInfo, {
initialData: [],
})
const { data: versions } = useRequest(home.updateInfo, { initialData: [] })
const { data: systemInfo } = useRequest(home.systemInfo)
let messageReactive: MessageReactive | null = null
const updateLoading = ref(false)
const updating = ref(false)
const progressLogs = ref<string[]>([])
const errorMsg = ref('')
const waitingRestart = ref(false)
const restartTimedOut = ref(false)
let currentWs: WebSocket | null = null
// 解析描述文本为列表项
const parseDescription = (text: string): string[] => {
return text.split('\n').filter((line) => line.trim())
const currentVersion = computed(() => systemInfo.value?.panel_version || '')
const latestVersion = computed(() => versions.value?.[0]?.version || '')
// 更新开始后仅展示进度,隐藏版本对比与更新日志
const showProgress = computed(() => updating.value || !!errorMsg.value)
const parseDescription = (text: string): string[] =>
text.split('\n').filter((line) => line.trim())
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const reload = () => window.location.reload()
// 升级会重启面板,重启期间请求失败(连接拒绝或 503);静默轮询直到新版本就绪后刷新
const waitForRestart = async (targetVersion: string) => {
waitingRestart.value = true
const started = Date.now()
while (Date.now() - started < 120000) {
await sleep(3000)
try {
const data: any = await http.Get('/home/system_info', { meta: { noAlert: true } })
if (data && (!targetVersion || data.panel_version === targetVersion)) {
reload()
return
}
} catch {
// 面板重启中,继续等待
}
}
restartTimedOut.value = true
}
const resetUpdate = () => {
updating.value = false
progressLogs.value = []
errorMsg.value = ''
waitingRestart.value = false
restartTimedOut.value = false
}
const startUpdate = () => {
resetUpdate()
updating.value = true
const targetVersion = latestVersion.value
ws.panelUpdate()
.then((socket) => {
currentWs = socket
socket.onmessage = (event) => {
let data
try {
data = JSON.parse(event.data)
} catch {
return
}
if (data.status === 'progress') {
progressLogs.value.push(data.msg)
} else if (data.status === 'error') {
errorMsg.value = data.msg
} else if (data.status === 'success') {
waitForRestart(targetVersion)
}
}
socket.onclose = () => {
currentWs = null
// 连接断开且未报错/未进入等待,可能面板已重启,转入等待重启轮询
if (updating.value && !waitingRestart.value && !errorMsg.value) {
waitForRestart(targetVersion)
}
}
socket.onerror = () => {
currentWs = null
if (updating.value && !waitingRestart.value && !errorMsg.value) {
errorMsg.value = $gettext('WebSocket connection failed')
}
}
})
.catch(() => {
errorMsg.value = $gettext('WebSocket connection failed')
})
}
const handleUpdate = () => {
@@ -29,79 +108,117 @@ const handleUpdate = () => {
content: $gettext('Are you sure you want to update the panel?'),
positiveText: $gettext('Confirm'),
negativeText: $gettext('Cancel'),
onPositiveClick: () => {
updateLoading.value = true
messageReactive = window.$message.loading($gettext('Panel updating...'), {
duration: 0,
})
useRequest(home.update())
.onSuccess(() => {
setTimeout(() => {
setTimeout(() => {
window.location.reload()
}, 400)
router.push({ name: 'home-index' })
}, 2500)
window.$message.success($gettext('Panel updated successfully'))
})
.onComplete(() => {
updateLoading.value = false
messageReactive?.destroy()
})
},
onNegativeClick: () => {
window.$message.info($gettext('Update canceled'))
},
onPositiveClick: startUpdate,
})
}
</script>
<template>
<PageContainer :show-footer="true">
<n-list v-if="versions.length" hoverable>
<n-list-item v-for="(item, index) in versions" :key="index">
<n-thing>
<!-- 更新进度 -->
<n-card v-if="showProgress" :segmented="true" size="small">
<n-flex vertical :size="20" py-4 items-center>
<n-flex align="center" :size="12" justify="center">
<n-spin v-if="!errorMsg && !restartTimedOut" :size="22" />
<n-text v-else-if="errorMsg" type="error">
<the-icon icon="mdi:alert-circle-outline" :size="26" />
</n-text>
<n-text v-else type="warning">
<the-icon icon="mdi:alert-outline" :size="26" />
</n-text>
<n-text class="text-lg font-medium">
<template v-if="errorMsg">{{ $gettext('Update failed') }}</template>
<template v-else-if="restartTimedOut">{{ $gettext('Update timed out') }}</template>
<template v-else>{{ $gettext('Updating to v%{ v }...', { v: latestVersion }) }}</template>
</n-text>
</n-flex>
<n-timeline v-if="progressLogs.length || errorMsg" style="width: 100%; max-width: 460px">
<n-timeline-item
v-for="(log, i) in progressLogs"
:key="i"
type="success"
:content="log"
/>
<n-timeline-item
v-if="waitingRestart && !restartTimedOut"
type="info"
:content="$gettext('Panel is restarting, please wait...')"
/>
<n-timeline-item
v-if="restartTimedOut"
type="warning"
:content="
$gettext('Update may have failed, please check the panel logs and refresh manually.')
"
/>
<n-timeline-item
v-if="errorMsg"
type="error"
:title="$gettext('Error')"
:content="errorMsg"
/>
</n-timeline>
<n-flex v-if="errorMsg || restartTimedOut" justify="center">
<n-button v-if="errorMsg" @click="resetUpdate">{{ $gettext('Back') }}</n-button>
<n-button v-if="restartTimedOut" type="primary" @click="reload">
{{ $gettext('Refresh') }}
</n-button>
</n-flex>
</n-flex>
</n-card>
<!-- 版本对比 + 更新日志 -->
<template v-else>
<n-card :segmented="true" size="small">
<n-flex vertical align="center" :size="18" py-6>
<n-text type="success">
<the-icon icon="mdi:rocket-launch-outline" :size="46" />
</n-text>
<n-text class="text-lg font-medium">{{ $gettext('A new version is available') }}</n-text>
<n-flex align="center" :size="28" py-1>
<n-flex vertical align="center" :size="6">
<n-text depth="3" class="text-xs">{{ $gettext('Current') }}</n-text>
<n-tag round :bordered="false" size="large">v{{ currentVersion }}</n-tag>
</n-flex>
<n-text depth="3">
<the-icon icon="mdi:arrow-right-thin" :size="30" />
</n-text>
<n-flex vertical align="center" :size="6">
<n-text depth="3" class="text-xs">{{ $gettext('Latest') }}</n-text>
<n-tag type="success" round :bordered="false" size="large">v{{ latestVersion }}</n-tag>
</n-flex>
</n-flex>
<n-button type="primary" size="large" @click="handleUpdate">
<template #icon>
<the-icon icon="mdi:download" :size="18" />
</template>
{{ $gettext('Update Now') }}
</n-button>
</n-flex>
</n-card>
<n-flex vertical :size="12" mt-4>
<n-text depth="2" class="text-sm font-medium" px-1>{{ $gettext('Changelog') }}</n-text>
<n-card v-for="(item, index) in versions" :key="index" :segmented="true" size="small">
<template #header>
<div class="flex gap-3 items-center">
<span>v{{ item.version }}</span>
<n-tag v-if="index === 0" type="success" size="small" :bordered="false">
<n-flex align="center" :size="8">
<n-text class="font-medium">v{{ item.version }}</n-text>
<n-tag v-if="index === 0" type="success" size="small" round :bordered="false">
{{ $gettext('Latest') }}
</n-tag>
<n-tag size="small" :bordered="false">
{{ item.type }}
</n-tag>
</div>
<n-tag size="small" round :bordered="false">{{ item.type }}</n-tag>
</n-flex>
</template>
<template #header-extra>
<n-button
v-if="index === 0"
type="primary"
:loading="updateLoading"
:disabled="updateLoading"
@click="handleUpdate"
>
{{ $gettext('Update Now') }}
</n-button>
<n-text depth="3" class="text-xs">{{ formatDateTime(item.updated_at) }}</n-text>
</template>
<template #description>
<n-text depth="3">
{{ formatDateTime(item.updated_at) }}
</n-text>
</template>
<n-ol p-0>
<n-li v-for="(line, i) in parseDescription(item.description)" :key="i">
{{ line }}
</n-li>
<n-ol p-0 pl-5>
<n-li v-for="(line, i) in parseDescription(item.description)" :key="i">{{ line }}</n-li>
</n-ol>
</n-thing>
</n-list-item>
</n-list>
<div v-else pt-10>
<n-result
status="418"
title="Loading..."
:description="$gettext('Loading update information, please wait a moment')"
/>
</div>
</n-card>
</n-flex>
</template>
</PageContainer>
</template>