feat: 工具箱-迁移功能 (Toolbox Migration) (#1358)

* Initial plan

* feat: add migration backend service, routes and Wire injection

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* feat: add migration frontend - API, WebSocket, MigrationView and tab integration

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* refactor: move migration types to pkg/types, fix code review issues

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* chore: final cleanup

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* feat: add refresh button on migration pre-check step

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* feat: add SSH key exchange for rsync authentication

Generate temporary ED25519 key pair before migration, deploy public
key to remote via POST /api/toolbox_migration/ssh_key, use -i flag
in all rsync/ssh commands, cleanup key from both sides after migration.

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: lint

* fix: lint

* fix: align migration code with actual codebase patterns

- Project service name: use project name directly (not ace-project-xxx)
- MySQL dump: use MYSQL_PWD env var (not -p flag), remove --socket
- PostgreSQL dump: use PGPASSWORD env var with pg_dump -h 127.0.0.1 -U postgres
- PostgreSQL import: use PGPASSWORD with psql -h 127.0.0.1 -U postgres
- MySQL import: use MYSQL_PWD on remote
- Website create API: use correct listens/domains/type fields
- Project create API: use root_dir/exec_start/user fields
- Database create API: pass server_id properly
- Site directory: use filepath.Join(app.Root, "sites") instead of hardcoded path
- maskPassword: handle MYSQL_PWD/PGPASSWORD env var format

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: add PHP version and proxy backend to website create API body

PHP sites now include the php version number, and reverse proxy sites
include the backend address from Proxies[0].Pass. This matches the
WebsiteCreate validation rules (requiredIf:Type,php / requiredIf:Type,proxy).

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* refactor: replace map[string]any with typed request structs for all remote API calls

Use request.WebsiteCreate, request.DatabaseCreate, request.ProjectCreate,
and request.ToolboxMigrationSSHKey instead of ad-hoc map[string]any.
This ensures all required/validated fields are properly typed and present.

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: website list empty and database migration items stuck in running status

1. Website list: pass "all" to websiteRepo.List() instead of "" — empty
   string was treated as a type filter matching nothing.

2. Database status: result was stored with name "aaa (mysql)" but
   failResult/succeedResult looked up by "aaa" — name mismatch meant
   status was never updated from "running". Use displayName consistently.

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: use acepanel CLI to get remote DB passwords, show success text in results

1. Remote DB import: use `acepanel setting get mysql_root_password` and
   `acepanel setting get postgres_password` on the remote server via SSH
   instead of guessing file paths. The $() is escaped so it executes on
   the remote side, not locally.

2. Migration results: successful items now show "Migration succeeded"
   text alongside the completion timestamp instead of a bare date.

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: lint

* refactor: 不基于rsync的迁移

* fix: lint

* fix: lint

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
Co-authored-by: 耗子 <haozi@loli.email>
This commit is contained in:
Copilot
2026-02-10 02:51:03 +08:00
committed by GitHub
parent 3c6c5aa31e
commit 7da411b2f3
12 changed files with 2017 additions and 6 deletions
+3 -2
View File
@@ -132,6 +132,7 @@ func initAce() (*app.Ace, error) {
toolboxSSHService := service.NewToolboxSSHService(locale)
toolboxDiskService := service.NewToolboxDiskService(locale)
toolboxLogService := service.NewToolboxLogService(locale, db, containerImageRepo, settingRepo)
toolboxMigrationService := service.NewToolboxMigrationService(locale, config, logger, settingRepo, websiteRepo, databaseRepo, projectRepo, appRepo, environmentRepo)
webHookRepo := data.NewWebHookRepo(locale, db, logger)
webHookService := service.NewWebHookService(webHookRepo)
templateRepo := data.NewTemplateRepo(locale, cacheRepo)
@@ -158,9 +159,9 @@ func initAce() (*app.Ace, error) {
s3fsApp := s3fs.NewApp(locale)
supervisorApp := supervisor.NewApp(locale)
loader := bootstrap.NewLoader(apacheApp, codeserverApp, dockerApp, fail2banApp, frpApp, giteaApp, mariadbApp, memcachedApp, minioApp, mysqlApp, nginxApp, openrestyApp, perconaApp, phpmyadminApp, podmanApp, postgresqlApp, pureftpdApp, redisApp, rsyncApp, s3fsApp, supervisorApp)
http := route.NewHttp(config, userService, userTokenService, homeService, taskService, websiteService, projectService, databaseService, databaseServerService, databaseUserService, backupService, backupStorageService, certService, certDNSService, certAccountService, appService, environmentService, environmentGoService, environmentJavaService, environmentNodejsService, environmentPHPService, environmentPythonService, cronService, processService, safeService, firewallService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, logService, monitorService, settingService, systemctlService, toolboxSystemService, toolboxBenchmarkService, toolboxSSHService, toolboxDiskService, toolboxLogService, webHookService, templateService, loader)
http := route.NewHttp(config, userService, userTokenService, homeService, taskService, websiteService, projectService, databaseService, databaseServerService, databaseUserService, backupService, backupStorageService, certService, certDNSService, certAccountService, appService, environmentService, environmentGoService, environmentJavaService, environmentNodejsService, environmentPHPService, environmentPythonService, cronService, processService, safeService, firewallService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, logService, monitorService, settingService, systemctlService, toolboxSystemService, toolboxBenchmarkService, toolboxSSHService, toolboxDiskService, toolboxLogService, toolboxMigrationService, webHookService, templateService, loader)
wsService := service.NewWsService(locale, config, logger, sshRepo)
ws := route.NewWs(wsService)
ws := route.NewWs(wsService, toolboxMigrationService)
mux, err := bootstrap.NewRouter(locale, middlewares, http, ws)
if err != nil {
return nil, err
+1 -1
View File
@@ -501,7 +501,7 @@ func (r *backupRepo) createMySQL(name string, storage storage.Storage, target st
// 导出数据库
name = name + ".sql"
_ = os.Setenv("MYSQL_PWD", rootPassword)
if _, err = shell.Execf(`mysqldump -u root '%s' > '%s'`, target, filepath.Join(tmpDir, name)); err != nil {
if _, err = shell.Execf(`mysqldump -u root --single-transaction --quick '%s' > '%s'`, target, filepath.Join(tmpDir, name)); err != nil {
return err
}
_ = os.Unsetenv("MYSQL_PWD")
@@ -0,0 +1,43 @@
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"`
Projects []ToolboxMigrationProject `json:"projects"`
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"` // 服务器名称
}
// 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"`
}
+14
View File
@@ -58,6 +58,7 @@ type Http struct {
toolboxSSH *service.ToolboxSSHService
toolboxDisk *service.ToolboxDiskService
toolboxLog *service.ToolboxLogService
toolboxMigration *service.ToolboxMigrationService
webhook *service.WebHookService
template *service.TemplateService
apps *apploader.Loader
@@ -106,6 +107,7 @@ func NewHttp(
toolboxSSH *service.ToolboxSSHService,
toolboxDisk *service.ToolboxDiskService,
toolboxLog *service.ToolboxLogService,
toolboxMigration *service.ToolboxMigrationService,
webhook *service.WebHookService,
template *service.TemplateService,
apps *apploader.Loader,
@@ -153,6 +155,7 @@ func NewHttp(
toolboxSSH: toolboxSSH,
toolboxDisk: toolboxDisk,
toolboxLog: toolboxLog,
toolboxMigration: toolboxMigration,
webhook: webhook,
template: template,
apps: apps,
@@ -565,6 +568,17 @@ func (route *Http) Register(r *chi.Mux) {
r.Post("/clean", route.toolboxLog.Clean)
})
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("/webhook", func(r chi.Router) {
r.Get("/", route.webhook.List)
r.Post("/", route.webhook.Create)
+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,
}
}
@@ -23,5 +25,6 @@ func (route *Ws) Register(r *chi.Mux) {
r.Get("/ssh", route.ws.Session)
r.Get("/container/{id}", route.ws.ContainerTerminal)
r.Get("/container/image/pull", route.ws.ContainerImagePull)
r.Get("/migration/progress", route.toolboxMigration.Progress)
})
}
+1
View File
@@ -48,5 +48,6 @@ var ProviderSet = wire.NewSet(
NewToolboxSSHService,
NewToolboxDiskService,
NewToolboxLogService,
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
@@ -52,5 +52,13 @@ export default {
}
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)
})
}
}
+3
View File
@@ -6,6 +6,7 @@ defineOptions({
import BenchmarkView from '@/views/toolbox/BenchmarkView.vue'
import DiskView from '@/views/toolbox/DiskView.vue'
import LogView from '@/views/toolbox/LogView.vue'
import MigrationView from '@/views/toolbox/MigrationView.vue'
import ProcessView from '@/views/toolbox/ProcessView.vue'
import SshView from '@/views/toolbox/SshView.vue'
import SystemView from '@/views/toolbox/SystemView.vue'
@@ -27,6 +28,7 @@ const current = ref('process')
<n-tab name="log" :tab="$gettext('Log Clean')" />
<n-tab name="webhook" :tab="$gettext('WebHook')" />
<n-tab name="benchmark" :tab="$gettext('Benchmark')" />
<n-tab name="migration" :tab="$gettext('Migration')" />
</n-tabs>
</template>
<n-flex vertical>
@@ -37,6 +39,7 @@ const current = ref('process')
<log-view v-if="current === 'log'" />
<web-hook-view v-if="current === 'webhook'" />
<benchmark-view v-if="current === 'benchmark'" />
<migration-view v-if="current === 'migration'" />
</n-flex>
</common-page>
</template>
+806
View File
@@ -0,0 +1,806 @@
<script setup lang="ts">
defineOptions({
name: 'toolbox-migration'
})
import home from '@/api/panel/home'
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 projects = ref<any[]>([])
const selectedWebsites = ref<number[]>([])
const selectedDatabases = ref<string[]>([])
const selectedProjects = 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('Please fill in all connection fields'))
return
}
loading.value = true
useRequest(migration.precheck(connectionForm.value))
.onSuccess(({ data }: any) => {
remoteEnv.value = data.remote
// 同时获取本地环境信息
useRequest(home.installedEnvironment()).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('Please fill in all connection fields'))
return
}
loading.value = true
useRequest(migration.precheck(connectionForm.value))
.onSuccess(({ data }: any) => {
remoteEnv.value = data.remote
useRequest(home.installedEnvironment()).onSuccess(({ data: localData }: any) => {
localEnv.value = localData
checkEnvironment()
loading.value = false
window.$message.success($gettext('Environment check refreshed'))
})
})
.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 server mismatch: local is %{local}, remote is %{remote}. Migration cannot proceed.',
{
local: localEnv.value.webserver || $gettext('none'),
remote: remoteEnv.value.webserver || $gettext('none')
}
)
)
passed = false
}
// 检查其他环境差异
const envTypes = ['go', 'java', 'nodejs', 'php', 'python']
for (const envType of envTypes) {
const localItems = localEnv.value[envType] || []
const remoteItems = remoteEnv.value[envType] || []
if (localItems.length > 0 && remoteItems.length === 0) {
warnings.push(
$gettext(
'%{type} is installed locally but not on the remote server. Related projects may need reconfiguration.',
{
type: envType.toUpperCase()
}
)
)
}
}
// 检查数据库差异
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} is installed locally but not on the remote server. Database migration for this type will be skipped.',
{
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 || []
projects.value = data.projects || []
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
})),
projects: projects.value
.filter((_: any, i: number) => selectedProjects.value.includes(i))
.map((p: any) => ({ id: p.id, name: p.name, path: p.root_dir || p.path })),
stop_on_mig: stopOnMig.value
}
if (
selectedItems.websites.length === 0 &&
selectedItems.databases.length === 0 &&
selectedItems.projects.length === 0
) {
window.$message.warning($gettext('Please select at least one item to migrate'))
return
}
window.$dialog.warning({
title: $gettext('Confirm Migration'),
content: $gettext(
'Are you sure you want to start migration? This will transfer the selected items to the remote server.'
),
positiveText: $gettext('Start'),
negativeText: $gettext('Cancel'),
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('Reset Migration'),
content: $gettext('Are you sure you want to reset the migration state?'),
positiveText: $gettext('Confirm'),
negativeText: $gettext('Cancel'),
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 = []
projects.value = []
selectedWebsites.value = []
selectedDatabases.value = []
selectedProjects.value = []
migrationLogs.value = []
migrationResults.value = []
migrationStartedAt.value = null
migrationEndedAt.value = null
window.$message.success($gettext('Migration state has been reset'))
})
}
})
}
// 获取状态标签类型
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>
<n-flex vertical>
<!-- 步骤指示器 -->
<n-card>
<n-steps :current="currentStep" size="small">
<n-step
:title="$gettext('Connection')"
:description="$gettext('Enter remote server info')"
/>
<n-step :title="$gettext('Pre-check')" :description="$gettext('Verify environment')" />
<n-step
:title="$gettext('Select Items')"
:description="$gettext('Choose what to migrate')"
/>
<n-step :title="$gettext('Migrating')" :description="$gettext('Transfer in progress')" />
<n-step :title="$gettext('Complete')" :description="$gettext('View results')" />
</n-steps>
</n-card>
<!-- 第一步连接信息 -->
<n-card v-if="currentStep === 1" :title="$gettext('Remote Server Connection')">
<n-form label-placement="left" label-width="auto">
<n-form-item :label="$gettext('Panel URL')">
<n-input
v-model:value="connectionForm.url"
:placeholder="$gettext('e.g. https://remote-server:8888')"
/>
</n-form-item>
<n-form-item :label="$gettext('Token ID')">
<n-input-number
v-model:value="connectionForm.token_id"
:placeholder="$gettext('API Token ID')"
:min="1"
style="width: 100%"
/>
</n-form-item>
<n-form-item :label="$gettext('Access Token')">
<n-input
v-model:value="connectionForm.token"
type="password"
show-password-on="click"
:placeholder="$gettext('API Access Token')"
/>
</n-form-item>
</n-form>
<n-flex justify="end">
<n-button type="primary" :loading="loading" :disabled="loading" @click="handlePreCheck">
{{ $gettext('Next') }}
</n-button>
</n-flex>
</n-card>
<!-- 第二步环境预检查 -->
<n-card v-if="currentStep === 2" :title="$gettext('Environment Pre-check')">
<!-- 警告信息 -->
<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>{{ $gettext('Environment') }}</th>
<th>{{ $gettext('Local') }}</th>
<th>{{ $gettext('Remote') }}</th>
<th>{{ $gettext('Status') }}</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ $gettext('Web Server') }}</td>
<td>{{ localEnv?.webserver || $gettext('None') }}</td>
<td>{{ remoteEnv?.webserver || $gettext('None') }}</td>
<td>
<n-tag
:type="localEnv?.webserver === remoteEnv?.webserver ? 'success' : 'error'"
size="small"
>
{{
localEnv?.webserver === remoteEnv?.webserver
? $gettext('Match')
: $gettext('Mismatch')
}}
</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">{{ $gettext('Not installed') }}</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">{{ $gettext('Not installed') }}</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] || [])
? $gettext('Match')
: $gettext('Different')
}}
</n-tag>
</td>
</tr>
<tr>
<td>{{ $gettext('Database') }}</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">{{ $gettext('None') }}</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">{{ $gettext('None') }}</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">{{ $gettext('Previous') }}</n-button>
<n-button :loading="loading" :disabled="loading" @click="handleRefreshPreCheck">
{{ $gettext('Refresh') }}
</n-button>
</n-flex>
<n-button
type="primary"
:disabled="!envCheckPassed || loading"
:loading="loading"
@click="handleGetItems"
>
{{ $gettext('Next') }}
</n-button>
</n-flex>
</n-card>
<!-- 第三步选择迁移项 -->
<n-card v-if="currentStep === 3" :title="$gettext('Select Migration Items')">
<!-- 网站 -->
<n-card :title="$gettext('Websites')" 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="$gettext('No websites found')" />
</n-card>
<!-- 数据库 -->
<n-card :title="$gettext('Databases')" 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="$gettext('No databases found')" />
</n-card>
<!-- 项目 -->
<n-card :title="$gettext('Projects')" size="small" embedded style="margin-bottom: 12px">
<template v-if="projects.length > 0">
<n-checkbox-group v-model:value="selectedProjects">
<n-flex vertical>
<n-checkbox v-for="(proj, index) in projects" :key="proj.id" :value="index">
{{ proj.name }}
<n-tag size="small" style="margin-left: 8px">{{ proj.type }}</n-tag>
<n-text depth="3" style="margin-left: 8px">{{ proj.root_dir || proj.path }}</n-text>
</n-checkbox>
</n-flex>
</n-checkbox-group>
</template>
<n-empty v-else :description="$gettext('No projects found')" />
</n-card>
<!-- 选项 -->
<n-card size="small" embedded style="margin-bottom: 12px">
<n-checkbox v-model:checked="stopOnMig">
{{ $gettext('Stop services during migration to ensure data consistency (recommended)') }}
</n-checkbox>
</n-card>
<n-flex justify="space-between">
<n-button @click="currentStep = 2">{{ $gettext('Previous') }}</n-button>
<n-button
type="primary"
:loading="loading"
:disabled="loading"
@click="handleStartMigration"
>
{{ $gettext('Start Migration') }}
</n-button>
</n-flex>
</n-card>
<!-- 第四步迁移进度 -->
<n-card v-if="currentStep === 4" :title="$gettext('Migration Progress')">
<!-- 迁移项状态 -->
<n-table :bordered="true" :single-line="false" size="small" style="margin-bottom: 12px">
<thead>
<tr>
<th>{{ $gettext('Type') }}</th>
<th>{{ $gettext('Name') }}</th>
<th>{{ $gettext('Status') }}</th>
<th>{{ $gettext('Duration') }}</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="$gettext('Migration Logs')" size="small" embedded>
<template #header-extra>
<n-button
size="small"
:disabled="migrationLogs.length === 0"
tag="a"
:href="migration.logUrl"
target="_blank"
>
{{ $gettext('Download Log') }}
</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)">
{{ $gettext('Migration in progress...') }}
</div>
</div>
</n-card>
</n-card>
<!-- 第五步迁移完成 -->
<n-card v-if="currentStep === 5" :title="$gettext('Migration Complete')">
<n-result
:status="migrationResults.every((r: any) => r.status === 'success') ? 'success' : 'warning'"
:title="
migrationResults.every((r: any) => r.status === 'success')
? $gettext('All items migrated successfully')
: $gettext('Migration completed with some issues')
"
>
<template #footer>
<n-flex vertical>
<n-text v-if="migrationStartedAt && migrationEndedAt">
{{ $gettext('Started') }}: {{ new Date(migrationStartedAt).toLocaleString() }}
&nbsp;|&nbsp;
{{ $gettext('Ended') }}: {{ 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>{{ $gettext('Type') }}</th>
<th>{{ $gettext('Name') }}</th>
<th>{{ $gettext('Status') }}</th>
<th>{{ $gettext('Duration') }}</th>
<th>{{ $gettext('Details') }}</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">
{{ $gettext('Migration succeeded') }} -
{{ 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="$gettext('Reminder')"
style="margin-top: 16px"
>
{{
$gettext(
'Some environments differ between local and remote servers. You may need to adjust settings on the remote server otherwise related items may not work properly after migration.'
)
}}
</n-alert>
<n-flex justify="center" style="margin-top: 16px">
<n-button tag="a" :href="migration.logUrl" target="_blank">
{{ $gettext('Download Log') }}
</n-button>
<n-button type="primary" @click="handleReset">
{{ $gettext('Start New Migration') }}
</n-button>
</n-flex>
</n-card>
</n-flex>
</template>
<style scoped lang="scss"></style>