mirror of
https://github.com/tnb-labs/panel.git
synced 2026-08-29 02:10:58 +08:00
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:
+1
-1
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user