diff --git a/web/src/pages/nodes/AgentInstallWizard.tsx b/web/src/pages/nodes/AgentInstallWizard.tsx index 7bab0ca..6533833 100644 --- a/web/src/pages/nodes/AgentInstallWizard.tsx +++ b/web/src/pages/nodes/AgentInstallWizard.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react' -import { Modal, Steps, Button, Space, Message, Spin } from '@arco-design/web-react' +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' @@ -13,7 +13,8 @@ interface Props { visible: boolean onClose: () => void onSuccess: () => void - masterVersion: string + // null = 正在拉取;空字符串 = 拉取失败(Step2 将展示手动输入框而非 Select) + masterVersion: string | null // 当从节点列表直接点"生成安装命令"时传入,跳过 Step1 fixedNode?: { id: number; name: string } } @@ -24,14 +25,33 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion, const [singleName, setSingleName] = useState('') const [batchText, setBatchText] = useState('') + // 批量进度(已生成 / 总数) + const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null) + const [deploy, setDeploy] = useState({ mode: 'systemd', arch: 'auto', - agentVersion: masterVersion, + 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(null) const [singleNodeInfo, setSingleNodeInfo] = useState<{ id: number; name: string } | null>(null) const [batchRows, setBatchRows] = useState([]) @@ -45,6 +65,7 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion, setSingleToken(null) setSingleNodeInfo(null) setBatchRows([]) + setBatchProgress(null) } const handleClose = () => { @@ -76,6 +97,24 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion, } 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() + 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) { @@ -103,23 +142,30 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion, } else { const names = parseBatchNames() const created = await batchCreateNodes(names) - const rows: BatchCommandRow[] = [] - for (const c of created) { - const tok = await createInstallToken(c.id, { - mode: deploy.mode, - arch: deploy.arch, - agentVersion: deploy.agentVersion, - downloadSrc: deploy.downloadSrc, - ttlSeconds: deploy.ttlSeconds, - }) - rows.push({ - nodeId: c.id, - nodeName: c.name, - command: `curl -fsSL ${tok.url} | sudo sh`, - expiresAt: tok.expiresAt, - }) - } - setBatchRows(rows) + 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: `curl -fsSL ${tok.url} | sudo sh`, + expiresAt: tok.expiresAt, + })) + if (mountedRef.current) setBatchRows(rows) } setStep(2) onSuccess() @@ -178,6 +224,17 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion, {submitting && (
+ {batchProgress && ( +
+
+ 正在生成安装命令 {batchProgress.done} / {batchProgress.total} +
+ +
+ )}
)} diff --git a/web/src/pages/nodes/NodesPage.tsx b/web/src/pages/nodes/NodesPage.tsx index 0b93cf1..bccff3e 100644 --- a/web/src/pages/nodes/NodesPage.tsx +++ b/web/src/pages/nodes/NodesPage.tsx @@ -19,7 +19,8 @@ export default function NodesPage() { const [wizardVisible, setWizardVisible] = useState(false) const [wizardFixedNode, setWizardFixedNode] = useState<{ id: number; name: string } | undefined>() - const [masterVersion, setMasterVersion] = useState('latest') + // null = 拉取中 / 未知;空字符串 = 拉取失败(UI 将要求用户手动输入版本,避免生成无效 URL) + const [masterVersion, setMasterVersion] = useState(null) const [editVisible, setEditVisible] = useState(false) const [editNode, setEditNode] = useState(null) @@ -39,10 +40,11 @@ export default function NodesPage() { useEffect(() => { fetchNodes() - // 取 Master 版本号作为 Wizard agentVersion 默认值 + // 取 Master 版本号作为 Wizard agentVersion 默认值。 + // 拉取失败或字段缺失时置为空串,Wizard 会提示用户手动输入。 fetchSystemInfo().then((info) => { - if (info?.version) setMasterVersion(info.version) - }).catch(() => {}) + setMasterVersion(info?.version || '') + }).catch(() => setMasterVersion('')) }, [fetchNodes]) const handleDelete = async (id: number) => { diff --git a/web/src/pages/nodes/wizard/Step2DeployOptions.tsx b/web/src/pages/nodes/wizard/Step2DeployOptions.tsx index aaf5952..f5b48bc 100644 --- a/web/src/pages/nodes/wizard/Step2DeployOptions.tsx +++ b/web/src/pages/nodes/wizard/Step2DeployOptions.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Form, Radio, Select, Typography } from '@arco-design/web-react' +import { Form, Radio, Select, Input, Typography } from '@arco-design/web-react' import type { InstallMode, InstallArch, InstallSource } from '../../../types/nodes' const { Text } = Typography @@ -13,13 +13,16 @@ export interface DeployOptions { } interface Props { - masterVersion: string + // null = 拉取中;空串 = 拉取失败(改为手动输入) + masterVersion: string | null value: DeployOptions onChange: (v: DeployOptions) => void } export function Step2DeployOptions({ masterVersion, value, onChange }: Props) { const update = (patch: Partial) => onChange({ ...value, ...patch }) + const versionKnown = !!masterVersion + const versionLoading = masterVersion === null return (
@@ -48,14 +51,32 @@ export function Step2DeployOptions({ masterVersion, value, onChange }: Props) { /> - - update({ agentVersion: v })} + options={[ + { label: `${masterVersion}(跟随 Master,推荐)`, value: masterVersion as string }, + ]} + /> + ) : ( + update({ agentVersion: v })} + disabled={versionLoading} + /> + )}