mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-27 19:19:35 +08:00
feat(BackupX): 修复跨节点备份恢复终态处理 (#60)
* feat(BackupX): 修复集群部署管理逻辑 * feat(BackupX): 修复节点池任务运行归属 * feat(BackupX): 修复跨节点恢复路由 * feat(BackupX): 修复跨节点备份恢复终态处理 * test(BackupX): 稳定安装流HTTP测试
This commit is contained in:
@@ -1,12 +1,11 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Modal, Steps, Button, Space, Message, Spin, Progress } from '@arco-design/web-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 { 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'
|
||||
import { useAgentDeployFlow, type AgentDeployRow } from './useAgentDeployFlow'
|
||||
|
||||
const Step = Steps.Step
|
||||
|
||||
@@ -25,9 +24,7 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
|
||||
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 deployFlow = useAgentDeployFlow()
|
||||
|
||||
const [deploy, setDeploy] = useState<DeployOptions>({
|
||||
mode: 'systemd',
|
||||
@@ -66,7 +63,6 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
|
||||
setSingleToken(null)
|
||||
setSingleNodeInfo(null)
|
||||
setBatchRows([])
|
||||
setBatchProgress(null)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -102,71 +98,21 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
|
||||
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)
|
||||
const result = await deployFlow.submitExistingNode(fixedNode, deploy)
|
||||
applySingleOrTableResult(result.rows, fixedNode)
|
||||
} 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)
|
||||
const result = await deployFlow.submitNewNodes([singleName.trim()], deploy)
|
||||
applySingleOrTableResult(result.rows)
|
||||
} 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)
|
||||
const result = await deployFlow.submitNewNodes(names, deploy)
|
||||
if (mountedRef.current) setBatchRows(toBatchRows(result.rows))
|
||||
if (result.status === 'partialFailed') {
|
||||
Message.warning('部分节点安装命令生成失败,可在结果表中查看')
|
||||
}
|
||||
}
|
||||
setStep(2)
|
||||
onSuccess()
|
||||
@@ -181,14 +127,12 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
|
||||
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)
|
||||
const row = await deployFlow.regenerateNode(singleNodeInfo, deploy)
|
||||
if (row.status === 'ready' && row.installToken) {
|
||||
setSingleToken(row.installToken)
|
||||
} else {
|
||||
Message.error(row.errorMessage || '重新生成失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '重新生成失败')
|
||||
} finally {
|
||||
@@ -196,6 +140,25 @@ 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)
|
||||
setBatchRows((rows) => rows.map((item) => (
|
||||
item.nodeId === row.nodeId ? toBatchRows([next])[0] : item
|
||||
)))
|
||||
if (next.status === 'ready') {
|
||||
Message.success(`节点「${row.nodeName}」安装命令已重新生成`)
|
||||
} else {
|
||||
Message.error(next.errorMessage || '重试失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '重试失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const previewParams = {
|
||||
mode: deploy.mode,
|
||||
arch: deploy.arch,
|
||||
@@ -225,17 +188,6 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -289,7 +241,7 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
|
||||
onRegenerate={regenerateSingle}
|
||||
/>
|
||||
)}
|
||||
{batchRows.length > 0 && <BatchCommandTable rows={batchRows} />}
|
||||
{batchRows.length > 0 && <BatchCommandTable rows={batchRows} onRetryNode={retryBatchNode} />}
|
||||
<div style={{ marginTop: 24, textAlign: 'right' }}>
|
||||
<Button type="primary" onClick={handleClose}>
|
||||
完成
|
||||
@@ -299,4 +251,31 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
|
||||
function applySingleOrTableResult(rows: AgentDeployRow[], fallbackNode?: { id: number; name: string }) {
|
||||
const row = rows[0]
|
||||
if (!row) return
|
||||
if (row.status === 'ready' && row.installToken) {
|
||||
setSingleNodeInfo({ id: row.nodeId || fallbackNode?.id || 0, name: row.nodeName || fallbackNode?.name || '' })
|
||||
setSingleToken(row.installToken)
|
||||
setBatchRows([])
|
||||
return
|
||||
}
|
||||
setSingleNodeInfo(null)
|
||||
setSingleToken(null)
|
||||
setBatchRows(toBatchRows(rows))
|
||||
Message.error(row.errorMessage || '安装命令生成失败')
|
||||
}
|
||||
}
|
||||
|
||||
function toBatchRows(rows: AgentDeployRow[]): BatchCommandRow[] {
|
||||
return rows.map((row) => ({
|
||||
nodeId: row.nodeId,
|
||||
nodeName: row.nodeName,
|
||||
status: row.status,
|
||||
command: row.command,
|
||||
expiresAt: row.expiresAt,
|
||||
errorMessage: row.errorMessage,
|
||||
embeddedCommand: row.embeddedCommand,
|
||||
}))
|
||||
}
|
||||
|
||||
30
web/src/pages/nodes/BatchCommandTable.test.ts
Normal file
30
web/src/pages/nodes/BatchCommandTable.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { BatchCommandRow } from './BatchCommandTable'
|
||||
import { getExportableBatchRows } from './BatchCommandTable'
|
||||
|
||||
function row(patch: Partial<BatchCommandRow>): BatchCommandRow {
|
||||
return {
|
||||
nodeId: 1,
|
||||
nodeName: 'prod-a',
|
||||
status: 'ready',
|
||||
command: 'curl install',
|
||||
expiresAt: '2099-01-01T00:00:00Z',
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
|
||||
describe('getExportableBatchRows', () => {
|
||||
it('excludes failed and expired commands from batch export', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-05-09T00:00:00Z'))
|
||||
const rows = [
|
||||
row({ nodeId: 1, nodeName: 'ready', expiresAt: '2026-05-09T00:05:00Z' }),
|
||||
row({ nodeId: 2, nodeName: 'failed', status: 'failed', errorMessage: 'failed' }),
|
||||
row({ nodeId: 3, nodeName: 'expired', expiresAt: '2026-05-08T23:59:59Z' }),
|
||||
]
|
||||
|
||||
expect(getExportableBatchRows(rows).map((item) => item.nodeName)).toEqual(['ready'])
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
@@ -1,29 +1,32 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Table, Button, Space, Message, Typography } from '@arco-design/web-react'
|
||||
import { IconCopy, IconDownload } from '@arco-design/web-react/icon'
|
||||
import { Table, Button, Space, Message, Typography, Tag } from '@arco-design/web-react'
|
||||
import { IconCopy, IconDownload, IconRefresh } from '@arco-design/web-react/icon'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
export interface BatchCommandRow {
|
||||
nodeId: number
|
||||
nodeName: string
|
||||
status: 'ready' | 'failed'
|
||||
command: string
|
||||
expiresAt: string
|
||||
errorMessage?: string
|
||||
embeddedCommand?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rows: BatchCommandRow[]
|
||||
onRetryNode?: (row: BatchCommandRow) => void
|
||||
}
|
||||
|
||||
export function BatchCommandTable({ rows }: Props) {
|
||||
export function BatchCommandTable({ rows, onRetryNode }: Props) {
|
||||
const [remaining, setRemaining] = useState<Record<number, number>>({})
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () => {
|
||||
const next: Record<number, number> = {}
|
||||
rows.forEach((r) => {
|
||||
const exp = new Date(r.expiresAt).getTime()
|
||||
next[r.nodeId] = Math.max(0, Math.floor((exp - Date.now()) / 1000))
|
||||
next[r.nodeId] = secondsLeft(r.expiresAt)
|
||||
})
|
||||
setRemaining(next)
|
||||
}
|
||||
@@ -38,12 +41,13 @@ export function BatchCommandTable({ rows }: Props) {
|
||||
}
|
||||
|
||||
const exportAll = () => {
|
||||
const exportRows = getExportableBatchRows(rows)
|
||||
const content = [
|
||||
'#!/bin/sh',
|
||||
'# BackupX Agent 批量部署脚本',
|
||||
'# 使用方法:在目标机逐个执行下面对应节点命令',
|
||||
'',
|
||||
...rows.map((r) => `# --- ${r.nodeName} ---\n${r.command}`),
|
||||
...exportRows.map((r) => `# --- ${r.nodeName} ---\n${r.command}`),
|
||||
].join('\n\n')
|
||||
const blob = new Blob([content], { type: 'text/x-shellscript' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
@@ -61,11 +65,20 @@ export function BatchCommandTable({ rows }: Props) {
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: '节点', dataIndex: 'nodeName', width: 140 },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 90,
|
||||
render: (status: BatchCommandRow['status']) => (
|
||||
status === 'ready' ? <Tag color="green">可执行</Tag> : <Tag color="red">失败</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '安装命令',
|
||||
dataIndex: 'command',
|
||||
render: (cmd: unknown, row: BatchCommandRow) => {
|
||||
const left = remaining[row.nodeId] ?? 0
|
||||
if (row.status === 'failed') {
|
||||
return <Text type="error" style={{ fontSize: 12 }}>{row.errorMessage || '生成安装命令失败'}</Text>
|
||||
}
|
||||
return (
|
||||
<Text style={{
|
||||
fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all',
|
||||
@@ -80,6 +93,9 @@ export function BatchCommandTable({ rows }: Props) {
|
||||
title: '剩余', dataIndex: 'expiresAt', width: 90,
|
||||
render: (_v: unknown, row: BatchCommandRow) => {
|
||||
const left = remaining[row.nodeId] ?? 0
|
||||
if (row.status === 'failed') {
|
||||
return <Text type="secondary" style={{ fontSize: 12 }}>-</Text>
|
||||
}
|
||||
return (
|
||||
<Text type={left === 0 ? 'secondary' : 'primary'} style={{ fontSize: 12 }}>
|
||||
{left === 0 ? '已过期' : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`}
|
||||
@@ -88,10 +104,17 @@ export function BatchCommandTable({ rows }: Props) {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
title: '操作', width: 110,
|
||||
render: (_v: unknown, row: BatchCommandRow) => (
|
||||
<Button size="small" icon={<IconCopy />} onClick={() => copy(row.command)}
|
||||
disabled={(remaining[row.nodeId] ?? 0) === 0}>复制</Button>
|
||||
<Space>
|
||||
{row.status === 'ready' && (
|
||||
<Button size="small" icon={<IconCopy />} onClick={() => copy(row.command)}
|
||||
disabled={(remaining[row.nodeId] ?? 0) === 0}>复制</Button>
|
||||
)}
|
||||
{row.status === 'failed' && onRetryNode && (
|
||||
<Button size="small" icon={<IconRefresh />} onClick={() => onRetryNode(row)}>重试</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
@@ -100,9 +123,22 @@ export function BatchCommandTable({ rows }: Props) {
|
||||
/>
|
||||
<div style={{ marginTop: 12, textAlign: 'right' }}>
|
||||
<Space>
|
||||
<Button icon={<IconDownload />} onClick={exportAll}>导出 .sh</Button>
|
||||
<Button icon={<IconDownload />} onClick={exportAll}
|
||||
disabled={getExportableBatchRows(rows).length === 0}>导出 .sh</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function secondsLeft(expiresAt: string) {
|
||||
if (!expiresAt) {
|
||||
return 0
|
||||
}
|
||||
const exp = new Date(expiresAt).getTime()
|
||||
return Math.max(0, Math.floor((exp - Date.now()) / 1000))
|
||||
}
|
||||
|
||||
export function getExportableBatchRows(rows: BatchCommandRow[]) {
|
||||
return rows.filter((row) => row.status === 'ready' && secondsLeft(row.expiresAt) > 0)
|
||||
}
|
||||
|
||||
21
web/src/pages/nodes/NodesPage.test.ts
Normal file
21
web/src/pages/nodes/NodesPage.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { UserInfo } from '../../services/auth'
|
||||
import { canManageNodes } from './NodesPage'
|
||||
|
||||
function user(role: string): UserInfo {
|
||||
return {
|
||||
id: 1,
|
||||
username: role,
|
||||
displayName: role,
|
||||
role,
|
||||
}
|
||||
}
|
||||
|
||||
describe('canManageNodes', () => {
|
||||
it('allows only admins to manage deployment operations', () => {
|
||||
expect(canManageNodes(user('admin'))).toBe(true)
|
||||
expect(canManageNodes(user('operator'))).toBe(false)
|
||||
expect(canManageNodes(user('viewer'))).toBe(false)
|
||||
expect(canManageNodes(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -10,12 +10,21 @@ import type { NodeSummary } from '../../types/nodes'
|
||||
import { listNodes, deleteNode, updateNode, rotateNodeToken } from '../../services/nodes'
|
||||
import { fetchSystemInfo } from '../../services/system'
|
||||
import { AgentInstallWizard } from './AgentInstallWizard'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { isAdmin } from '../../utils/permissions'
|
||||
import type { UserInfo } from '../../services/auth'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
export function canManageNodes(user: UserInfo | null | undefined): boolean {
|
||||
return isAdmin(user)
|
||||
}
|
||||
|
||||
export default function NodesPage() {
|
||||
const [nodes, setNodes] = useState<NodeSummary[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const currentUser = useAuthStore((state) => state.user)
|
||||
const manageable = canManageNodes(currentUser)
|
||||
|
||||
const [wizardVisible, setWizardVisible] = useState(false)
|
||||
const [wizardFixedNode, setWizardFixedNode] = useState<{ id: number; name: string } | undefined>()
|
||||
@@ -143,38 +152,43 @@ export default function NodesPage() {
|
||||
},
|
||||
{
|
||||
title: '操作', width: 180,
|
||||
render: (_: unknown, record: NodeSummary) => (
|
||||
<Space>
|
||||
<Button type="text" icon={<IconEdit />} size="small"
|
||||
onClick={() => {
|
||||
setEditNode(record); setEditName(record.name)
|
||||
setEditLabels(record.labels || '')
|
||||
setEditMaxConcurrent(record.maxConcurrent || 0)
|
||||
setEditBandwidthLimit(record.bandwidthLimit || '')
|
||||
setEditVisible(true)
|
||||
}} />
|
||||
{!record.isLocal && (
|
||||
<>
|
||||
<Dropdown trigger="click" droplist={(
|
||||
<Menu>
|
||||
<Menu.Item key="install"
|
||||
onClick={() => { setWizardFixedNode({ id: record.id, name: record.name }); setWizardVisible(true) }}>
|
||||
生成安装命令
|
||||
</Menu.Item>
|
||||
<Menu.Item key="rotate" onClick={() => handleRotate(record)}>
|
||||
重新生成 Token
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
)}>
|
||||
<Button type="text" icon={<IconMore />} size="small" />
|
||||
</Dropdown>
|
||||
<Popconfirm title="确定删除该节点?" onOk={() => handleDelete(record.id)}>
|
||||
<Button type="text" status="danger" icon={<IconDelete />} size="small" />
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
render: (_: unknown, record: NodeSummary) => {
|
||||
if (!manageable) {
|
||||
return <Text type="secondary">-</Text>
|
||||
}
|
||||
return (
|
||||
<Space>
|
||||
<Button type="text" icon={<IconEdit />} size="small"
|
||||
onClick={() => {
|
||||
setEditNode(record); setEditName(record.name)
|
||||
setEditLabels(record.labels || '')
|
||||
setEditMaxConcurrent(record.maxConcurrent || 0)
|
||||
setEditBandwidthLimit(record.bandwidthLimit || '')
|
||||
setEditVisible(true)
|
||||
}} />
|
||||
{!record.isLocal && (
|
||||
<>
|
||||
<Dropdown trigger="click" droplist={(
|
||||
<Menu>
|
||||
<Menu.Item key="install"
|
||||
onClick={() => { setWizardFixedNode({ id: record.id, name: record.name }); setWizardVisible(true) }}>
|
||||
生成安装命令
|
||||
</Menu.Item>
|
||||
<Menu.Item key="rotate" onClick={() => handleRotate(record)}>
|
||||
重新生成 Token
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
)}>
|
||||
<Button type="text" icon={<IconMore />} size="small" />
|
||||
</Dropdown>
|
||||
<Popconfirm title="确定删除该节点?" onOk={() => handleDelete(record.id)}>
|
||||
<Button type="text" status="danger" icon={<IconDelete />} size="small" />
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -183,12 +197,12 @@ export default function NodesPage() {
|
||||
<PageHeader
|
||||
title="节点管理"
|
||||
subTitle="管理集群中的服务器节点"
|
||||
extra={
|
||||
extra={manageable ? (
|
||||
<Button type="primary" icon={<IconPlus />}
|
||||
onClick={() => { setWizardFixedNode(undefined); setWizardVisible(true) }}>
|
||||
添加节点
|
||||
</Button>
|
||||
}
|
||||
) : undefined}
|
||||
/>
|
||||
|
||||
<Card style={{ marginTop: 16 }}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildAgentDownloadCommand, buildAgentInstallCommand } from './installCommands'
|
||||
import { buildAgentDownloadCommand, buildAgentInstallCommand, buildEmbeddedAgentInstallCommand } from './installCommands'
|
||||
|
||||
describe('install command builders', () => {
|
||||
it('adds script marker validation and fallback install path', () => {
|
||||
@@ -22,16 +22,24 @@ describe('install command builders', () => {
|
||||
expect(cmd).toContain('non-script content')
|
||||
})
|
||||
|
||||
it('prefers embedded script content when available', () => {
|
||||
it('keeps URL install command as primary even when embedded script is available', () => {
|
||||
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('builds embedded fallback command explicitly', () => {
|
||||
const cmd = buildEmbeddedAgentInstallCommand('IyEvYmluL3NoCg==')
|
||||
|
||||
expect(cmd).toContain('base64 -d')
|
||||
expect(cmd).toContain('base64 -D')
|
||||
expect(cmd).toContain('BACKUPX_AGENT_INSTALL_V1')
|
||||
expect(cmd).toContain("'IyEvYmluL3NoCg=='")
|
||||
expect(cmd).not.toContain('https://master.example.com/api/install/abc')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,19 +12,7 @@ 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) {
|
||||
if (scriptBase64?.trim()) {
|
||||
const marker = shellQuote(INSTALL_MAGIC_MARKER)
|
||||
return [
|
||||
'enc=$(mktemp)',
|
||||
'tmp=$(mktemp)',
|
||||
`printf %s ${shellQuote(scriptBase64.trim())} > "$enc"`,
|
||||
'(base64 -d < "$enc" > "$tmp" 2>/dev/null || base64 -D < "$enc" > "$tmp")',
|
||||
`{ grep -q ${marker} "$tmp" || { echo 'BackupX embedded installer is invalid.' >&2; head -5 "$tmp" >&2; false; }; }`,
|
||||
runScriptCommand('"$tmp"'),
|
||||
].join(' && ') + '; rc=$?; rm -f "$enc" "$tmp"; test $rc -eq 0'
|
||||
}
|
||||
|
||||
export function buildAgentInstallCommand(url: string, fallbackUrl?: string, _scriptBase64?: string) {
|
||||
const primary = url.trim()
|
||||
const fallback = (fallbackUrl || legacyInstallUrl(primary)).trim()
|
||||
const urls = fallback && fallback !== primary ? [primary, fallback] : [primary]
|
||||
@@ -41,17 +29,7 @@ export function buildAgentInstallCommand(url: string, fallbackUrl?: string, scri
|
||||
].join(' && ') + '; rc=$?; rm -f "$tmp"; test $rc -eq 0'
|
||||
}
|
||||
|
||||
export function buildAgentDownloadCommand(url: string, fallbackUrl?: string, scriptBase64?: string) {
|
||||
if (scriptBase64?.trim()) {
|
||||
const marker = shellQuote(INSTALL_MAGIC_MARKER)
|
||||
return [
|
||||
`printf %s ${shellQuote(scriptBase64.trim())} > /tmp/bx-agent-install.b64`,
|
||||
'(base64 -d < /tmp/bx-agent-install.b64 > /tmp/bx-agent-install.sh 2>/dev/null || base64 -D < /tmp/bx-agent-install.b64 > /tmp/bx-agent-install.sh)',
|
||||
`{ grep -q ${marker} /tmp/bx-agent-install.sh || { echo 'BackupX embedded installer is invalid.' >&2; head -5 /tmp/bx-agent-install.sh >&2; false; }; }`,
|
||||
runScriptCommand('/tmp/bx-agent-install.sh'),
|
||||
].join(' && ')
|
||||
}
|
||||
|
||||
export function buildAgentDownloadCommand(url: string, fallbackUrl?: string, _scriptBase64?: string) {
|
||||
const primary = url.trim()
|
||||
const fallback = (fallbackUrl || legacyInstallUrl(primary)).trim()
|
||||
const marker = shellQuote(INSTALL_MAGIC_MARKER)
|
||||
@@ -65,3 +43,15 @@ export function buildAgentDownloadCommand(url: string, fallbackUrl?: string, scr
|
||||
runScriptCommand('/tmp/bx-agent-install.sh'),
|
||||
].join(' && ')
|
||||
}
|
||||
|
||||
export function buildEmbeddedAgentInstallCommand(scriptBase64: string) {
|
||||
const marker = shellQuote(INSTALL_MAGIC_MARKER)
|
||||
return [
|
||||
'enc=$(mktemp)',
|
||||
'tmp=$(mktemp)',
|
||||
`printf %s ${shellQuote(scriptBase64.trim())} > "$enc"`,
|
||||
'(base64 -d < "$enc" > "$tmp" 2>/dev/null || base64 -D < "$enc" > "$tmp")',
|
||||
`{ grep -q ${marker} "$tmp" || { echo 'BackupX embedded installer is invalid.' >&2; head -5 "$tmp" >&2; false; }; }`,
|
||||
runScriptCommand('"$tmp"'),
|
||||
].join(' && ') + '; rc=$?; rm -f "$enc" "$tmp"; test $rc -eq 0'
|
||||
}
|
||||
|
||||
90
web/src/pages/nodes/useAgentDeployFlow.test.ts
Normal file
90
web/src/pages/nodes/useAgentDeployFlow.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { InstallTokenInput, InstallTokenResult } from '../../types/nodes'
|
||||
import { createAgentDeployFlow } from './useAgentDeployFlow'
|
||||
|
||||
function deployOptions(): InstallTokenInput {
|
||||
return {
|
||||
mode: 'systemd',
|
||||
arch: 'auto',
|
||||
agentVersion: 'v2.3.1',
|
||||
downloadSrc: 'github',
|
||||
ttlSeconds: 900,
|
||||
}
|
||||
}
|
||||
|
||||
function tokenResult(overrides: Partial<InstallTokenResult> = {}): InstallTokenResult {
|
||||
return {
|
||||
installToken: 'install-token',
|
||||
expiresAt: '2099-01-01T00:00:00Z',
|
||||
url: 'https://master.example.com/api/install/install-token',
|
||||
fallbackUrl: 'https://master.example.com/install/install-token',
|
||||
scriptBase64: 'IyEvYmluL3NoCg==',
|
||||
composeUrl: '',
|
||||
fallbackComposeUrl: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('createAgentDeployFlow', () => {
|
||||
it('creates one node then issues one install token', async () => {
|
||||
const calls: string[] = []
|
||||
const flow = createAgentDeployFlow({
|
||||
batchCreateNodes: async (names) => {
|
||||
calls.push(`batch:${names.join(',')}`)
|
||||
return [{ id: 7, name: names[0] }]
|
||||
},
|
||||
createInstallToken: async (nodeId) => {
|
||||
calls.push(`token:${nodeId}`)
|
||||
return tokenResult()
|
||||
},
|
||||
})
|
||||
|
||||
const result = await flow.submitNewNodes(['prod-a'], deployOptions())
|
||||
|
||||
expect(calls).toEqual(['batch:prod-a', 'token:7'])
|
||||
expect(result.status).toBe('ready')
|
||||
expect(result.rows).toHaveLength(1)
|
||||
expect(result.rows[0]).toMatchObject({
|
||||
nodeId: 7,
|
||||
nodeName: 'prod-a',
|
||||
status: 'ready',
|
||||
})
|
||||
expect(result.rows[0].command).toContain('/api/install/install-token')
|
||||
expect(result.rows[0].embeddedCommand).toContain('IyEvYmluL3NoCg==')
|
||||
})
|
||||
|
||||
it('returns partialFailed when one batch token request fails', async () => {
|
||||
const flow = createAgentDeployFlow({
|
||||
batchCreateNodes: async (names) => names.map((name, index) => ({ id: index + 1, name })),
|
||||
createInstallToken: async (nodeId) => {
|
||||
if (nodeId === 2) {
|
||||
throw new Error('token service unavailable')
|
||||
}
|
||||
return tokenResult({ installToken: `tok-${nodeId}`, url: `https://master.example.com/api/install/tok-${nodeId}` })
|
||||
},
|
||||
})
|
||||
|
||||
const result = await flow.submitNewNodes(['prod-a', 'prod-b', 'prod-c'], deployOptions())
|
||||
|
||||
expect(result.status).toBe('partialFailed')
|
||||
expect(result.rows.map((row) => row.status)).toEqual(['ready', 'failed', 'ready'])
|
||||
expect(result.rows[1]).toMatchObject({
|
||||
nodeId: 2,
|
||||
nodeName: 'prod-b',
|
||||
status: 'failed',
|
||||
errorMessage: 'token service unavailable',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects duplicate names before creating nodes', async () => {
|
||||
const flow = createAgentDeployFlow({
|
||||
batchCreateNodes: async () => {
|
||||
throw new Error('should not call batchCreateNodes')
|
||||
},
|
||||
createInstallToken: async () => tokenResult(),
|
||||
})
|
||||
|
||||
await expect(flow.submitNewNodes(['prod-a', ' prod-a '], deployOptions()))
|
||||
.rejects.toThrow('批次内重复节点名')
|
||||
})
|
||||
})
|
||||
146
web/src/pages/nodes/useAgentDeployFlow.ts
Normal file
146
web/src/pages/nodes/useAgentDeployFlow.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { BatchCreateResult, InstallTokenInput, InstallTokenResult } from '../../types/nodes'
|
||||
import { batchCreateNodes, createInstallToken } from '../../services/nodes'
|
||||
import {
|
||||
buildAgentInstallCommand,
|
||||
buildEmbeddedAgentInstallCommand,
|
||||
} from './installCommands'
|
||||
|
||||
export type DeployRowStatus = 'ready' | 'failed'
|
||||
export type DeployResultStatus = 'ready' | 'partialFailed'
|
||||
|
||||
export interface AgentDeployNode {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface AgentDeployRow {
|
||||
nodeId: number
|
||||
nodeName: string
|
||||
status: DeployRowStatus
|
||||
command: string
|
||||
expiresAt: string
|
||||
installToken?: InstallTokenResult
|
||||
embeddedCommand?: string
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
export interface AgentDeployResult {
|
||||
status: DeployResultStatus
|
||||
rows: AgentDeployRow[]
|
||||
}
|
||||
|
||||
interface AgentDeployFlowDeps {
|
||||
batchCreateNodes: (names: string[]) => Promise<BatchCreateResult[]>
|
||||
createInstallToken: (nodeId: number, input: InstallTokenInput) => Promise<InstallTokenResult>
|
||||
}
|
||||
|
||||
const TOKEN_CONCURRENCY = 4
|
||||
|
||||
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)
|
||||
} catch (error) {
|
||||
return {
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
status: 'failed',
|
||||
command: '',
|
||||
expiresAt: '',
|
||||
errorMessage: resolveErrorMessage(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async submitNewNodes(names: string[], input: InstallTokenInput): Promise<AgentDeployResult> {
|
||||
const cleanedNames = normalizeNodeNames(names)
|
||||
const nodes = await deps.batchCreateNodes(cleanedNames)
|
||||
const rows = await mapWithConcurrency(nodes, TOKEN_CONCURRENCY, (node) => issueTokenForNode(node, input))
|
||||
return resultFromRows(rows)
|
||||
},
|
||||
|
||||
async submitExistingNode(node: AgentDeployNode, input: InstallTokenInput): Promise<AgentDeployResult> {
|
||||
const row = await issueTokenForNode(node, input)
|
||||
return resultFromRows([row])
|
||||
},
|
||||
|
||||
async regenerateNode(node: AgentDeployNode, input: InstallTokenInput): Promise<AgentDeployRow> {
|
||||
return issueTokenForNode(node, input)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function useAgentDeployFlow() {
|
||||
return useMemo(() => createAgentDeployFlow({ batchCreateNodes, createInstallToken }), [])
|
||||
}
|
||||
|
||||
function readyRow(node: AgentDeployNode, token: InstallTokenResult): AgentDeployRow {
|
||||
return {
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
status: 'ready',
|
||||
command: buildAgentInstallCommand(token.url, token.fallbackUrl),
|
||||
expiresAt: token.expiresAt,
|
||||
installToken: token,
|
||||
embeddedCommand: token.scriptBase64
|
||||
? buildEmbeddedAgentInstallCommand(token.scriptBase64)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function resultFromRows(rows: AgentDeployRow[]): AgentDeployResult {
|
||||
return {
|
||||
status: rows.some((row) => row.status === 'failed') ? 'partialFailed' : 'ready',
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeNodeNames(names: string[]) {
|
||||
const cleaned = names.map((name) => name.trim()).filter(Boolean)
|
||||
if (cleaned.length === 0) {
|
||||
throw new Error('请至少输入一个节点名称')
|
||||
}
|
||||
if (cleaned.length > 50) {
|
||||
throw new Error('单次最多创建 50 个节点')
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
for (const name of cleaned) {
|
||||
if (seen.has(name)) {
|
||||
throw new Error(`批次内重复节点名:${name}`)
|
||||
}
|
||||
seen.add(name)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
mapper: (item: T, index: number) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
const results = new Array<R>(items.length)
|
||||
let nextIndex = 0
|
||||
const workerCount = Math.min(concurrency, items.length)
|
||||
const workers = Array.from({ length: workerCount }, async () => {
|
||||
for (;;) {
|
||||
const index = nextIndex
|
||||
nextIndex += 1
|
||||
if (index >= items.length) {
|
||||
return
|
||||
}
|
||||
results[index] = await mapper(items[index], index)
|
||||
}
|
||||
})
|
||||
await Promise.all(workers)
|
||||
return results
|
||||
}
|
||||
|
||||
function resolveErrorMessage(error: unknown) {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message
|
||||
}
|
||||
return '生成安装命令失败'
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Typography, Button, Space, Collapse, Spin, Message, Tag } from '@arco-d
|
||||
import { IconCopy, IconRefresh } from '@arco-design/web-react/icon'
|
||||
import { fetchScriptPreview } from '../../../services/nodes'
|
||||
import type { InstallTokenResult, InstallMode } from '../../../types/nodes'
|
||||
import { buildAgentDownloadCommand, buildAgentInstallCommand } from '../installCommands'
|
||||
import { buildAgentDownloadCommand, buildAgentInstallCommand, buildEmbeddedAgentInstallCommand } from '../installCommands'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@@ -30,8 +30,9 @@ export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewPara
|
||||
}, [token.expiresAt])
|
||||
|
||||
const expired = remaining === 0
|
||||
const command = buildAgentInstallCommand(token.url, token.fallbackUrl, token.scriptBase64)
|
||||
const fallbackCommand = buildAgentDownloadCommand(token.url, token.fallbackUrl, token.scriptBase64)
|
||||
const command = buildAgentInstallCommand(token.url, token.fallbackUrl)
|
||||
const fallbackCommand = buildAgentDownloadCommand(token.url, token.fallbackUrl)
|
||||
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
|
||||
@@ -107,8 +108,22 @@ export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewPara
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}>
|
||||
安装命令包含节点 token,请仅在目标机执行并妥善保存;公开安装链接会在 TTL 到期或首次消费后作废。
|
||||
主安装命令包含公开 install token,会在 TTL 到期或首次消费后作废;嵌入式备用命令包含完整节点 token,不依赖公开链接消费状态,请仅在目标机执行并妥善保存。
|
||||
</Text>
|
||||
|
||||
<Collapse bordered={false} onChange={(_key, keys) => {
|
||||
|
||||
Reference in New Issue
Block a user