mirror of
https://github.com/tnb-labs/panel.git
synced 2026-09-01 14:55:12 +08:00
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:
+3
-2
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -48,5 +48,6 @@ var ProviderSet = wire.NewSet(
|
||||
NewToolboxSSHService,
|
||||
NewToolboxDiskService,
|
||||
NewToolboxLogService,
|
||||
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'
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() }}
|
||||
|
|
||||
{{ $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>
|
||||
Reference in New Issue
Block a user