mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-25 11:33:42 +08:00
303 lines
9.8 KiB
TypeScript
303 lines
9.8 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react'
|
||
import { Modal, Steps, Button, Space, Message, Spin, Progress } from '@arco-design/web-react'
|
||
import { Step1NodeName, type Mode } from './wizard/Step1NodeName'
|
||
import { Step2DeployOptions, type DeployOptions } from './wizard/Step2DeployOptions'
|
||
import { Step3CommandPreview } from './wizard/Step3CommandPreview'
|
||
import { BatchCommandTable, type BatchCommandRow } from './BatchCommandTable'
|
||
import { batchCreateNodes, createInstallToken } from '../../services/nodes'
|
||
import type { InstallTokenResult } from '../../types/nodes'
|
||
import { buildAgentInstallCommand } from './installCommands'
|
||
|
||
const Step = Steps.Step
|
||
|
||
interface Props {
|
||
visible: boolean
|
||
onClose: () => void
|
||
onSuccess: () => void
|
||
// null = 正在拉取;空字符串 = 拉取失败(Step2 将展示手动输入框而非 Select)
|
||
masterVersion: string | null
|
||
// 当从节点列表直接点"生成安装命令"时传入,跳过 Step1
|
||
fixedNode?: { id: number; name: string }
|
||
}
|
||
|
||
export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion, fixedNode }: Props) {
|
||
const [step, setStep] = useState(fixedNode ? 1 : 0)
|
||
const [mode, setMode] = useState<Mode>('single')
|
||
const [singleName, setSingleName] = useState('')
|
||
const [batchText, setBatchText] = useState('')
|
||
|
||
// 批量进度(已生成 / 总数)
|
||
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
|
||
|
||
const [deploy, setDeploy] = useState<DeployOptions>({
|
||
mode: 'systemd',
|
||
arch: 'auto',
|
||
agentVersion: masterVersion || '',
|
||
downloadSrc: 'github',
|
||
ttlSeconds: 900,
|
||
})
|
||
|
||
// 当父组件异步拿到 masterVersion 后,同步到 deploy.agentVersion(仅初始为空时)
|
||
useEffect(() => {
|
||
if (masterVersion && !deploy.agentVersion) {
|
||
setDeploy((prev) => ({ ...prev, agentVersion: masterVersion }))
|
||
}
|
||
}, [masterVersion]) // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// unmount 保护:用户关 Modal / 切页时,已发出的请求完成后不再更新 state
|
||
const mountedRef = useRef(true)
|
||
useEffect(() => {
|
||
mountedRef.current = true
|
||
return () => {
|
||
mountedRef.current = false
|
||
}
|
||
}, [])
|
||
|
||
const [singleToken, setSingleToken] = useState<InstallTokenResult | null>(null)
|
||
const [singleNodeInfo, setSingleNodeInfo] = useState<{ id: number; name: string } | null>(null)
|
||
const [batchRows, setBatchRows] = useState<BatchCommandRow[]>([])
|
||
const [submitting, setSubmitting] = useState(false)
|
||
|
||
const reset = () => {
|
||
setStep(fixedNode ? 1 : 0)
|
||
setMode('single')
|
||
setSingleName('')
|
||
setBatchText('')
|
||
setSingleToken(null)
|
||
setSingleNodeInfo(null)
|
||
setBatchRows([])
|
||
setBatchProgress(null)
|
||
}
|
||
|
||
const handleClose = () => {
|
||
reset()
|
||
onClose()
|
||
}
|
||
|
||
const parseBatchNames = (): string[] =>
|
||
batchText.split('\n').map((s) => s.trim()).filter(Boolean)
|
||
|
||
const handleNextFromStep1 = () => {
|
||
if (mode === 'single') {
|
||
if (!singleName.trim()) {
|
||
Message.warning('请输入节点名称')
|
||
return
|
||
}
|
||
} else {
|
||
const names = parseBatchNames()
|
||
if (names.length === 0) {
|
||
Message.warning('请至少输入一个节点名称')
|
||
return
|
||
}
|
||
if (names.length > 50) {
|
||
Message.warning('单次最多创建 50 个节点')
|
||
return
|
||
}
|
||
}
|
||
setStep(1)
|
||
}
|
||
|
||
const handleGenerate = async () => {
|
||
if (!deploy.agentVersion.trim()) {
|
||
Message.warning('请填写 Agent 版本号(形如 v1.7.0)')
|
||
return
|
||
}
|
||
// 步骤 1 的批次内去重在前端先提示一次,再由后端最终校验
|
||
if (mode === 'batch' && !fixedNode) {
|
||
const names = parseBatchNames()
|
||
const seen = new Set<string>()
|
||
const dups: string[] = []
|
||
for (const n of names) {
|
||
if (seen.has(n)) dups.push(n)
|
||
seen.add(n)
|
||
}
|
||
if (dups.length > 0) {
|
||
Message.warning(`批次内有重复节点名:${Array.from(new Set(dups)).join(', ')}`)
|
||
return
|
||
}
|
||
}
|
||
setSubmitting(true)
|
||
try {
|
||
if (fixedNode) {
|
||
const tok = await createInstallToken(fixedNode.id, {
|
||
mode: deploy.mode,
|
||
arch: deploy.arch,
|
||
agentVersion: deploy.agentVersion,
|
||
downloadSrc: deploy.downloadSrc,
|
||
ttlSeconds: deploy.ttlSeconds,
|
||
})
|
||
setSingleNodeInfo(fixedNode)
|
||
setSingleToken(tok)
|
||
} else if (mode === 'single') {
|
||
const created = await batchCreateNodes([singleName.trim()])
|
||
const one = created[0]
|
||
const tok = await createInstallToken(one.id, {
|
||
mode: deploy.mode,
|
||
arch: deploy.arch,
|
||
agentVersion: deploy.agentVersion,
|
||
downloadSrc: deploy.downloadSrc,
|
||
ttlSeconds: deploy.ttlSeconds,
|
||
})
|
||
setSingleNodeInfo({ id: one.id, name: one.name })
|
||
setSingleToken(tok)
|
||
} else {
|
||
const names = parseBatchNames()
|
||
const created = await batchCreateNodes(names)
|
||
setBatchProgress({ done: 0, total: created.length })
|
||
// 并发生成 install token(Promise.all),每完成一个递增 done 计数
|
||
let done = 0
|
||
const tokens = await Promise.all(
|
||
created.map(async (c) => {
|
||
const tok = await createInstallToken(c.id, {
|
||
mode: deploy.mode,
|
||
arch: deploy.arch,
|
||
agentVersion: deploy.agentVersion,
|
||
downloadSrc: deploy.downloadSrc,
|
||
ttlSeconds: deploy.ttlSeconds,
|
||
})
|
||
done += 1
|
||
if (mountedRef.current) setBatchProgress({ done, total: created.length })
|
||
return { c, tok }
|
||
}),
|
||
)
|
||
const rows: BatchCommandRow[] = tokens.map(({ c, tok }) => ({
|
||
nodeId: c.id,
|
||
nodeName: c.name,
|
||
command: buildAgentInstallCommand(tok.url, tok.fallbackUrl, tok.scriptBase64),
|
||
expiresAt: tok.expiresAt,
|
||
}))
|
||
if (mountedRef.current) setBatchRows(rows)
|
||
}
|
||
setStep(2)
|
||
onSuccess()
|
||
} catch (e: any) {
|
||
Message.error(e?.message || '操作失败')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
const regenerateSingle = async () => {
|
||
if (!singleNodeInfo) return
|
||
setSubmitting(true)
|
||
try {
|
||
const tok = await createInstallToken(singleNodeInfo.id, {
|
||
mode: deploy.mode,
|
||
arch: deploy.arch,
|
||
agentVersion: deploy.agentVersion,
|
||
downloadSrc: deploy.downloadSrc,
|
||
ttlSeconds: deploy.ttlSeconds,
|
||
})
|
||
setSingleToken(tok)
|
||
} catch (e: any) {
|
||
Message.error(e?.message || '重新生成失败')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
const previewParams = {
|
||
mode: deploy.mode,
|
||
arch: deploy.arch,
|
||
agentVersion: deploy.agentVersion,
|
||
downloadSrc: deploy.downloadSrc,
|
||
}
|
||
|
||
// fixedNode 路径下步骤只有 2 步(部署参数 + 安装命令),step 值从 1 开始,
|
||
// 需要映射到 Steps current(0-based)
|
||
const stepsCurrent = fixedNode ? step - 1 : step
|
||
|
||
return (
|
||
<Modal
|
||
title={fixedNode ? `为「${fixedNode.name}」生成安装命令` : '添加节点'}
|
||
visible={visible}
|
||
onCancel={handleClose}
|
||
footer={null}
|
||
style={{ width: 760 }}
|
||
unmountOnExit
|
||
>
|
||
<Steps current={stepsCurrent} size="small" style={{ marginBottom: 24 }}>
|
||
{!fixedNode && <Step title="节点信息" />}
|
||
<Step title="部署参数" />
|
||
<Step title="安装命令" />
|
||
</Steps>
|
||
|
||
{submitting && (
|
||
<div style={{ textAlign: 'center', padding: 32 }}>
|
||
<Spin />
|
||
{batchProgress && (
|
||
<div style={{ marginTop: 16, maxWidth: 360, marginLeft: 'auto', marginRight: 'auto' }}>
|
||
<div style={{ fontSize: 13, marginBottom: 6 }}>
|
||
正在生成安装命令 {batchProgress.done} / {batchProgress.total}
|
||
</div>
|
||
<Progress
|
||
percent={Math.round((batchProgress.done / batchProgress.total) * 100)}
|
||
showText
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{!submitting && step === 0 && (
|
||
<>
|
||
<Step1NodeName
|
||
mode={mode}
|
||
onModeChange={setMode}
|
||
singleName={singleName}
|
||
onSingleNameChange={setSingleName}
|
||
batchText={batchText}
|
||
onBatchTextChange={setBatchText}
|
||
/>
|
||
<div style={{ marginTop: 24, textAlign: 'right' }}>
|
||
<Space>
|
||
<Button onClick={handleClose}>取消</Button>
|
||
<Button type="primary" onClick={handleNextFromStep1}>
|
||
下一步
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{!submitting && step === 1 && (
|
||
<>
|
||
<Step2DeployOptions masterVersion={masterVersion} value={deploy} onChange={setDeploy} />
|
||
<div style={{ marginTop: 24, textAlign: 'right' }}>
|
||
<Space>
|
||
{!fixedNode && (
|
||
<Button onClick={() => setStep(0)}>上一步</Button>
|
||
)}
|
||
<Button onClick={handleClose}>取消</Button>
|
||
<Button type="primary" onClick={handleGenerate} loading={submitting}>
|
||
生成安装命令
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{!submitting && step === 2 && (
|
||
<>
|
||
{singleToken && singleNodeInfo && (
|
||
<Step3CommandPreview
|
||
nodeId={singleNodeInfo.id}
|
||
nodeName={singleNodeInfo.name}
|
||
token={singleToken}
|
||
mode={deploy.mode}
|
||
previewParams={previewParams}
|
||
onRegenerate={regenerateSingle}
|
||
/>
|
||
)}
|
||
{batchRows.length > 0 && <BatchCommandTable rows={batchRows} />}
|
||
<div style={{ marginTop: 24, textAlign: 'right' }}>
|
||
<Button type="primary" onClick={handleClose}>
|
||
完成
|
||
</Button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</Modal>
|
||
)
|
||
}
|