Compare commits

...

11 Commits

Author SHA1 Message Date
耗子 f6c5805ef6 fix: lint 2026-02-12 23:33:06 +08:00
耗子 c2001a477c fix: lint 2026-02-12 22:33:22 +08:00
耗子 8e76c6c3b2 fix: 不迁移443 2026-02-12 22:28:37 +08:00
耗子 4ff67bca0d fix: webserver 2026-02-12 22:08:39 +08:00
耗子 8f3eb90c2b fix: build 2026-02-12 21:51:49 +08:00
耗子 ccf696dc55 feat: 迁移至3.0 2026-02-12 21:48:46 +08:00
耗子 6f23f3f3a9 feat: 迁移至3.0 2026-02-12 21:47:52 +08:00
耗子 5576426f33 fix: 修复防火墙端口放行 2025-08-23 15:35:54 +08:00
耗子 d81ed39459 fix: 优化描述 2025-08-22 23:02:21 +08:00
耗子 f9238b6b59 fix: 修复下载 2025-08-22 23:01:14 +08:00
耗子 34df623e0c feat: 添加更新通知 2025-08-22 21:47:18 +08:00
22 changed files with 2043 additions and 34 deletions
-2
View File
@@ -1,8 +1,6 @@
name: Build
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
+3 -2
View File
@@ -118,6 +118,7 @@ func initWeb() (*app.Web, error) {
systemctlService := service.NewSystemctlService(locale)
toolboxSystemService := service.NewToolboxSystemService(locale)
toolboxBenchmarkService := service.NewToolboxBenchmarkService(locale)
toolboxMigrationService := service.NewToolboxMigrationService(locale, koanf, logger, settingRepo, websiteRepo, databaseRepo, databaseServerRepo, databaseUserRepo, appRepo)
codeserverApp := codeserver.NewApp()
dockerApp := docker.NewApp()
fail2banApp := fail2ban.NewApp(locale, websiteRepo)
@@ -142,9 +143,9 @@ func initWeb() (*app.Web, error) {
s3fsApp := s3fs.NewApp(locale)
supervisorApp := supervisor.NewApp(locale)
loader := bootstrap.NewLoader(codeserverApp, dockerApp, fail2banApp, frpApp, giteaApp, memcachedApp, minioApp, mysqlApp, nginxApp, php74App, php80App, php81App, php82App, php83App, php84App, phpmyadminApp, podmanApp, postgresqlApp, pureftpdApp, redisApp, rsyncApp, s3fsApp, supervisorApp)
http := route.NewHttp(userService, userTokenService, dashboardService, taskService, websiteService, databaseService, databaseServerService, databaseUserService, backupService, certService, certDNSService, certAccountService, appService, cronService, processService, safeService, firewallService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, monitorService, settingService, systemctlService, toolboxSystemService, toolboxBenchmarkService, loader)
http := route.NewHttp(userService, userTokenService, dashboardService, taskService, websiteService, databaseService, databaseServerService, databaseUserService, backupService, certService, certDNSService, certAccountService, appService, cronService, processService, safeService, firewallService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, monitorService, settingService, systemctlService, toolboxSystemService, toolboxBenchmarkService, toolboxMigrationService, loader)
wsService := service.NewWsService(locale, koanf, sshRepo)
ws := route.NewWs(wsService)
ws := route.NewWs(wsService, toolboxMigrationService)
mux, err := bootstrap.NewRouter(locale, middlewares, http, ws)
if err != nil {
return nil, err
+2
View File
@@ -5,6 +5,7 @@ go 1.24.0
require (
github.com/bddjr/hlfhr v1.3.8
github.com/beevik/ntp v1.4.3
github.com/coder/websocket v1.8.14
github.com/creack/pty v1.1.24
github.com/expr-lang/expr v1.17.6
github.com/go-chi/chi/v5 v5.2.2
@@ -56,6 +57,7 @@ require (
golang.org/x/net v0.43.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/gorm v1.30.1
resty.dev/v3 v3.0.0-beta.6
)
require (
+4
View File
@@ -36,6 +36,8 @@ github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVk
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
@@ -536,4 +538,6 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
resty.dev/v3 v3.0.0-beta.6 h1:ghRdNpoE8/wBCv+kTKIOauW1aCrSIeTq7GxtfYgtevU=
resty.dev/v3 v3.0.0-beta.6/go.mod h1:NTOerrC/4T7/FE6tXIZGIysXXBdgNqwMZuKtxpea9NM=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
+4 -4
View File
@@ -230,10 +230,10 @@ func (s *App) InstallExtension(w http.ResponseWriter, r *http.Request) {
return
}
cmd := fmt.Sprintf(`curl -sSLOm 10 --retry 3 'https://dl.cdn.haozi.net/panel/php_exts/%s.sh' | bash -s -- 'install' '%d' >> '/tmp/%s.log' 2>&1`, url.PathEscape(req.Slug), s.version, req.Slug)
cmd := fmt.Sprintf(`curl -sSLm 10 --retry 3 'https://dl.cdn.haozi.net/panel/php_exts/%s.sh' | bash -s -- 'install' '%d' >> '/tmp/%s.log' 2>&1`, url.PathEscape(req.Slug), s.version, req.Slug)
officials := []string{"fileinfo", "exif", "imap", "pgsql", "pdo_pgsql", "zip", "bz2", "readline", "snmp", "ldap", "enchant", "pspell", "calendar", "gmp", "sysvmsg", "sysvsem", "sysvshm", "xsl", "intl", "gettext"}
if slices.Contains(officials, req.Slug) {
cmd = fmt.Sprintf(`curl -sSLOm 10 --retry 3 'https://dl.cdn.haozi.net/panel/php_exts/official.sh' | bash -s -- 'install' '%d' '%s' >> '/tmp/%s.log' 2>&1`, s.version, req.Slug, req.Slug)
cmd = fmt.Sprintf(`curl -sSLm 10 --retry 3 'https://dl.cdn.haozi.net/panel/php_exts/official.sh' | bash -s -- 'install' '%d' '%s' >> '/tmp/%s.log' 2>&1`, s.version, req.Slug, req.Slug)
}
task := new(biz.Task)
@@ -261,10 +261,10 @@ func (s *App) UninstallExtension(w http.ResponseWriter, r *http.Request) {
return
}
cmd := fmt.Sprintf(`curl -sSLOm 10 --retry 3 'https://dl.cdn.haozi.net/panel/php_exts/%s.sh' | bash -s -- 'uninstall' '%d' >> '/tmp/%s.log' 2>&1`, url.PathEscape(req.Slug), s.version, req.Slug)
cmd := fmt.Sprintf(`curl -sSLm 10 --retry 3 'https://dl.cdn.haozi.net/panel/php_exts/%s.sh' | bash -s -- 'uninstall' '%d' >> '/tmp/%s.log' 2>&1`, url.PathEscape(req.Slug), s.version, req.Slug)
officials := []string{"fileinfo", "exif", "imap", "pgsql", "pdo_pgsql", "zip", "bz2", "readline", "snmp", "ldap", "enchant", "pspell", "calendar", "gmp", "sysvmsg", "sysvsem", "sysvshm", "xsl", "intl", "gettext"}
if slices.Contains(officials, req.Slug) {
cmd = fmt.Sprintf(`curl -sSLOm 10 --retry 3 'https://dl.cdn.haozi.net/panel/php_exts/official.sh' | bash -s -- 'uninstall' '%d' '%s' >> '/tmp/%s.log' 2>&1`, s.version, req.Slug, req.Slug)
cmd = fmt.Sprintf(`curl -sSLm 10 --retry 3 'https://dl.cdn.haozi.net/panel/php_exts/official.sh' | bash -s -- 'uninstall' '%d' '%s' >> '/tmp/%s.log' 2>&1`, s.version, req.Slug, req.Slug)
}
task := new(biz.Task)
+6 -6
View File
@@ -187,13 +187,13 @@ func (r *appRepo) Install(channel, slug string) error {
}
if app.IsCli {
return shell.ExecfWithOutput(`curl -sSLOm 10 --retry 3 "%s" | bash -s -- "%s" "%s"`, shellUrl, shellChannel, shellVersion)
return shell.ExecfWithOutput(`curl -sSLm 10 --retry 3 "%s" | bash -s -- "%s" "%s"`, shellUrl, shellChannel, shellVersion)
}
task := new(biz.Task)
task.Name = r.t.Get("Install app %s", item.Name)
task.Status = biz.TaskStatusWaiting
task.Shell = fmt.Sprintf(`curl -sSLOm 10 --retry 3 "%s" | bash -s -- "%s" "%s" >> /tmp/%s.log 2>&1`, shellUrl, shellChannel, shellVersion, item.Slug)
task.Shell = fmt.Sprintf(`curl -sSLm 10 --retry 3 "%s" | bash -s -- "%s" "%s" >> /tmp/%s.log 2>&1`, shellUrl, shellChannel, shellVersion, item.Slug)
task.Log = "/tmp/" + item.Slug + ".log"
return r.task.Push(task)
@@ -242,13 +242,13 @@ func (r *appRepo) UnInstall(slug string) error {
}
if app.IsCli {
return shell.ExecfWithOutput(`curl -sSLOm 10 --retry 3 "%s" | bash -s -- "%s" "%s"`, shellUrl, shellChannel, shellVersion)
return shell.ExecfWithOutput(`curl -sSLm 10 --retry 3 "%s" | bash -s -- "%s" "%s"`, shellUrl, shellChannel, shellVersion)
}
task := new(biz.Task)
task.Name = r.t.Get("Uninstall app %s", item.Name)
task.Status = biz.TaskStatusWaiting
task.Shell = fmt.Sprintf(`curl -sSLOm 10 --retry 3 "%s" | bash -s -- "%s" "%s" >> /tmp/%s.log 2>&1`, shellUrl, shellChannel, shellVersion, item.Slug)
task.Shell = fmt.Sprintf(`curl -sSLm 10 --retry 3 "%s" | bash -s -- "%s" "%s" >> /tmp/%s.log 2>&1`, shellUrl, shellChannel, shellVersion, item.Slug)
task.Log = "/tmp/" + item.Slug + ".log"
return r.task.Push(task)
@@ -297,13 +297,13 @@ func (r *appRepo) Update(slug string) error {
}
if app.IsCli {
return shell.ExecfWithOutput(`curl -sSLOm 10 --retry 3 "%s" | bash -s -- "%s" "%s"`, shellUrl, shellChannel, shellVersion)
return shell.ExecfWithOutput(`curl -sSLm 10 --retry 3 "%s" | bash -s -- "%s" "%s"`, shellUrl, shellChannel, shellVersion)
}
task := new(biz.Task)
task.Name = r.t.Get("Update app %s", item.Name)
task.Status = biz.TaskStatusWaiting
task.Shell = fmt.Sprintf(`curl -sSLOm 10 --retry 3 "%s" | bash -s -- "%s" "%s" >> /tmp/%s.log 2>&1`, shellUrl, shellChannel, shellVersion, item.Slug)
task.Shell = fmt.Sprintf(`curl -sSLm 10 --retry 3 "%s" | bash -s -- "%s" "%s" >> /tmp/%s.log 2>&1`, shellUrl, shellChannel, shellVersion, item.Slug)
task.Log = "/tmp/" + item.Slug + ".log"
return r.task.Push(task)
+1 -1
View File
@@ -798,7 +798,7 @@ func (r *backupRepo) UpdatePanel(version, url, checksum string) error {
if app.IsCli {
fmt.Println(r.t.Get("|-Run post-update script..."))
}
if _, err := shell.Execf("curl -sSLOm 10 https://dl.cdn.haozi.net/panel/auto_update.sh | bash"); err != nil {
if _, err := shell.Execf("curl -sSLm 10 https://dl.cdn.haozi.net/panel/auto_update.sh | bash"); err != nil {
return errors.New(r.t.Get("|-Run post-update script failed: %v", err))
}
if _, err := shell.Execf(`wget -O /etc/systemd/system/panel.service https://dl.cdn.haozi.net/panel/panel.service && sed -i "s|/www|%s|g" /etc/systemd/system/panel.service`, app.Root); err != nil {
+2 -2
View File
@@ -316,8 +316,8 @@ func (r *settingRepo) UpdatePanel(req *request.SettingPanel) (bool, error) {
fw := firewall.NewFirewall()
err = fw.Port(firewall.FireInfo{
Type: firewall.TypeNormal,
PortStart: config.HTTP.Port,
PortEnd: config.HTTP.Port,
PortStart: req.Port,
PortEnd: req.Port,
Direction: firewall.DirectionIn,
Strategy: firewall.StrategyAccept,
}, firewall.OperationAdd)
@@ -0,0 +1,54 @@
package request
// ToolboxMigrationConnection 迁移连接信息
type ToolboxMigrationConnection struct {
URL string `json:"url" validate:"required"`
TokenID uint `json:"token_id" validate:"required"`
Token string `json:"token" validate:"required"`
}
// ToolboxMigrationItems 迁移选择项
type ToolboxMigrationItems struct {
Websites []ToolboxMigrationWebsite `json:"websites"`
Databases []ToolboxMigrationDatabase `json:"databases"`
DatabaseUsers []ToolboxMigrationDatabaseUser `json:"database_users"`
StopOnMig bool `json:"stop_on_mig"` // 迁移中是否停止服务
}
// ToolboxMigrationWebsite 迁移网站项
type ToolboxMigrationWebsite struct {
ID uint `json:"id"`
Name string `json:"name"`
Path string `json:"path"` // 网站目录
}
// ToolboxMigrationDatabase 迁移数据库项
type ToolboxMigrationDatabase struct {
Type string `json:"type"` // mysql / postgresql
Name string `json:"name"`
ServerID uint `json:"server_id"`
Server string `json:"server"` // 服务器名称
}
// ToolboxMigrationDatabaseUser 迁移数据库用户项
type ToolboxMigrationDatabaseUser struct {
ID uint `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
Host string `json:"host"` // 仅 mysql
ServerID uint `json:"server_id"`
Server string `json:"server"` // 服务器名称
Type string `json:"type"` // mysql / postgresql
}
// ToolboxMigrationProject 迁移项目项
type ToolboxMigrationProject struct {
ID uint `json:"id"`
Name string `json:"name"`
Path string `json:"path"` // 项目目录
}
// ToolboxMigrationExec 远程执行命令请求
type ToolboxMigrationExec struct {
Command string `json:"command" validate:"required"`
}
+1
View File
@@ -19,6 +19,7 @@ type WebsiteCreate struct {
DBUser string `form:"db_user" json:"db_user" validate:"requiredIf:DB,true"`
DBPassword string `form:"db_password" json:"db_password" validate:"requiredIf:DB,true"`
Remark string `form:"remark" json:"remark"`
Type string // 迁移用的
}
type WebsiteDelete struct {
+14
View File
@@ -44,6 +44,7 @@ type Http struct {
systemctl *service.SystemctlService
toolboxSystem *service.ToolboxSystemService
toolboxBenchmark *service.ToolboxBenchmarkService
toolboxMigration *service.ToolboxMigrationService
apps *apploader.Loader
}
@@ -77,6 +78,7 @@ func NewHttp(
systemctl *service.SystemctlService,
toolboxSystem *service.ToolboxSystemService,
toolboxBenchmark *service.ToolboxBenchmarkService,
toolboxMigration *service.ToolboxMigrationService,
apps *apploader.Loader,
) *Http {
return &Http{
@@ -109,6 +111,7 @@ func NewHttp(
systemctl: systemctl,
toolboxSystem: toolboxSystem,
toolboxBenchmark: toolboxBenchmark,
toolboxMigration: toolboxMigration,
apps: apps,
}
}
@@ -408,6 +411,17 @@ func (route *Http) Register(r *chi.Mux) {
r.Post("/test", route.toolboxBenchmark.Test)
})
r.Route("/toolbox_migration", func(r chi.Router) {
r.Get("/status", route.toolboxMigration.GetStatus)
r.Post("/precheck", route.toolboxMigration.PreCheck)
r.Get("/items", route.toolboxMigration.GetItems)
r.Post("/start", route.toolboxMigration.Start)
r.Post("/reset", route.toolboxMigration.Reset)
r.Get("/results", route.toolboxMigration.GetResults)
r.Get("/log", route.toolboxMigration.DownloadLog)
r.Post("/exec", route.toolboxMigration.Exec)
})
r.Route("/apps", func(r chi.Router) {
route.apps.Register(r)
})
+6 -3
View File
@@ -7,12 +7,14 @@ import (
)
type Ws struct {
ws *service.WsService
ws *service.WsService
toolboxMigration *service.ToolboxMigrationService
}
func NewWs(ws *service.WsService) *Ws {
func NewWs(ws *service.WsService, toolboxMigration *service.ToolboxMigrationService) *Ws {
return &Ws{
ws: ws,
ws: ws,
toolboxMigration: toolboxMigration,
}
}
@@ -20,5 +22,6 @@ func (route *Ws) Register(r *chi.Mux) {
r.Route("/api/ws", func(r chi.Router) {
r.Get("/ssh", route.ws.Session)
r.Get("/exec", route.ws.Exec)
r.Get("/migration/progress", route.toolboxMigration.Progress)
})
}
+14 -12
View File
@@ -542,16 +542,18 @@ func (s *CliService) Port(ctx context.Context, cmd *cli.Command) error {
}
// 放行端口
fw := firewall.NewFirewall()
err = fw.Port(firewall.FireInfo{
Type: firewall.TypeNormal,
PortStart: config.HTTP.Port,
PortEnd: config.HTTP.Port,
Direction: firewall.DirectionIn,
Strategy: firewall.StrategyAccept,
}, firewall.OperationAdd)
if err != nil {
return err
if ok, _ := systemctl.IsEnabled("firewalld"); ok {
fw := firewall.NewFirewall()
err = fw.Port(firewall.FireInfo{
Type: firewall.TypeNormal,
PortStart: port,
PortEnd: port,
Direction: firewall.DirectionIn,
Strategy: firewall.StrategyAccept,
}, firewall.OperationAdd)
if err != nil {
return err
}
}
if err = io.Write("/usr/local/etc/panel/config.yml", string(encoded), 0700); err != nil {
@@ -990,8 +992,8 @@ checkPort:
fw := firewall.NewFirewall()
_ = fw.Port(firewall.FireInfo{
Type: firewall.TypeNormal,
PortStart: config.HTTP.Port,
PortEnd: config.HTTP.Port,
PortStart: port,
PortEnd: port,
Direction: firewall.DirectionIn,
Strategy: firewall.StrategyAccept,
}, firewall.OperationAdd)
+5 -2
View File
@@ -12,6 +12,7 @@ import (
"github.com/leonelquinteros/gotext"
"github.com/libtnb/chix"
"github.com/libtnb/utils/collect"
"github.com/samber/lo"
"github.com/shirou/gopsutil/disk"
"github.com/shirou/gopsutil/host"
"github.com/spf13/cast"
@@ -191,6 +192,7 @@ func (s *DashboardService) CountInfo(w http.ResponseWriter, r *http.Request) {
}
func (s *DashboardService) InstalledDbAndPhp(w http.ResponseWriter, r *http.Request) {
nginxInstalled, _ := s.appRepo.IsInstalled("slug = ?", "nginx")
mysqlInstalled, _ := s.appRepo.IsInstalled("slug = ?", "mysql")
postgresqlInstalled, _ := s.appRepo.IsInstalled("slug = ?", "postgresql")
php, _ := s.appRepo.GetInstalledAll("slug like ?", "php%")
@@ -218,8 +220,9 @@ func (s *DashboardService) InstalledDbAndPhp(w http.ResponseWriter, r *http.Requ
}
Success(w, chix.M{
"php": phpData,
"db": dbData,
"webserver": lo.If(nginxInstalled, "nginx").Else(""),
"php": phpData,
"db": dbData,
})
}
+1
View File
@@ -34,5 +34,6 @@ var ProviderSet = wire.NewSet(
NewWebsiteService,
NewToolboxSystemService,
NewToolboxBenchmarkService,
NewToolboxMigrationService,
NewWsService,
)
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
package types
import "time"
// MigrationStep 迁移步骤
type MigrationStep string
const (
MigrationStepIdle MigrationStep = "idle" // 空闲
MigrationStepConnect MigrationStep = "connect" // 连接信息
MigrationStepPreCheck MigrationStep = "precheck" // 预检查
MigrationStepSelect MigrationStep = "select" // 选择迁移项
MigrationStepRunning MigrationStep = "running" // 迁移中
MigrationStepDone MigrationStep = "done" // 迁移完成
)
// MigrationItemStatus 迁移项状态
type MigrationItemStatus string
const (
MigrationItemPending MigrationItemStatus = "pending"
MigrationItemRunning MigrationItemStatus = "running"
MigrationItemSuccess MigrationItemStatus = "success"
MigrationItemFailed MigrationItemStatus = "failed"
MigrationItemSkipped MigrationItemStatus = "skipped"
)
// MigrationItemResult 单个迁移项的结果
type MigrationItemResult struct {
Type string `json:"type"` // website / database / project
Name string `json:"name"` // 名称
Status MigrationItemStatus `json:"status"` // 状态
Error string `json:"error"` // 失败原因
StartedAt *time.Time `json:"started_at"` // 开始时间
EndedAt *time.Time `json:"ended_at"` // 结束时间
Duration float64 `json:"duration"` // 耗时(秒)
}
@@ -0,0 +1,18 @@
import { http } from '@/utils'
export default {
// 获取迁移状态
status: (): any => http.Get('/toolbox_migration/status'),
// 预检查远程环境
precheck: (data: any): any => http.Post('/toolbox_migration/precheck', data),
// 获取可迁移项
items: (): any => http.Get('/toolbox_migration/items'),
// 开始迁移
start: (data: any): any => http.Post('/toolbox_migration/start', data),
// 重置迁移
reset: (): any => http.Post('/toolbox_migration/reset'),
// 获取迁移结果
results: (): any => http.Get('/toolbox_migration/results'),
// 下载迁移日志
logUrl: '/api/toolbox_migration/log'
}
+8
View File
@@ -20,5 +20,13 @@ export default {
ws.onopen = () => resolve(ws)
ws.onerror = (e) => reject(e)
})
},
// 迁移进度
migrationProgress: (): Promise<WebSocket> => {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`${base}/migration/progress`)
ws.onopen = () => resolve(ws)
ws.onerror = (e) => reject(e)
})
}
}
+9
View File
@@ -408,12 +408,21 @@ if (import.meta.hot) {
clearInterval(homeInterval)
})
}
const endOfSupportDate = new Date('2026-12-31T23:59:59').getTime()
const isEOL = new Date().getTime() > endOfSupportDate - 30 * 24 * 60 * 60 * 1000 // 提前30天提醒
</script>
<template>
<app-page :show-footer="true" min-w-375>
<div flex-1>
<n-space vertical>
<n-alert type="info" v-if="!isEOL">
当前版本将于 2026 年底停止维护请尽快升级至 AcePanel 3.0
</n-alert>
<n-alert type="error" v-else>
当前版本已停止维护请升级至 AcePanel 3.0 以获得更好的性能和安全性
</n-alert>
<n-card :segmented="true" size="small">
<n-page-header :subtitle="systemInfo?.panel_version">
<n-grid :cols="4" pb-10>
+775
View File
@@ -0,0 +1,775 @@
<script setup lang="ts">
defineOptions({
name: 'toolbox-migration'
})
import dashboard from '@/api/panel/dashboard'
import migration from '@/api/panel/toolbox-migration'
import ws from '@/api/ws'
import { useRequest } from 'alova/client'
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
// 步骤状态
const currentStep = ref(1)
const loading = ref(false)
// 第一步:连接信息
const connectionForm = ref({
url: '',
token_id: 1,
token: ''
})
// 第二步:环境对比
const localEnv = ref<any>(null)
const remoteEnv = ref<any>(null)
const envCheckPassed = ref(false)
const envWarnings = ref<string[]>([])
// 第三步:迁移项选择
const websites = ref<any[]>([])
const databases = ref<any[]>([])
const databaseUsers = ref<any[]>([])
const selectedWebsites = ref<number[]>([])
const selectedDatabases = ref<string[]>([])
const selectedDatabaseUsers = ref<number[]>([])
const stopOnMig = ref(true)
// 第四步:迁移进度
const migrationLogs = ref<string[]>([])
const migrationResults = ref<any[]>([])
const migrationRunning = ref(false)
// 第五步:迁移结果
const migrationStartedAt = ref<string | null>(null)
const migrationEndedAt = ref<string | null>(null)
// WebSocket 连接
let progressWs: WebSocket | null = null
// 日志容器引用
const logContainer = ref<HTMLElement | null>(null)
// 初始化:检查是否有正在进行的迁移
const checkStatus = () => {
useRequest(migration.status()).onSuccess(({ data }: any) => {
if (data.step === 'running') {
currentStep.value = 4
migrationRunning.value = true
connectProgressWs()
} else if (data.step === 'done') {
currentStep.value = 5
migrationResults.value = data.results || []
migrationStartedAt.value = data.started_at
migrationEndedAt.value = data.ended_at
}
})
}
onMounted(() => {
checkStatus()
})
onUnmounted(() => {
if (progressWs) {
progressWs.close()
progressWs = null
}
})
// 第一步:连接并预检查
const handlePreCheck = () => {
if (!connectionForm.value.url || !connectionForm.value.token_id || !connectionForm.value.token) {
window.$message.error($gettext('请填写完整的连接信息'))
return
}
loading.value = true
useRequest(migration.precheck(connectionForm.value))
.onSuccess(({ data }: any) => {
remoteEnv.value = data.remote
// 同时获取本地环境信息
useRequest(dashboard.installedDbAndPhp()).onSuccess(({ data: localData }: any) => {
localEnv.value = localData
checkEnvironment()
currentStep.value = 2
loading.value = false
})
})
.onComplete(() => {
loading.value = false
})
}
// 第二步:刷新环境检查
const handleRefreshPreCheck = () => {
if (!connectionForm.value.url || !connectionForm.value.token_id || !connectionForm.value.token) {
window.$message.error($gettext('请填写完整的连接信息'))
return
}
loading.value = true
useRequest(migration.precheck(connectionForm.value))
.onSuccess(({ data }: any) => {
remoteEnv.value = data.remote
useRequest(dashboard.installedDbAndPhp()).onSuccess(({ data: localData }: any) => {
localEnv.value = localData
checkEnvironment()
loading.value = false
window.$message.success($gettext('环境检查已刷新'))
})
})
.onComplete(() => {
loading.value = false
})
}
// 环境检查逻辑
const checkEnvironment = () => {
const warnings: string[] = []
let passed = true
if (!localEnv.value || !remoteEnv.value) return
// webserver 必须一致
if (localEnv.value.webserver !== remoteEnv.value.webserver) {
warnings.push(
$gettext(
'Web 服务器类型不匹配。本地: %{local},远程: %{remote}。相关网站可能无法正常工作。',
{
local: localEnv.value.webserver || $gettext('none'),
remote: remoteEnv.value.webserver || $gettext('none')
}
)
)
passed = false
}
// 检查数据库差异
const localDBTypes = (localEnv.value.db || [])
.map((d: any) => d.value)
.filter((v: string) => v !== '0')
const remoteDBTypes = (remoteEnv.value.db || [])
.map((d: any) => d.value)
.filter((v: string) => v !== '0')
for (const dbType of localDBTypes) {
if (!remoteDBTypes.includes(dbType)) {
warnings.push(
$gettext('%{type} 已安装在本地但未安装在远程服务器。相关数据库可能无法正常工作。', {
type: dbType.toUpperCase()
})
)
}
}
envWarnings.value = warnings
envCheckPassed.value = passed
}
// 第二步:获取可迁移项列表
const handleGetItems = () => {
loading.value = true
useRequest(migration.items())
.onSuccess(({ data }: any) => {
websites.value = data.websites || []
databases.value = data.databases || []
databaseUsers.value = data.database_users || []
currentStep.value = 3
loading.value = false
})
.onComplete(() => {
loading.value = false
})
}
// 第三步:开始迁移
const handleStartMigration = () => {
const selectedItems = {
websites: websites.value
.filter((_: any, i: number) => selectedWebsites.value.includes(i))
.map((w: any) => ({ id: w.id, name: w.name, path: w.path })),
databases: databases.value
.filter((_: any, i: number) => selectedDatabases.value.includes(String(i)))
.map((d: any) => ({
type: d.type,
name: d.name,
server_id: d.server_id,
server: d.server
})),
database_users: databaseUsers.value
.filter((_: any, i: number) => selectedDatabaseUsers.value.includes(i))
.map((u: any) => ({
id: u.id,
username: u.username,
password: u.password,
host: u.host,
server_id: u.server_id,
server: u.server?.name,
type: u.server?.type
})),
stop_on_mig: stopOnMig.value
}
if (
selectedItems.websites.length === 0 &&
selectedItems.databases.length === 0 &&
selectedItems.database_users.length === 0
) {
window.$message.warning($gettext('请至少选择一个迁移项'))
return
}
window.$dialog.warning({
title: $gettext('确认迁移'),
content: $gettext('你确定要开始迁移吗?这将把所选项迁移到远程服务器。'),
positiveText: $gettext('开始'),
negativeText: $gettext('取消'),
onPositiveClick: () => {
loading.value = true
migrationLogs.value = []
migrationResults.value = []
useRequest(migration.start(selectedItems))
.onSuccess(() => {
currentStep.value = 4
migrationRunning.value = true
loading.value = false
connectProgressWs()
})
.onComplete(() => {
loading.value = false
})
}
})
}
// 连接 WebSocket 获取进度
const connectProgressWs = async () => {
try {
progressWs = await ws.migrationProgress()
progressWs.onmessage = (event: MessageEvent) => {
const data = JSON.parse(event.data)
migrationResults.value = data.results || []
migrationStartedAt.value = data.started_at
migrationEndedAt.value = data.ended_at
if (data.new_logs) {
migrationLogs.value.push(...data.new_logs)
// 限制日志行数
if (migrationLogs.value.length > 1000) {
migrationLogs.value = migrationLogs.value.slice(-1000)
}
// 自动滚动到底部
nextTick(() => {
if (logContainer.value) {
logContainer.value.scrollTop = logContainer.value.scrollHeight
}
})
}
if (data.step === 'done') {
migrationRunning.value = false
currentStep.value = 5
if (progressWs) {
progressWs.close()
progressWs = null
}
}
}
progressWs.onclose = () => {
if (migrationRunning.value) {
// 连接意外断开,尝试重连
setTimeout(connectProgressWs, 3000)
}
}
} catch {
// 如果 WebSocket 连接失败,回退到轮询
pollProgress()
}
}
// 轮询进度(备用方案)
const pollProgress = () => {
const timer = setInterval(() => {
useRequest(migration.results()).onSuccess(({ data }: any) => {
migrationResults.value = data.results || []
migrationStartedAt.value = data.started_at
migrationEndedAt.value = data.ended_at
if (data.logs) {
migrationLogs.value = data.logs
}
if (data.step === 'done') {
migrationRunning.value = false
currentStep.value = 5
clearInterval(timer)
}
})
}, 2000)
}
// 重置迁移
const handleReset = () => {
window.$dialog.warning({
title: $gettext('重置迁移状态'),
content: $gettext('你确定要重置迁移状态吗?这将清除所有迁移记录并允许你开始新的迁移。'),
positiveText: $gettext('确认'),
negativeText: $gettext('取消'),
onPositiveClick: () => {
useRequest(migration.reset()).onSuccess(() => {
currentStep.value = 1
connectionForm.value = { url: '', token_id: 1, token: '' }
localEnv.value = null
remoteEnv.value = null
envCheckPassed.value = false
envWarnings.value = []
websites.value = []
databases.value = []
databaseUsers.value = []
selectedWebsites.value = []
selectedDatabases.value = []
selectedDatabaseUsers.value = []
migrationLogs.value = []
migrationResults.value = []
migrationStartedAt.value = null
migrationEndedAt.value = null
window.$message.success($gettext('迁移状态已重置'))
})
}
})
}
// 获取状态标签类型
const getStatusType = (status: string) => {
switch (status) {
case 'success':
return 'success'
case 'failed':
return 'error'
case 'running':
return 'warning'
case 'skipped':
return 'default'
default:
return 'info'
}
}
// 格式化耗时
const formatDuration = (seconds: number) => {
if (seconds < 60) return `${seconds.toFixed(1)}s`
const mins = Math.floor(seconds / 60)
const secs = (seconds % 60).toFixed(1)
return `${mins}m ${secs}s`
}
</script>
<template>
<common-page show-footer>
<n-flex vertical>
<!-- 步骤指示器 -->
<n-card>
<n-steps :current="currentStep" size="small">
<n-step title="连接" description="输入远程服务器信息" />
<n-step title="预检查" description="验证环境" />
<n-step title="选择项目" description="选择要迁移的内容" />
<n-step title="迁移中" description="正在传输" />
<n-step title="完成" description="查看结果" />
</n-steps>
</n-card>
<!-- 第一步连接信息 -->
<n-card v-if="currentStep === 1" title="远程服务器连接">
<n-form label-placement="left" label-width="auto">
<n-form-item label="面板地址">
<n-input
v-model:value="connectionForm.url"
placeholder="例如 https://remote-server:8888"
/>
</n-form-item>
<n-form-item label="令牌 ID">
<n-input-number
v-model:value="connectionForm.token_id"
placeholder="API 令牌 ID"
:min="1"
style="width: 100%"
/>
</n-form-item>
<n-form-item label="访问令牌">
<n-input
v-model:value="connectionForm.token"
type="password"
show-password-on="click"
placeholder="API 访问令牌"
/>
</n-form-item>
</n-form>
<n-flex justify="end">
<n-button type="primary" :loading="loading" :disabled="loading" @click="handlePreCheck">
下一步
</n-button>
</n-flex>
</n-card>
<!-- 第二步环境预检查 -->
<n-card v-if="currentStep === 2" title="环境预检查">
<!-- 警告信息 -->
<n-flex v-if="envWarnings.length > 0" vertical style="margin-bottom: 16px">
<n-alert
v-for="(warning, index) in envWarnings"
:key="index"
:type="envCheckPassed ? 'warning' : 'error'"
style="margin-bottom: 8px"
>
{{ warning }}
</n-alert>
</n-flex>
<!-- 环境对比表 -->
<n-table :bordered="true" :single-line="false" size="small">
<thead>
<tr>
<th>环境</th>
<th>本地</th>
<th>远程</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr>
<td>Web 服务器</td>
<td>{{ localEnv?.webserver || '无' }}</td>
<td>{{ remoteEnv?.webserver || '无' }}</td>
<td>
<n-tag
:type="localEnv?.webserver === remoteEnv?.webserver ? 'success' : 'error'"
size="small"
>
{{ localEnv?.webserver === remoteEnv?.webserver ? '一致' : '不一致' }}
</n-tag>
</td>
</tr>
<tr v-for="envType in ['go', 'java', 'nodejs', 'php', 'python']" :key="envType">
<td>{{ envType.toUpperCase() }}</td>
<td>
<template v-if="(localEnv?.[envType] || []).length > 0">
<n-tag
v-for="item in localEnv[envType]"
:key="item.value"
size="small"
style="margin: 2px"
>
{{ item.label }}
</n-tag>
</template>
<template v-else>
<n-text depth="3">未安装</n-text>
</template>
</td>
<td>
<template v-if="(remoteEnv?.[envType] || []).length > 0">
<n-tag
v-for="item in remoteEnv[envType]"
:key="item.value"
size="small"
style="margin: 2px"
>
{{ item.label }}
</n-tag>
</template>
<template v-else>
<n-text depth="3">未安装</n-text>
</template>
</td>
<td>
<n-tag
:type="
JSON.stringify(localEnv?.[envType] || []) ===
JSON.stringify(remoteEnv?.[envType] || [])
? 'success'
: 'warning'
"
size="small"
>
{{
JSON.stringify(localEnv?.[envType] || []) ===
JSON.stringify(remoteEnv?.[envType] || [])
? '一致'
: '有差异'
}}
</n-tag>
</td>
</tr>
<tr>
<td>数据库</td>
<td>
<template
v-if="(localEnv?.db || []).filter((d: any) => d.value !== '0').length > 0"
>
<n-tag
v-for="item in localEnv.db.filter((d: any) => d.value !== '0')"
:key="item.value"
size="small"
style="margin: 2px"
>
{{ item.label }}
</n-tag>
</template>
<template v-else>
<n-text depth="3"></n-text>
</template>
</td>
<td>
<template
v-if="(remoteEnv?.db || []).filter((d: any) => d.value !== '0').length > 0"
>
<n-tag
v-for="item in remoteEnv.db.filter((d: any) => d.value !== '0')"
:key="item.value"
size="small"
style="margin: 2px"
>
{{ item.label }}
</n-tag>
</template>
<template v-else>
<n-text depth="3"></n-text>
</template>
</td>
<td>
<n-tag :type="'info'" size="small">-</n-tag>
</td>
</tr>
</tbody>
</n-table>
<n-flex justify="space-between" style="margin-top: 16px">
<n-flex>
<n-button @click="currentStep = 1">上一步</n-button>
<n-button :loading="loading" :disabled="loading" @click="handleRefreshPreCheck">
刷新
</n-button>
</n-flex>
<n-button
type="primary"
:disabled="!envCheckPassed || loading"
:loading="loading"
@click="handleGetItems"
>
下一步
</n-button>
</n-flex>
</n-card>
<!-- 第三步选择迁移项 -->
<n-card v-if="currentStep === 3" title="选择迁移项目">
<!-- 网站 -->
<n-card title="网站" size="small" embedded style="margin-bottom: 12px">
<template v-if="websites.length > 0">
<n-checkbox-group v-model:value="selectedWebsites">
<n-flex vertical>
<n-checkbox v-for="(site, index) in websites" :key="site.id" :value="index">
{{ site.name }}
<n-text depth="3" style="margin-left: 8px">{{ site.path }}</n-text>
</n-checkbox>
</n-flex>
</n-checkbox-group>
</template>
<n-empty v-else description="未找到网站" />
</n-card>
<!-- 数据库 -->
<n-card title="数据库" size="small" embedded style="margin-bottom: 12px">
<template v-if="databases.length > 0">
<n-checkbox-group v-model:value="selectedDatabases">
<n-flex vertical>
<n-checkbox v-for="(db, index) in databases" :key="db.name" :value="String(index)">
{{ db.name }}
<n-tag size="small" style="margin-left: 8px">{{ db.type }}</n-tag>
<n-text depth="3" style="margin-left: 8px">{{ db.server }}</n-text>
</n-checkbox>
</n-flex>
</n-checkbox-group>
</template>
<n-empty v-else description="未找到数据库" />
</n-card>
<!-- 数据库用户 -->
<n-card title="数据库用户" size="small" embedded style="margin-bottom: 12px">
<template v-if="databaseUsers.length > 0">
<n-checkbox-group v-model:value="selectedDatabaseUsers">
<n-flex vertical>
<n-checkbox v-for="(user, index) in databaseUsers" :key="user.id" :value="index">
{{ user.username }}
<n-text v-if="user.host" depth="3" style="margin-left: 4px"
>@{{ user.host }}</n-text
>
<n-tag size="small" style="margin-left: 8px">{{ user.server?.type }}</n-tag>
<n-text depth="3" style="margin-left: 8px">{{ user.server?.name }}</n-text>
</n-checkbox>
</n-flex>
</n-checkbox-group>
</template>
<n-empty v-else description="未找到数据库用户" />
</n-card>
<!-- 选项 -->
<n-card size="small" embedded style="margin-bottom: 12px">
<n-checkbox v-model:checked="stopOnMig">
迁移期间停止服务以确保数据一致性推荐
</n-checkbox>
</n-card>
<n-flex justify="space-between">
<n-button @click="currentStep = 2">上一步</n-button>
<n-button
type="primary"
:loading="loading"
:disabled="loading"
@click="handleStartMigration"
>
开始迁移
</n-button>
</n-flex>
</n-card>
<!-- 第四步迁移进度 -->
<n-card v-if="currentStep === 4" title="迁移进度">
<!-- 迁移项状态 -->
<n-table :bordered="true" :single-line="false" size="small" style="margin-bottom: 12px">
<thead>
<tr>
<th>类型</th>
<th>名称</th>
<th>状态</th>
<th>耗时</th>
</tr>
</thead>
<tbody>
<tr v-for="(result, index) in migrationResults" :key="index">
<td>
<n-tag size="small">{{ result.type }}</n-tag>
</td>
<td>{{ result.name }}</td>
<td>
<n-tag :type="getStatusType(result.status)" size="small">
{{ result.status }}
</n-tag>
</td>
<td>{{ result.duration ? formatDuration(result.duration) : '-' }}</td>
</tr>
</tbody>
</n-table>
<!-- 实时日志 -->
<n-card title="迁移日志" size="small" embedded>
<template #header-extra>
<n-button
size="small"
:disabled="migrationLogs.length === 0"
tag="a"
:href="migration.logUrl"
target="_blank"
>
下载日志
</n-button>
</template>
<div
ref="logContainer"
style="
height: 400px;
overflow-y: auto;
font-family: monospace;
font-size: 13px;
line-height: 1.6;
background: var(--n-color);
padding: 8px;
border-radius: 4px;
"
>
<div
v-for="(log, index) in migrationLogs"
:key="index"
style="white-space: pre-wrap; word-break: break-all"
>
{{ log }}
</div>
<div v-if="migrationRunning" style="color: var(--n-text-color-3)">迁移进行中...</div>
</div>
</n-card>
</n-card>
<!-- 第五步迁移完成 -->
<n-card v-if="currentStep === 5" title="迁移完成">
<n-result
:status="
migrationResults.every((r: any) => r.status === 'success') ? 'success' : 'warning'
"
:title="
migrationResults.every((r: any) => r.status === 'success')
? '所有项目已成功迁移'
: '迁移已完成,但存在部分问题'
"
>
<template #footer>
<n-flex vertical>
<n-text v-if="migrationStartedAt && migrationEndedAt">
开始{{ new Date(migrationStartedAt).toLocaleString() }} &nbsp;|&nbsp; 结束{{
new Date(migrationEndedAt).toLocaleString()
}}
</n-text>
</n-flex>
</template>
</n-result>
<!-- 详细结果表 -->
<n-table :bordered="true" :single-line="false" size="small" style="margin-top: 16px">
<thead>
<tr>
<th>类型</th>
<th>名称</th>
<th>状态</th>
<th>耗时</th>
<th>详情</th>
</tr>
</thead>
<tbody>
<tr v-for="(result, index) in migrationResults" :key="index">
<td>
<n-tag size="small">{{ result.type }}</n-tag>
</td>
<td>{{ result.name }}</td>
<td>
<n-tag :type="getStatusType(result.status)" size="small">
{{ result.status }}
</n-tag>
</td>
<td>{{ result.duration ? formatDuration(result.duration) : '-' }}</td>
<td>
<n-text v-if="result.error" type="error">{{ result.error }}</n-text>
<n-text v-else-if="result.status === 'success' && result.ended_at" type="success">
迁移成功 - {{ new Date(result.ended_at).toLocaleString() }}
</n-text>
<n-text v-else-if="result.ended_at">
{{ new Date(result.ended_at).toLocaleString() }}
</n-text>
<n-text v-else>-</n-text>
</td>
</tr>
</tbody>
</n-table>
<!-- 环境差异提醒 -->
<n-alert v-if="envWarnings.length > 0" type="warning" title="提醒" style="margin-top: 16px">
本地与远程服务器存在部分环境差异你可能需要在远程服务器上调整相关设置否则迁移后相关项目可能无法正常运行
</n-alert>
<n-flex justify="center" style="margin-top: 16px">
<n-button tag="a" :href="migration.logUrl" target="_blank">下载日志</n-button>
<n-button type="primary" @click="handleReset">开始新的迁移</n-button>
</n-flex>
</n-card>
</n-flex>
</common-page>
</template>
+10
View File
@@ -31,6 +31,16 @@ export default {
role: ['admin'],
requireAuth: true
}
},
{
name: 'toolbox-migration',
path: 'migration',
component: () => import('./MigrationView.vue'),
meta: {
title: '迁移 3.0',
role: ['admin'],
requireAuth: true
}
}
]
} as RouteType