mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-17 00:47:37 +08:00
功能: 一键部署 Agent 向导 (#44)
This commit is contained in:
301
web/src/pages/nodes/AgentInstallWizard.tsx
Normal file
301
web/src/pages/nodes/AgentInstallWizard.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
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'
|
||||
|
||||
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: `curl -fsSL ${tok.url} | sudo sh`,
|
||||
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>
|
||||
)
|
||||
}
|
||||
108
web/src/pages/nodes/BatchCommandTable.tsx
Normal file
108
web/src/pages/nodes/BatchCommandTable.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
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'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
export interface BatchCommandRow {
|
||||
nodeId: number
|
||||
nodeName: string
|
||||
command: string
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rows: BatchCommandRow[]
|
||||
}
|
||||
|
||||
export function BatchCommandTable({ rows }: 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))
|
||||
})
|
||||
setRemaining(next)
|
||||
}
|
||||
tick()
|
||||
const id = setInterval(tick, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [rows])
|
||||
|
||||
const copy = async (s: string) => {
|
||||
await navigator.clipboard.writeText(s)
|
||||
Message.success('已复制')
|
||||
}
|
||||
|
||||
const exportAll = () => {
|
||||
const content = [
|
||||
'#!/bin/sh',
|
||||
'# BackupX Agent 批量部署脚本',
|
||||
'# 使用方法:在目标机逐个执行下面对应节点命令',
|
||||
'',
|
||||
...rows.map((r) => `# --- ${r.nodeName} ---\n${r.command}`),
|
||||
].join('\n\n')
|
||||
const blob = new Blob([content], { type: 'text/x-shellscript' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `backupx-batch-install-${new Date().toISOString().slice(0, 10)}.sh`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: '节点', dataIndex: 'nodeName', width: 140 },
|
||||
{
|
||||
title: '安装命令',
|
||||
dataIndex: 'command',
|
||||
render: (cmd: unknown, row: BatchCommandRow) => {
|
||||
const left = remaining[row.nodeId] ?? 0
|
||||
return (
|
||||
<Text style={{
|
||||
fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all',
|
||||
opacity: left === 0 ? 0.4 : 1,
|
||||
}}>
|
||||
{cmd as string}
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '剩余', dataIndex: 'expiresAt', width: 90,
|
||||
render: (_v: unknown, row: BatchCommandRow) => {
|
||||
const left = remaining[row.nodeId] ?? 0
|
||||
return (
|
||||
<Text type={left === 0 ? 'secondary' : 'primary'} style={{ fontSize: 12 }}>
|
||||
{left === 0 ? '已过期' : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`}
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_v: unknown, row: BatchCommandRow) => (
|
||||
<Button size="small" icon={<IconCopy />} onClick={() => copy(row.command)}
|
||||
disabled={(remaining[row.nodeId] ?? 0) === 0}>复制</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={rows}
|
||||
rowKey="nodeId"
|
||||
/>
|
||||
<div style={{ marginTop: 12, textAlign: 'right' }}>
|
||||
<Space>
|
||||
<Button icon={<IconDownload />} onClick={exportAll}>导出 .sh</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +1,27 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Table, Button, Space, Tag, Typography, PageHeader, Modal, Input, Message, Badge, Popconfirm, Card, Descriptions, Empty
|
||||
Table, Button, Space, Tag, Typography, PageHeader, Modal, Input, Message, Badge, Popconfirm, Card,
|
||||
Empty, Dropdown, Menu,
|
||||
} from '@arco-design/web-react'
|
||||
import {
|
||||
IconPlus, IconDelete, IconDesktop, IconCloudDownload, IconEdit
|
||||
IconPlus, IconDelete, IconDesktop, IconCloudDownload, IconEdit, IconMore,
|
||||
} from '@arco-design/web-react/icon'
|
||||
import type { NodeSummary } from '../../types/nodes'
|
||||
import { listNodes, createNode, deleteNode, updateNode } from '../../services/nodes'
|
||||
import { listNodes, deleteNode, updateNode, rotateNodeToken } from '../../services/nodes'
|
||||
import { fetchSystemInfo } from '../../services/system'
|
||||
import { AgentInstallWizard } from './AgentInstallWizard'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
const { Text } = Typography
|
||||
|
||||
export default function NodesPage() {
|
||||
const [nodes, setNodes] = useState<NodeSummary[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [createVisible, setCreateVisible] = useState(false)
|
||||
const [newNodeName, setNewNodeName] = useState('')
|
||||
const [newToken, setNewToken] = useState('')
|
||||
|
||||
// 编辑状态
|
||||
const [wizardVisible, setWizardVisible] = useState(false)
|
||||
const [wizardFixedNode, setWizardFixedNode] = useState<{ id: number; name: string } | undefined>()
|
||||
// null = 拉取中 / 未知;空字符串 = 拉取失败(UI 将要求用户手动输入版本,避免生成无效 URL)
|
||||
const [masterVersion, setMasterVersion] = useState<string | null>(null)
|
||||
|
||||
const [editVisible, setEditVisible] = useState(false)
|
||||
const [editNode, setEditNode] = useState<NodeSummary | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
@@ -34,22 +38,14 @@ export default function NodesPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchNodes() }, [fetchNodes])
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newNodeName.trim()) {
|
||||
Message.warning('请输入节点名称')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await createNode(newNodeName.trim())
|
||||
setNewToken(result.token)
|
||||
Message.success('节点创建成功')
|
||||
fetchNodes()
|
||||
} catch {
|
||||
Message.error('创建节点失败')
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
fetchNodes()
|
||||
// 取 Master 版本号作为 Wizard agentVersion 默认值。
|
||||
// 拉取失败或字段缺失时置为空串,Wizard 会提示用户手动输入。
|
||||
fetchSystemInfo().then((info) => {
|
||||
setMasterVersion(info?.version || '')
|
||||
}).catch(() => setMasterVersion(''))
|
||||
}, [fetchNodes])
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
@@ -76,10 +72,30 @@ export default function NodesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRotate = async (record: NodeSummary) => {
|
||||
try {
|
||||
const { newToken } = await rotateNodeToken(record.id)
|
||||
Modal.success({
|
||||
title: 'Token 已轮换',
|
||||
content: (
|
||||
<div>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
新 Token(24 小时内新旧 Token 均可认证,便于滚动替换):
|
||||
</Text>
|
||||
<Text copyable style={{ fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all' }}>
|
||||
{newToken}
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
} catch {
|
||||
Message.error('轮换 Token 失败')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '节点名称',
|
||||
dataIndex: 'name',
|
||||
title: '节点名称', dataIndex: 'name',
|
||||
render: (name: string, record: NodeSummary) => (
|
||||
<Space>
|
||||
{record.isLocal ? <IconDesktop style={{ color: 'var(--color-primary-6)' }} /> : <IconCloudDownload />}
|
||||
@@ -89,60 +105,48 @@ export default function NodesPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (status: string) => {
|
||||
if (status === 'online') return <Badge status="success" text="在线" />
|
||||
return <Badge status="default" text="离线" />
|
||||
},
|
||||
title: '状态', dataIndex: 'status', width: 100,
|
||||
render: (status: string) => status === 'online'
|
||||
? <Badge status="success" text="在线" />
|
||||
: <Badge status="default" text="离线" />,
|
||||
},
|
||||
{ title: '主机名', dataIndex: 'hostname', render: (v: string) => v || '-' },
|
||||
{ title: 'IP 地址', dataIndex: 'ipAddress', render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '主机名',
|
||||
dataIndex: 'hostname',
|
||||
render: (v: string) => v || '-',
|
||||
title: '系统', dataIndex: 'os', width: 120,
|
||||
render: (_: string, record: NodeSummary) => record.os
|
||||
? <Tag bordered>{record.os}/{record.arch}</Tag> : '-',
|
||||
},
|
||||
{ title: 'Agent 版本', dataIndex: 'agentVersion', width: 100, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: 'IP 地址',
|
||||
dataIndex: 'ipAddress',
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '系统',
|
||||
dataIndex: 'os',
|
||||
width: 120,
|
||||
render: (_: string, record: NodeSummary) => {
|
||||
if (!record.os) return '-'
|
||||
return <Tag bordered>{record.os}/{record.arch}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Agent 版本',
|
||||
dataIndex: 'agentVersion',
|
||||
width: 100,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '最后活跃',
|
||||
dataIndex: 'lastSeen',
|
||||
width: 170,
|
||||
title: '最后活跃', dataIndex: 'lastSeen', width: 170,
|
||||
render: (v: string) => v ? new Date(v).toLocaleString('zh-CN') : '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
title: '操作', width: 180,
|
||||
render: (_: unknown, record: NodeSummary) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<IconEdit />}
|
||||
size="small"
|
||||
onClick={() => { setEditNode(record); setEditName(record.name); setEditVisible(true) }}
|
||||
/>
|
||||
<Button type="text" icon={<IconEdit />} size="small"
|
||||
onClick={() => { setEditNode(record); setEditName(record.name); setEditVisible(true) }} />
|
||||
{!record.isLocal && (
|
||||
<Popconfirm title="确定删除该节点?" onOk={() => handleDelete(record.id)}>
|
||||
<Button type="text" status="danger" icon={<IconDelete />} size="small" />
|
||||
</Popconfirm>
|
||||
<>
|
||||
<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>
|
||||
),
|
||||
@@ -155,90 +159,33 @@ export default function NodesPage() {
|
||||
title="节点管理"
|
||||
subTitle="管理集群中的服务器节点"
|
||||
extra={
|
||||
<Button type="primary" icon={<IconPlus />} onClick={() => { setCreateVisible(true); setNewToken(''); setNewNodeName('') }}>
|
||||
<Button type="primary" icon={<IconPlus />}
|
||||
onClick={() => { setWizardFixedNode(undefined); setWizardVisible(true) }}>
|
||||
添加节点
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card style={{ marginTop: 16 }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
data={nodes}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
noDataElement={<Empty description="暂无节点数据,系统将自动创建本机节点" />}
|
||||
/>
|
||||
<Table columns={columns} data={nodes} rowKey="id" loading={loading} pagination={false}
|
||||
noDataElement={<Empty description="暂无节点数据,系统将自动创建本机节点" />} />
|
||||
</Card>
|
||||
|
||||
{/* 添加节点弹窗 */}
|
||||
<Modal
|
||||
title="添加远程节点"
|
||||
visible={createVisible}
|
||||
onCancel={() => setCreateVisible(false)}
|
||||
style={{ width: 640 }}
|
||||
footer={newToken ? (
|
||||
<Button type="primary" onClick={() => setCreateVisible(false)}>完成</Button>
|
||||
) : undefined}
|
||||
onOk={handleCreate}
|
||||
okText="创建"
|
||||
>
|
||||
{!newToken ? (
|
||||
<Input
|
||||
placeholder="输入节点名称,如:生产服务器-A"
|
||||
value={newNodeName}
|
||||
onChange={setNewNodeName}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<Descriptions column={1} border data={[
|
||||
{ label: '节点名称', value: newNodeName },
|
||||
{ label: '认证令牌', value: <Text copyable style={{ wordBreak: 'break-all', fontSize: 12, fontFamily: 'monospace' }}>{newToken}</Text> },
|
||||
]} />
|
||||
<div style={{ marginTop: 12, padding: '8px 12px', background: 'var(--color-fill-2)', borderRadius: 6 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
令牌仅显示一次,请妥善保存。将其配置到远程服务器后,Agent 会自动连接 Master。
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Text bold style={{ fontSize: 13 }}>Agent 部署步骤</Text>
|
||||
<ol style={{ fontSize: 12, color: 'var(--color-text-2)', paddingLeft: 20, marginTop: 8 }}>
|
||||
<li>把 BackupX 二进制上传到目标服务器(与 Master 同一个可执行文件)</li>
|
||||
<li>通过以下命令启动 Agent(替换 MASTER_URL):</li>
|
||||
</ol>
|
||||
<div style={{ background: 'var(--color-fill-2)', padding: '8px 12px', borderRadius: 6, marginTop: 4 }}>
|
||||
<Text copyable style={{ fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all' }}>
|
||||
{`backupx agent --master ${window.location.origin} --token ${newToken}`}
|
||||
</Text>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
|
||||
或使用配置文件 / 环境变量:<br />
|
||||
<code>BACKUPX_AGENT_MASTER={window.location.origin}</code><br />
|
||||
<code>BACKUPX_AGENT_TOKEN={newToken}</code>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
<AgentInstallWizard
|
||||
visible={wizardVisible}
|
||||
onClose={() => setWizardVisible(false)}
|
||||
onSuccess={fetchNodes}
|
||||
masterVersion={masterVersion}
|
||||
fixedNode={wizardFixedNode}
|
||||
/>
|
||||
|
||||
{/* 编辑节点弹窗 */}
|
||||
<Modal
|
||||
title="编辑节点"
|
||||
visible={editVisible}
|
||||
onCancel={() => setEditVisible(false)}
|
||||
onOk={handleEdit}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Modal title="编辑节点" visible={editVisible}
|
||||
onCancel={() => setEditVisible(false)} onOk={handleEdit}
|
||||
okText="保存" cancelText="取消">
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text type="secondary">节点名称</Text>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="输入节点名称"
|
||||
value={editName}
|
||||
onChange={setEditName}
|
||||
/>
|
||||
<Input placeholder="输入节点名称" value={editName} onChange={setEditName} />
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
|
||||
60
web/src/pages/nodes/wizard/Step1NodeName.tsx
Normal file
60
web/src/pages/nodes/wizard/Step1NodeName.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Radio, Input, Typography } from '@arco-design/web-react'
|
||||
|
||||
const { Text } = Typography
|
||||
const TextArea = Input.TextArea
|
||||
|
||||
export type Mode = 'single' | 'batch'
|
||||
|
||||
interface Props {
|
||||
mode: Mode
|
||||
onModeChange: (m: Mode) => void
|
||||
singleName: string
|
||||
onSingleNameChange: (v: string) => void
|
||||
batchText: string
|
||||
onBatchTextChange: (v: string) => void
|
||||
}
|
||||
|
||||
export function Step1NodeName({
|
||||
mode, onModeChange, singleName, onSingleNameChange, batchText, onBatchTextChange,
|
||||
}: Props) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Radio.Group
|
||||
type="button"
|
||||
value={mode}
|
||||
onChange={(v) => onModeChange(v as Mode)}
|
||||
options={[
|
||||
{ label: '单节点', value: 'single' },
|
||||
{ label: '批量创建', value: 'batch' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{mode === 'single' ? (
|
||||
<div>
|
||||
<Text bold style={{ marginBottom: 6, display: 'block' }}>节点名称</Text>
|
||||
<Input
|
||||
placeholder="如:prod-db-01"
|
||||
value={singleName}
|
||||
onChange={onSingleNameChange}
|
||||
maxLength={128}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Text bold 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 }}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
|
||||
空行自动忽略;重名会在提交时报错
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
111
web/src/pages/nodes/wizard/Step2DeployOptions.tsx
Normal file
111
web/src/pages/nodes/wizard/Step2DeployOptions.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React from 'react'
|
||||
import { Form, Radio, Select, Input, Typography } from '@arco-design/web-react'
|
||||
import type { InstallMode, InstallArch, InstallSource } from '../../../types/nodes'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
export interface DeployOptions {
|
||||
mode: InstallMode
|
||||
arch: InstallArch
|
||||
agentVersion: string
|
||||
downloadSrc: InstallSource
|
||||
ttlSeconds: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
// null = 拉取中;空串 = 拉取失败(改为手动输入)
|
||||
masterVersion: string | null
|
||||
value: DeployOptions
|
||||
onChange: (v: DeployOptions) => void
|
||||
}
|
||||
|
||||
export function Step2DeployOptions({ masterVersion, value, onChange }: Props) {
|
||||
const update = (patch: Partial<DeployOptions>) => onChange({ ...value, ...patch })
|
||||
const versionKnown = !!masterVersion
|
||||
const versionLoading = masterVersion === null
|
||||
|
||||
return (
|
||||
<Form layout="vertical" size="default">
|
||||
<Form.Item label="安装模式">
|
||||
<Radio.Group
|
||||
type="button"
|
||||
value={value.mode}
|
||||
onChange={(v) => update({ mode: v as InstallMode })}
|
||||
options={[
|
||||
{ label: 'systemd(推荐)', value: 'systemd' },
|
||||
{ label: 'Docker', value: 'docker' },
|
||||
{ label: '前台运行(调试)', value: 'foreground' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="架构">
|
||||
<Select
|
||||
value={value.arch}
|
||||
onChange={(v) => update({ arch: v as InstallArch })}
|
||||
options={[
|
||||
{ label: '自动检测(uname -m)', value: 'auto' },
|
||||
{ label: 'amd64 (x86_64)', value: 'amd64' },
|
||||
{ label: 'arm64 (aarch64)', value: 'arm64' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Agent 版本"
|
||||
extra={
|
||||
!versionKnown && !versionLoading ? (
|
||||
<Text type="warning" style={{ fontSize: 12 }}>
|
||||
未能自动获取 Master 版本,请手动输入(形如 v1.7.0)
|
||||
</Text>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{versionKnown ? (
|
||||
<Select
|
||||
value={value.agentVersion}
|
||||
onChange={(v) => update({ agentVersion: v })}
|
||||
options={[
|
||||
{ label: `${masterVersion}(跟随 Master,推荐)`, value: masterVersion as string },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={versionLoading ? '加载中...' : 'v1.7.0'}
|
||||
value={value.agentVersion}
|
||||
onChange={(v) => update({ agentVersion: v })}
|
||||
disabled={versionLoading}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="安装命令有效期">
|
||||
<Select
|
||||
value={value.ttlSeconds}
|
||||
onChange={(v) => update({ ttlSeconds: v as number })}
|
||||
options={[
|
||||
{ label: '5 分钟', value: 300 },
|
||||
{ label: '15 分钟(推荐)', value: 900 },
|
||||
{ label: '1 小时', value: 3600 },
|
||||
{ label: '24 小时', value: 86400 },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="二进制下载源"
|
||||
extra={<Text type="secondary">国内服务器选 ghproxy 镜像加速</Text>}
|
||||
>
|
||||
<Radio.Group
|
||||
type="button"
|
||||
value={value.downloadSrc}
|
||||
onChange={(v) => update({ downloadSrc: v as InstallSource })}
|
||||
options={[
|
||||
{ label: 'GitHub 直连', value: 'github' },
|
||||
{ label: 'ghproxy 镜像', value: 'ghproxy' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
111
web/src/pages/nodes/wizard/Step3CommandPreview.tsx
Normal file
111
web/src/pages/nodes/wizard/Step3CommandPreview.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Typography, Button, Space, Collapse, Spin, Message, Tag } from '@arco-design/web-react'
|
||||
import { IconCopy, IconRefresh } from '@arco-design/web-react/icon'
|
||||
import { fetchScriptPreview } from '../../../services/nodes'
|
||||
import type { InstallTokenResult, InstallMode } from '../../../types/nodes'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface Props {
|
||||
nodeId: number
|
||||
nodeName: string
|
||||
token: InstallTokenResult
|
||||
mode: InstallMode
|
||||
previewParams: { mode: string; arch: string; agentVersion: string; downloadSrc: string }
|
||||
onRegenerate: () => void
|
||||
}
|
||||
|
||||
export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewParams, onRegenerate }: Props) {
|
||||
const [remaining, setRemaining] = useState(0)
|
||||
const [preview, setPreview] = useState<string>('')
|
||||
const [loadingPreview, setLoadingPreview] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const expires = new Date(token.expiresAt).getTime()
|
||||
const tick = () => setRemaining(Math.max(0, Math.floor((expires - Date.now()) / 1000)))
|
||||
tick()
|
||||
const id = setInterval(tick, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [token.expiresAt])
|
||||
|
||||
const expired = remaining === 0
|
||||
const command = `curl -fsSL ${token.url} | sudo sh`
|
||||
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)
|
||||
Message.success('已复制')
|
||||
}
|
||||
|
||||
const loadPreview = async () => {
|
||||
setLoadingPreview(true)
|
||||
try {
|
||||
const text = await fetchScriptPreview(nodeId, previewParams)
|
||||
setPreview(text)
|
||||
} catch {
|
||||
Message.error('预览加载失败')
|
||||
} finally {
|
||||
setLoadingPreview(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Text bold>节点:</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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}>
|
||||
命令仅显示一次,复制后请尽快在目标机执行。token 一经消费立即作废。
|
||||
</Text>
|
||||
|
||||
<Collapse bordered={false} onChange={(_key, keys) => {
|
||||
if (keys.includes('preview') && !preview) loadPreview()
|
||||
}}>
|
||||
<Collapse.Item name="preview" header="展开脚本预览">
|
||||
{loadingPreview ? <Spin /> : (
|
||||
<pre style={{
|
||||
background: 'var(--color-fill-2)', padding: 12, borderRadius: 4,
|
||||
fontSize: 12, maxHeight: 400, overflow: 'auto', whiteSpace: 'pre-wrap',
|
||||
}}>{preview}</pre>
|
||||
)}
|
||||
</Collapse.Item>
|
||||
</Collapse>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
|
||||
import type { NodeSummary, DirEntry } from '../types/nodes'
|
||||
import type { NodeSummary, DirEntry, BatchCreateResult, InstallTokenInput, InstallTokenResult } from '../types/nodes'
|
||||
|
||||
export async function listNodes() {
|
||||
const response = await http.get<ApiEnvelope<NodeSummary[]>>('/nodes')
|
||||
@@ -30,3 +30,33 @@ export async function listNodeDirectory(nodeId: number, path: string) {
|
||||
const response = await http.get<ApiEnvelope<DirEntry[]>>(`/nodes/${nodeId}/fs/list`, { params: { path } })
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function batchCreateNodes(names: string[]) {
|
||||
const response = await http.post<ApiEnvelope<BatchCreateResult[]>>('/nodes/batch', { names })
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function createInstallToken(nodeId: number, input: InstallTokenInput) {
|
||||
const response = await http.post<ApiEnvelope<InstallTokenResult>>(
|
||||
`/nodes/${nodeId}/install-tokens`, input,
|
||||
)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function rotateNodeToken(nodeId: number) {
|
||||
const response = await http.post<ApiEnvelope<{ newToken: string }>>(
|
||||
`/nodes/${nodeId}/rotate-token`,
|
||||
)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function fetchScriptPreview(
|
||||
nodeId: number,
|
||||
params: { mode: string; arch: string; agentVersion: string; downloadSrc: string },
|
||||
) {
|
||||
const response = await http.get<string>(`/nodes/${nodeId}/install-script-preview`, {
|
||||
params,
|
||||
responseType: 'text',
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -18,3 +18,27 @@ export interface DirEntry {
|
||||
isDir: boolean
|
||||
size: number
|
||||
}
|
||||
|
||||
export type InstallMode = 'systemd' | 'docker' | 'foreground'
|
||||
export type InstallArch = 'amd64' | 'arm64' | 'auto'
|
||||
export type InstallSource = 'github' | 'ghproxy'
|
||||
|
||||
export interface BatchCreateResult {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface InstallTokenInput {
|
||||
mode: InstallMode
|
||||
arch: InstallArch
|
||||
agentVersion: string
|
||||
downloadSrc: InstallSource
|
||||
ttlSeconds: number
|
||||
}
|
||||
|
||||
export interface InstallTokenResult {
|
||||
installToken: string
|
||||
expiresAt: string
|
||||
url: string
|
||||
composeUrl: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user