feat: 优化集群部署与堡垒机接入 (#106)

支持受限网络、正向代理、私有 CA 与 SSH 堡垒机部署 Agent。

加固 Docker、systemd、Nginx、安装器、Release 校验与可信代理边界,并完善命令队列索引、前端安装向导及中英文运维文档。
This commit is contained in:
Wu Qing
2026-08-09 02:45:17 +08:00
committed by GitHub
parent 00151e466c
commit 5827074334
86 changed files with 4668 additions and 3082 deletions

491
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,14 +11,14 @@
},
"dependencies": {
"@arco-design/web-react": "^2.66.0",
"axios": "^1.16.0",
"echarts": "^6.0.0",
"axios": "^1.19.0",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"i18next": "^25.8.14",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^16.5.6",
"react-router-dom": "^6.30.0",
"react-router-dom": "^6.30.4",
"zustand": "^5.0.3"
},
"devDependencies": {
@@ -31,7 +31,7 @@
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^26.0.0",
"typescript": "^5.7.3",
"vite": "^6.4.2",
"vitest": "^3.0.8"
"vite": "^6.4.3",
"vitest": "^3.2.7"
}
}

View File

@@ -1,11 +1,12 @@
import React, { useEffect, useRef, useState } from 'react'
import { Modal, Steps, Button, Space, Message, Spin } from '@arco-design/web-react'
import { Step1NodeName, type Mode } from './wizard/Step1NodeName'
import { Step2DeployOptions, type DeployOptions } from './wizard/Step2DeployOptions'
import { Step2DeployOptions, isReleaseVersion, type DeployOptions } from './wizard/Step2DeployOptions'
import { Step3CommandPreview } from './wizard/Step3CommandPreview'
import { BatchCommandTable, type BatchCommandRow } from './BatchCommandTable'
import type { InstallTokenResult } from '../../types/nodes'
import type { InstallTokenInput, InstallTokenResult } from '../../types/nodes'
import { useAgentDeployFlow, type AgentDeployRow } from './useAgentDeployFlow'
import { validateAgentConnection } from './wizard/AgentConnectionOptions'
const Step = Steps.Step
@@ -29,15 +30,19 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
const [deploy, setDeploy] = useState<DeployOptions>({
mode: 'systemd',
arch: 'auto',
agentVersion: masterVersion || '',
agentVersion: isReleaseVersion(masterVersion) ? masterVersion || '' : '',
downloadSrc: 'github',
ttlSeconds: 900,
connectionMode: 'direct',
agentMasterUrl: '',
proxyUrl: '',
caCertFile: '',
})
// 当父组件异步拿到 masterVersion 后,同步到 deploy.agentVersion仅初始为空时
useEffect(() => {
if (masterVersion && !deploy.agentVersion) {
setDeploy((prev) => ({ ...prev, agentVersion: masterVersion }))
if (isReleaseVersion(masterVersion) && !deploy.agentVersion) {
setDeploy((prev) => ({ ...prev, agentVersion: masterVersion as string }))
}
}, [masterVersion]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -98,17 +103,23 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
Message.warning('请填写 Agent 版本号(形如 v1.7.0')
return
}
const connectionError = validateAgentConnection(deploy)
if (connectionError) {
Message.warning(connectionError)
return
}
const installInput = toInstallTokenInput(deploy)
setSubmitting(true)
try {
if (fixedNode) {
const result = await deployFlow.submitExistingNode(fixedNode, deploy)
const result = await deployFlow.submitExistingNode(fixedNode, installInput)
applySingleOrTableResult(result.rows, fixedNode)
} else if (mode === 'single') {
const result = await deployFlow.submitNewNodes([singleName.trim()], deploy)
const result = await deployFlow.submitNewNodes([singleName.trim()], installInput)
applySingleOrTableResult(result.rows)
} else {
const names = parseBatchNames()
const result = await deployFlow.submitNewNodes(names, deploy)
const result = await deployFlow.submitNewNodes(names, installInput)
if (mountedRef.current) setBatchRows(toBatchRows(result.rows))
if (result.status === 'partialFailed') {
Message.warning('部分节点安装命令生成失败,可在结果表中查看')
@@ -127,7 +138,7 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
if (!singleNodeInfo) return
setSubmitting(true)
try {
const row = await deployFlow.regenerateNode(singleNodeInfo, deploy)
const row = await deployFlow.regenerateNode(singleNodeInfo, toInstallTokenInput(deploy))
if (row.status === 'ready' && row.installToken) {
setSingleToken(row.installToken)
} else {
@@ -143,7 +154,7 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
const retryBatchNode = async (row: BatchCommandRow) => {
setSubmitting(true)
try {
const next = await deployFlow.regenerateNode({ id: row.nodeId, name: row.nodeName }, deploy)
const next = await deployFlow.regenerateNode({ id: row.nodeId, name: row.nodeName }, toInstallTokenInput(deploy))
setBatchRows((rows) => rows.map((item) => (
item.nodeId === row.nodeId ? toBatchRows([next])[0] : item
)))
@@ -164,6 +175,9 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
arch: deploy.arch,
agentVersion: deploy.agentVersion,
downloadSrc: deploy.downloadSrc,
agentMasterUrl: deploy.connectionMode === 'restricted' ? deploy.agentMasterUrl.trim() : '',
proxyUrl: deploy.connectionMode === 'restricted' ? deploy.proxyUrl.trim() : '',
caCertFile: deploy.connectionMode === 'restricted' ? deploy.caCertFile.trim() : '',
}
// fixedNode 路径下步骤只有 2 步(部署参数 + 安装命令step 值从 1 开始,
@@ -236,7 +250,6 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
nodeId={singleNodeInfo.id}
nodeName={singleNodeInfo.name}
token={singleToken}
mode={deploy.mode}
previewParams={previewParams}
onRegenerate={regenerateSingle}
/>
@@ -268,6 +281,19 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
}
}
function toInstallTokenInput(deploy: DeployOptions): InstallTokenInput {
return {
mode: deploy.mode,
arch: deploy.arch,
agentVersion: deploy.agentVersion.trim(),
downloadSrc: deploy.downloadSrc,
ttlSeconds: deploy.ttlSeconds,
agentMasterUrl: deploy.connectionMode === 'restricted' ? deploy.agentMasterUrl.trim() : undefined,
proxyUrl: deploy.connectionMode === 'restricted' ? deploy.proxyUrl.trim() : undefined,
caCertFile: deploy.connectionMode === 'restricted' ? deploy.caCertFile.trim() : undefined,
}
}
function toBatchRows(rows: AgentDeployRow[]): BatchCommandRow[] {
return rows.map((row) => ({
nodeId: row.nodeId,

View File

@@ -81,7 +81,7 @@ export function BatchCommandTable({ rows, onRetryNode }: Props) {
}
return (
<Text style={{
fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all',
fontSize: 12, wordBreak: 'break-all',
opacity: left === 0 ? 0.4 : 1,
}}>
{cmd as string}

View File

@@ -121,7 +121,7 @@ export default function NodesPage() {
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
Token24 Token 便
</Text>
<Text copyable style={{ fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all' }}>
<Text copyable style={{ fontSize: 12, wordBreak: 'break-all' }}>
{newToken}
</Text>
</div>
@@ -138,7 +138,7 @@ export default function NodesPage() {
render: (name: string, record: NodeSummary) => (
<Space>
{record.isLocal ? <IconDesktop style={{ color: 'var(--color-primary-6)' }} /> : <IconCloudDownload />}
<Text bold>{name}</Text>
<Text>{name}</Text>
{record.isLocal && <Tag color="arcoblue" size="small" bordered></Tag>}
</Space>
),

View File

@@ -17,21 +17,32 @@ describe('install command builders', () => {
'https://master.example.com/install/abc',
)
expect(cmd).toContain('/tmp/bx-agent-install.sh')
expect(cmd).toContain('mktemp /tmp/bx-agent-install.XXXXXX')
expect(cmd).toContain("'https://master.example.com/install/abc'")
expect(cmd).toContain('non-script content')
expect(cmd).toContain('umask 077')
expect(cmd).toContain('rm -f "$tmp"')
})
it('keeps URL install command as primary even when embedded script is available', () => {
it('keeps the one-time URL as the primary install command', () => {
const cmd = buildAgentInstallCommand(
'https://master.example.com/api/install/abc',
'https://master.example.com/install/abc',
'IyEvYmluL3NoCg==',
)
expect(cmd).toContain('https://master.example.com/api/install/abc')
expect(cmd).toContain('https://master.example.com/install/abc')
expect(cmd).not.toContain('IyEvYmluL3NoCg==')
})
it('binds proxy and private CA settings to installer downloads', () => {
const cmd = buildAgentInstallCommand(
'https://master.internal/api/install/abc',
undefined,
{ proxyUrl: 'socks5h://127.0.0.1:1080', caCertFile: '/etc/backupx-agent/ca.pem' },
)
expect(cmd).toContain("--proxy 'socks5h://127.0.0.1:1080'")
expect(cmd).toContain("--cacert '/etc/backupx-agent/ca.pem'")
})
it('builds embedded fallback command explicitly', () => {

View File

@@ -12,16 +12,34 @@ function runScriptCommand(path: string) {
return `if [ "$(id -u)" -eq 0 ]; then sh ${path}; else sudo sh ${path}; fi`
}
export function buildAgentInstallCommand(url: string, fallbackUrl?: string, _scriptBase64?: string) {
export interface InstallFetchOptions {
proxyUrl?: string
caCertFile?: string
}
function curlFetch(url: string, destination: string, options: InstallFetchOptions) {
const args = ['curl', '-fsS']
if (options.proxyUrl?.trim()) {
args.push('--proxy', shellQuote(options.proxyUrl.trim()))
}
if (options.caCertFile?.trim()) {
args.push('--cacert', shellQuote(options.caCertFile.trim()))
}
args.push(shellQuote(url), '-o', destination)
return args.join(' ')
}
export function buildAgentInstallCommand(url: string, fallbackUrl?: string, options: InstallFetchOptions = {}) {
const primary = url.trim()
const fallback = (fallbackUrl || legacyInstallUrl(primary)).trim()
const urls = fallback && fallback !== primary ? [primary, fallback] : [primary]
const marker = shellQuote(INSTALL_MAGIC_MARKER)
const fetchScript = urls.length > 1
? `(curl -fsSL ${shellQuote(urls[0])} -o "$tmp" && grep -q ${marker} "$tmp" || curl -fsSL ${shellQuote(urls[1])} -o "$tmp")`
: `(curl -fsSL ${shellQuote(urls[0])} -o "$tmp" && grep -q ${marker} "$tmp")`
? `(${curlFetch(urls[0], '"$tmp"', options)} && grep -q ${marker} "$tmp" || ${curlFetch(urls[1], '"$tmp"', options)})`
: `(${curlFetch(urls[0], '"$tmp"', options)} && grep -q ${marker} "$tmp")`
return [
'umask 077',
'tmp=$(mktemp)',
fetchScript,
`{ grep -q ${marker} "$tmp" || { echo 'BackupX install endpoint returned non-script content; check reverse proxy /api/install or /install forwarding.' >&2; head -5 "$tmp" >&2; false; }; }`,
@@ -29,24 +47,27 @@ export function buildAgentInstallCommand(url: string, fallbackUrl?: string, _scr
].join(' && ') + '; rc=$?; rm -f "$tmp"; test $rc -eq 0'
}
export function buildAgentDownloadCommand(url: string, fallbackUrl?: string, _scriptBase64?: string) {
export function buildAgentDownloadCommand(url: string, fallbackUrl?: string, options: InstallFetchOptions = {}) {
const primary = url.trim()
const fallback = (fallbackUrl || legacyInstallUrl(primary)).trim()
const marker = shellQuote(INSTALL_MAGIC_MARKER)
const fetchScript = fallback && fallback !== primary
? `(curl -fsSL ${shellQuote(primary)} -o /tmp/bx-agent-install.sh && grep -q ${marker} /tmp/bx-agent-install.sh || curl -fsSL ${shellQuote(fallback)} -o /tmp/bx-agent-install.sh)`
: `(curl -fsSL ${shellQuote(primary)} -o /tmp/bx-agent-install.sh && grep -q ${marker} /tmp/bx-agent-install.sh)`
? `(${curlFetch(primary, '"$tmp"', options)} && grep -q ${marker} "$tmp" || ${curlFetch(fallback, '"$tmp"', options)})`
: `(${curlFetch(primary, '"$tmp"', options)} && grep -q ${marker} "$tmp")`
return [
'umask 077',
'tmp=$(mktemp /tmp/bx-agent-install.XXXXXX)',
fetchScript,
`{ grep -q ${marker} /tmp/bx-agent-install.sh || { echo 'BackupX install endpoint returned non-script content; check reverse proxy /api/install or /install forwarding.' >&2; head -5 /tmp/bx-agent-install.sh >&2; false; }; }`,
runScriptCommand('/tmp/bx-agent-install.sh'),
].join(' && ')
`{ grep -q ${marker} "$tmp" || { echo 'BackupX install endpoint returned non-script content; check reverse proxy /api/install or /install forwarding.' >&2; head -5 "$tmp" >&2; false; }; }`,
runScriptCommand('"$tmp"'),
].join(' && ') + '; rc=$?; rm -f "$tmp"; test $rc -eq 0'
}
export function buildEmbeddedAgentInstallCommand(scriptBase64: string) {
const marker = shellQuote(INSTALL_MAGIC_MARKER)
return [
'umask 077',
'enc=$(mktemp)',
'tmp=$(mktemp)',
`printf %s ${shellQuote(scriptBase64.trim())} > "$enc"`,

View File

@@ -76,6 +76,24 @@ describe('createAgentDeployFlow', () => {
})
})
it('uses restricted-network options in batch install commands', async () => {
const flow = createAgentDeployFlow({
batchCreateNodes: async () => [{ id: 1, name: 'restricted' }],
createInstallToken: async () => tokenResult({
url: 'https://master.internal/api/install/install-token',
fallbackUrl: 'https://master.internal/install/install-token',
}),
})
const result = await flow.submitNewNodes(['restricted'], {
...deployOptions(),
proxyUrl: 'socks5h://127.0.0.1:1080',
caCertFile: '/etc/backupx-agent/ca.pem',
})
expect(result.rows[0].command).toContain("--proxy 'socks5h://127.0.0.1:1080'")
expect(result.rows[0].command).toContain("--cacert '/etc/backupx-agent/ca.pem'")
})
it('rejects duplicate names before creating nodes', async () => {
const flow = createAgentDeployFlow({
batchCreateNodes: async () => {

View File

@@ -41,7 +41,7 @@ export function createAgentDeployFlow(deps: AgentDeployFlowDeps) {
const issueTokenForNode = async (node: AgentDeployNode, input: InstallTokenInput): Promise<AgentDeployRow> => {
try {
const token = await deps.createInstallToken(node.id, input)
return readyRow(node, token)
return readyRow(node, token, input)
} catch (error) {
return {
nodeId: node.id,
@@ -77,12 +77,15 @@ export function useAgentDeployFlow() {
return useMemo(() => createAgentDeployFlow({ batchCreateNodes, createInstallToken }), [])
}
function readyRow(node: AgentDeployNode, token: InstallTokenResult): AgentDeployRow {
function readyRow(node: AgentDeployNode, token: InstallTokenResult, input: InstallTokenInput): AgentDeployRow {
return {
nodeId: node.id,
nodeName: node.name,
status: 'ready',
command: buildAgentInstallCommand(token.url, token.fallbackUrl),
command: buildAgentInstallCommand(token.url, token.fallbackUrl, {
proxyUrl: input.proxyUrl,
caCertFile: input.caCertFile,
}),
expiresAt: token.expiresAt,
installToken: token,
embeddedCommand: token.scriptBase64

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { validateAgentConnection, type AgentConnectionValue } from './AgentConnectionOptions'
function connection(patch: Partial<AgentConnectionValue> = {}): AgentConnectionValue {
return {
connectionMode: 'restricted',
agentMasterUrl: '',
proxyUrl: '',
caCertFile: '',
...patch,
}
}
describe('validateAgentConnection', () => {
it('accepts direct connectivity without overrides', () => {
expect(validateAgentConnection(connection({ connectionMode: 'direct' }))).toBe('')
})
it('accepts an SSH local-forward URL and SOCKS5 proxy', () => {
expect(validateAgentConnection(connection({ agentMasterUrl: 'http://127.0.0.1:18340' }))).toBe('')
expect(validateAgentConnection(connection({ proxyUrl: 'socks5h://127.0.0.1:1080' }))).toBe('')
})
it('rejects empty restricted settings and relative CA paths', () => {
expect(validateAgentConnection(connection())).not.toBe('')
expect(validateAgentConnection(connection({ caCertFile: 'internal-ca.pem' }))).not.toBe('')
})
it('rejects credentials and shell-unsafe values before submission', () => {
expect(validateAgentConnection(connection({ agentMasterUrl: 'https://user:pass@master.example.com' }))).not.toBe('')
expect(validateAgentConnection(connection({ proxyUrl: 'http://user:pass@proxy.example.com' }))).not.toBe('')
expect(validateAgentConnection(connection({ caCertFile: '/etc/pki/internal ca.pem' }))).not.toBe('')
})
})

View File

@@ -0,0 +1,110 @@
import React from 'react'
import { Form, Input, Radio, Typography } from '@arco-design/web-react'
const { Text } = Typography
export type ConnectionMode = 'direct' | 'restricted'
export interface AgentConnectionValue {
connectionMode: ConnectionMode
agentMasterUrl: string
proxyUrl: string
caCertFile: string
}
interface Props {
value: AgentConnectionValue
onChange: (value: AgentConnectionValue) => void
}
export function AgentConnectionOptions({ value, onChange }: Props) {
const update = (patch: Partial<AgentConnectionValue>) => onChange({ ...value, ...patch })
return (
<>
<Form.Item
label="Agent 网络路径"
extra={<Text type="secondary">Agent 访 Master Master </Text>}
>
<Radio.Group
type="button"
value={value.connectionMode}
onChange={(mode) => update({ connectionMode: mode as ConnectionMode })}
options={[
{ label: '直连', value: 'direct' },
{ label: '代理或堡垒机', value: 'restricted' },
]}
/>
</Form.Item>
{value.connectionMode === 'restricted' && (
<>
<Form.Item
label="Agent 连接地址"
extra={<Text type="secondary"> SSH 使 Master </Text>}
>
<Input
value={value.agentMasterUrl}
placeholder="例如 http://127.0.0.1:18340"
onChange={(agentMasterUrl) => update({ agentMasterUrl })}
/>
</Form.Item>
<Form.Item
label="显式代理 URL"
extra={<Text type="secondary"> httphttpssocks5socks5hSSH 使 socks5h://127.0.0.1:1080。</Text>}
>
<Input
value={value.proxyUrl}
placeholder="可选,例如 socks5h://127.0.0.1:1080"
onChange={(proxyUrl) => update({ proxyUrl })}
/>
</Form.Item>
<Form.Item
label="私有 CA 证书路径"
extra={<Text type="secondary"> PEM Agent </Text>}
>
<Input
value={value.caCertFile}
placeholder="可选,例如 /etc/pki/ca-trust/source/anchors/internal-ca.pem"
onChange={(caCertFile) => update({ caCertFile })}
/>
</Form.Item>
</>
)}
</>
)
}
export function validateAgentConnection(value: AgentConnectionValue) {
if (value.connectionMode === 'direct') return ''
const agentMasterUrl = value.agentMasterUrl.trim()
const proxyUrl = value.proxyUrl.trim()
const caCertFile = value.caCertFile.trim()
if (!agentMasterUrl && !proxyUrl && !caCertFile) {
return '请至少填写 Agent 连接地址、代理 URL 或私有 CA 路径'
}
if (agentMasterUrl) {
try {
const parsed = new URL(agentMasterUrl)
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.host || parsed.username || parsed.password || parsed.search || parsed.hash || /\s/.test(agentMasterUrl)) {
return 'Agent 连接地址必须是不含凭据、查询参数和片段的完整 HTTP(S) URL'
}
} catch {
return 'Agent 连接地址必须是完整的 HTTP 或 HTTPS URL'
}
}
if (proxyUrl) {
try {
const parsed = new URL(proxyUrl)
if (!['http:', 'https:', 'socks5:', 'socks5h:'].includes(parsed.protocol) || !parsed.host || parsed.username || parsed.password || (parsed.pathname !== '' && parsed.pathname !== '/') || parsed.search || parsed.hash || /\s/.test(proxyUrl)) {
return '代理 URL 仅支持无凭据、无路径的 http、https、socks5 或 socks5h 地址'
}
} catch {
return '代理 URL 仅支持 http、https、socks5 或 socks5h'
}
}
if (caCertFile && (!/^\/(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/.test(caCertFile) || caCertFile.split('/').some((part) => part === '..'))) {
return '私有 CA 证书必须使用不含空格或特殊字符的绝对路径'
}
return ''
}

View File

@@ -0,0 +1,30 @@
import React, { type ReactNode } from 'react'
import { Button, Space, Typography } from '@arco-design/web-react'
import { IconCopy } from '../../../components/icons'
const { Text } = Typography
interface Props {
label?: string
command: string
disabled?: boolean
action?: ReactNode
onCopy: (command: string) => void
}
export function InstallCommandBlock({ label, command, disabled, action, onCopy }: Props) {
return (
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 4, marginBottom: 12 }}>
{label && <Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>{label}</Text>}
<Text style={{ fontSize: 13, wordBreak: 'break-all', opacity: disabled ? 0.4 : 1, userSelect: 'all' }}>
{command}
</Text>
<div style={{ marginTop: 8 }}>
<Space>
<Button size="small" icon={<IconCopy />} disabled={disabled} onClick={() => onCopy(command)}></Button>
{action}
</Space>
</div>
</div>
)
}

View File

@@ -32,7 +32,7 @@ export function Step1NodeName({
</div>
{mode === 'single' ? (
<div>
<Text bold style={{ marginBottom: 6, display: 'block' }}></Text>
<Text style={{ marginBottom: 6, display: 'block' }}></Text>
<Input
placeholder="如prod-db-01"
value={singleName}
@@ -42,13 +42,13 @@ export function Step1NodeName({
</div>
) : (
<div>
<Text bold style={{ marginBottom: 6, display: 'block' }}> 50 </Text>
<Text style={{ marginBottom: 6, display: 'block' }}> 50 </Text>
<TextArea
rows={8}
placeholder={'prod-db-01\nprod-db-02\nprod-web-01'}
value={batchText}
onChange={onBatchTextChange}
style={{ fontFamily: 'monospace', fontSize: 13 }}
style={{ fontSize: 13 }}
/>
<Text type="secondary" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { isReleaseVersion } from './Step2DeployOptions'
describe('isReleaseVersion', () => {
it('accepts release tags and rejects source-build versions', () => {
expect(isReleaseVersion('v2.4.0')).toBe(true)
expect(isReleaseVersion('2.4.0-rc.1')).toBe(true)
expect(isReleaseVersion('dev')).toBe(false)
expect(isReleaseVersion('00151e4')).toBe(false)
expect(isReleaseVersion(null)).toBe(false)
})
})

View File

@@ -1,10 +1,11 @@
import React from 'react'
import { Form, Radio, Select, Input, Typography } from '@arco-design/web-react'
import type { InstallMode, InstallArch, InstallSource } from '../../../types/nodes'
import { AgentConnectionOptions, type AgentConnectionValue } from './AgentConnectionOptions'
const { Text } = Typography
export interface DeployOptions {
export interface DeployOptions extends AgentConnectionValue {
mode: InstallMode
arch: InstallArch
agentVersion: string
@@ -21,12 +22,17 @@ interface Props {
export function Step2DeployOptions({ masterVersion, value, onChange }: Props) {
const update = (patch: Partial<DeployOptions>) => onChange({ ...value, ...patch })
const versionKnown = !!masterVersion
const versionKnown = isReleaseVersion(masterVersion)
const versionLoading = masterVersion === null
return (
<Form layout="vertical" size="default">
<Form.Item label="安装模式">
<Form.Item
label="安装模式"
extra={value.mode === 'docker'
? <Text type="warning">Docker Agent 访使 volume systemd</Text>
: undefined}
>
<Radio.Group
type="button"
value={value.mode}
@@ -56,7 +62,9 @@ export function Step2DeployOptions({ masterVersion, value, onChange }: Props) {
extra={
!versionKnown && !versionLoading ? (
<Text type="warning" style={{ fontSize: 12 }}>
Master v1.7.0
{masterVersion
? `当前 Master 版本 ${masterVersion} 不是可下载的 Release请手动输入 Agent Release 标签`
: '未能自动获取 Master 版本,请手动输入 Agent Release 标签(形如 v1.7.0'}
</Text>
) : undefined
}
@@ -106,6 +114,12 @@ export function Step2DeployOptions({ masterVersion, value, onChange }: Props) {
]}
/>
</Form.Item>
<AgentConnectionOptions value={value} onChange={(connection) => update(connection)} />
</Form>
)
}
export function isReleaseVersion(version: string | null) {
return !!version && /^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)
}

View File

@@ -1,9 +1,10 @@
import React, { useEffect, useState } from 'react'
import { Typography, Button, Space, Collapse, Spin, Message, Tag } from '@arco-design/web-react'
import { IconCopy, IconRefresh } from '../../../components/icons'
import { IconRefresh } from '../../../components/icons'
import { fetchScriptPreview } from '../../../services/nodes'
import type { InstallTokenResult, InstallMode } from '../../../types/nodes'
import type { InstallTokenResult } from '../../../types/nodes'
import { buildAgentDownloadCommand, buildAgentInstallCommand, buildEmbeddedAgentInstallCommand } from '../installCommands'
import { InstallCommandBlock } from './InstallCommandBlock'
const { Text } = Typography
@@ -11,12 +12,19 @@ interface Props {
nodeId: number
nodeName: string
token: InstallTokenResult
mode: InstallMode
previewParams: { mode: string; arch: string; agentVersion: string; downloadSrc: string }
previewParams: {
mode: string
arch: string
agentVersion: string
downloadSrc: string
agentMasterUrl?: string
proxyUrl?: string
caCertFile?: string
}
onRegenerate: () => void
}
export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewParams, onRegenerate }: Props) {
export function Step3CommandPreview({ nodeId, nodeName, token, previewParams, onRegenerate }: Props) {
const [remaining, setRemaining] = useState(0)
const [preview, setPreview] = useState<string>('')
const [loadingPreview, setLoadingPreview] = useState(false)
@@ -30,12 +38,10 @@ export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewPara
}, [token.expiresAt])
const expired = remaining === 0
const command = buildAgentInstallCommand(token.url, token.fallbackUrl)
const fallbackCommand = buildAgentDownloadCommand(token.url, token.fallbackUrl)
const fetchOptions = { proxyUrl: previewParams.proxyUrl, caCertFile: previewParams.caCertFile }
const command = buildAgentInstallCommand(token.url, token.fallbackUrl, fetchOptions)
const fallbackCommand = buildAgentDownloadCommand(token.url, token.fallbackUrl, fetchOptions)
const embeddedCommand = token.scriptBase64 ? buildEmbeddedAgentInstallCommand(token.scriptBase64) : null
const dockerComposeCmd = mode === 'docker' && token.composeUrl
? `curl -fsSL ${token.composeUrl} -o docker-compose.yml && docker-compose up -d`
: null
const copy = async (s: string) => {
await navigator.clipboard.writeText(s)
@@ -57,73 +63,28 @@ export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewPara
return (
<div>
<Space style={{ marginBottom: 12 }}>
<Text bold></Text>
<Text></Text>
<Tag>{nodeName}</Tag>
<Tag color={expired ? 'gray' : 'green'}>
{expired ? '已过期' : `有效期 ${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, '0')}`}
</Tag>
</Space>
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 6, marginBottom: 12 }}>
<Text style={{
fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all',
opacity: expired ? 0.4 : 1, userSelect: 'all',
}}>
{command}
</Text>
<div style={{ marginTop: 8 }}>
<Space>
<Button size="small" icon={<IconCopy />} disabled={expired} onClick={() => copy(command)}></Button>
{expired && <Button size="small" type="primary" icon={<IconRefresh />} onClick={onRegenerate}></Button>}
</Space>
</div>
</div>
<InstallCommandBlock
command={command}
disabled={expired}
onCopy={copy}
action={expired ? <Button size="small" type="primary" icon={<IconRefresh />} onClick={onRegenerate}></Button> : undefined}
/>
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 6, marginBottom: 12 }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
/tmp
</Text>
<Text style={{
fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all',
opacity: expired ? 0.4 : 1, userSelect: 'all',
}}>
{fallbackCommand}
</Text>
<div style={{ marginTop: 8 }}>
<Button size="small" icon={<IconCopy />} disabled={expired} onClick={() => copy(fallbackCommand)}></Button>
</div>
</div>
{dockerComposeCmd && (
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 6, marginBottom: 12 }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
使 docker-compose
</Text>
<Text style={{ fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all', opacity: expired ? 0.4 : 1 }}>
{dockerComposeCmd}
</Text>
<div style={{ marginTop: 8 }}>
<Button size="small" icon={<IconCopy />} disabled={expired} onClick={() => copy(dockerComposeCmd)}></Button>
</div>
</div>
)}
<InstallCommandBlock label="或先下载到 /tmp 后执行:" command={fallbackCommand} disabled={expired} onCopy={copy} />
{embeddedCommand && (
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 6, marginBottom: 12 }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
使
</Text>
<Text style={{ fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all', userSelect: 'all' }}>
{embeddedCommand}
</Text>
<div style={{ marginTop: 8 }}>
<Button size="small" icon={<IconCopy />} onClick={() => copy(embeddedCommand)}></Button>
</div>
</div>
<InstallCommandBlock label="安装入口不可达时使用嵌入式备用命令:" command={embeddedCommand} onCopy={copy} />
)}
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}>
install token TTL token
install token TTL Token
</Text>
<Collapse bordered={false} onChange={(_key, keys) => {

View File

@@ -59,7 +59,15 @@ export async function rotateNodeToken(nodeId: number) {
export async function fetchScriptPreview(
nodeId: number,
params: { mode: string; arch: string; agentVersion: string; downloadSrc: string },
params: {
mode: string
arch: string
agentVersion: string
downloadSrc: string
agentMasterUrl?: string
proxyUrl?: string
caCertFile?: string
},
) {
const response = await http.get<string>(`/nodes/${nodeId}/install-script-preview`, {
params,

View File

@@ -51,6 +51,9 @@ export interface InstallTokenInput {
agentVersion: string
downloadSrc: InstallSource
ttlSeconds: number
agentMasterUrl?: string
proxyUrl?: string
caCertFile?: string
}
export interface InstallTokenResult {