mirror of
https://github.com/tnb-labs/panel.git
synced 2026-08-31 01:12:17 +08:00
Compare commits
11 Commits
v3.2.0-beta.2
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
| f6c5805ef6 | |||
| c2001a477c | |||
| 8e76c6c3b2 | |||
| 4ff67bca0d | |||
| 8f3eb90c2b | |||
| ccf696dc55 | |||
| 6f23f3f3a9 | |||
| 5576426f33 | |||
| d81ed39459 | |||
| f9238b6b59 | |||
| 34df623e0c |
@@ -1,8 +1,6 @@
|
||||
name: Build
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -34,5 +34,6 @@ var ProviderSet = wire.NewSet(
|
||||
NewWebsiteService,
|
||||
NewToolboxSystemService,
|
||||
NewToolboxBenchmarkService,
|
||||
NewToolboxMigrationService,
|
||||
NewWsService,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() }} | 结束:{{
|
||||
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>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user