mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-09-04 23:16:41 +08:00
功能: NodesPage 集成一键部署 Wizard 与操作列改造
This commit is contained in:
@@ -1,23 +1,26 @@
|
|||||||
import React, { useEffect, useState, useCallback } from 'react'
|
import React, { useEffect, useState, useCallback } from 'react'
|
||||||
import {
|
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'
|
} from '@arco-design/web-react'
|
||||||
import {
|
import {
|
||||||
IconPlus, IconDelete, IconDesktop, IconCloudDownload, IconEdit
|
IconPlus, IconDelete, IconDesktop, IconCloudDownload, IconEdit, IconMore,
|
||||||
} from '@arco-design/web-react/icon'
|
} from '@arco-design/web-react/icon'
|
||||||
import type { NodeSummary } from '../../types/nodes'
|
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() {
|
export default function NodesPage() {
|
||||||
const [nodes, setNodes] = useState<NodeSummary[]>([])
|
const [nodes, setNodes] = useState<NodeSummary[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
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>()
|
||||||
|
const [masterVersion, setMasterVersion] = useState('latest')
|
||||||
|
|
||||||
const [editVisible, setEditVisible] = useState(false)
|
const [editVisible, setEditVisible] = useState(false)
|
||||||
const [editNode, setEditNode] = useState<NodeSummary | null>(null)
|
const [editNode, setEditNode] = useState<NodeSummary | null>(null)
|
||||||
const [editName, setEditName] = useState('')
|
const [editName, setEditName] = useState('')
|
||||||
@@ -34,22 +37,13 @@ export default function NodesPage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => { fetchNodes() }, [fetchNodes])
|
useEffect(() => {
|
||||||
|
fetchNodes()
|
||||||
const handleCreate = async () => {
|
// 取 Master 版本号作为 Wizard agentVersion 默认值
|
||||||
if (!newNodeName.trim()) {
|
fetchSystemInfo().then((info) => {
|
||||||
Message.warning('请输入节点名称')
|
if (info?.version) setMasterVersion(info.version)
|
||||||
return
|
}).catch(() => {})
|
||||||
}
|
}, [fetchNodes])
|
||||||
try {
|
|
||||||
const result = await createNode(newNodeName.trim())
|
|
||||||
setNewToken(result.token)
|
|
||||||
Message.success('节点创建成功')
|
|
||||||
fetchNodes()
|
|
||||||
} catch {
|
|
||||||
Message.error('创建节点失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
@@ -76,10 +70,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 = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: '节点名称',
|
title: '节点名称', dataIndex: 'name',
|
||||||
dataIndex: 'name',
|
|
||||||
render: (name: string, record: NodeSummary) => (
|
render: (name: string, record: NodeSummary) => (
|
||||||
<Space>
|
<Space>
|
||||||
{record.isLocal ? <IconDesktop style={{ color: 'var(--color-primary-6)' }} /> : <IconCloudDownload />}
|
{record.isLocal ? <IconDesktop style={{ color: 'var(--color-primary-6)' }} /> : <IconCloudDownload />}
|
||||||
@@ -89,60 +103,48 @@ export default function NodesPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态', dataIndex: 'status', width: 100,
|
||||||
dataIndex: 'status',
|
render: (status: string) => status === 'online'
|
||||||
width: 100,
|
? <Badge status="success" text="在线" />
|
||||||
render: (status: string) => {
|
: <Badge status="default" text="离线" />,
|
||||||
if (status === 'online') return <Badge status="success" text="在线" />
|
|
||||||
return <Badge status="default" text="离线" />
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
{ title: '主机名', dataIndex: 'hostname', render: (v: string) => v || '-' },
|
||||||
|
{ title: 'IP 地址', dataIndex: 'ipAddress', render: (v: string) => v || '-' },
|
||||||
{
|
{
|
||||||
title: '主机名',
|
title: '系统', dataIndex: 'os', width: 120,
|
||||||
dataIndex: 'hostname',
|
render: (_: string, record: NodeSummary) => record.os
|
||||||
render: (v: string) => v || '-',
|
? <Tag bordered>{record.os}/{record.arch}</Tag> : '-',
|
||||||
},
|
},
|
||||||
|
{ title: 'Agent 版本', dataIndex: 'agentVersion', width: 100, render: (v: string) => v || '-' },
|
||||||
{
|
{
|
||||||
title: 'IP 地址',
|
title: '最后活跃', dataIndex: 'lastSeen', width: 170,
|
||||||
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,
|
|
||||||
render: (v: string) => v ? new Date(v).toLocaleString('zh-CN') : '-',
|
render: (v: string) => v ? new Date(v).toLocaleString('zh-CN') : '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作', width: 180,
|
||||||
width: 120,
|
|
||||||
render: (_: unknown, record: NodeSummary) => (
|
render: (_: unknown, record: NodeSummary) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button type="text" icon={<IconEdit />} size="small"
|
||||||
type="text"
|
onClick={() => { setEditNode(record); setEditName(record.name); setEditVisible(true) }} />
|
||||||
icon={<IconEdit />}
|
|
||||||
size="small"
|
|
||||||
onClick={() => { setEditNode(record); setEditName(record.name); setEditVisible(true) }}
|
|
||||||
/>
|
|
||||||
{!record.isLocal && (
|
{!record.isLocal && (
|
||||||
<Popconfirm title="确定删除该节点?" onOk={() => handleDelete(record.id)}>
|
<>
|
||||||
<Button type="text" status="danger" icon={<IconDelete />} size="small" />
|
<Dropdown trigger="click" droplist={(
|
||||||
</Popconfirm>
|
<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>
|
</Space>
|
||||||
),
|
),
|
||||||
@@ -155,90 +157,33 @@ export default function NodesPage() {
|
|||||||
title="节点管理"
|
title="节点管理"
|
||||||
subTitle="管理集群中的服务器节点"
|
subTitle="管理集群中的服务器节点"
|
||||||
extra={
|
extra={
|
||||||
<Button type="primary" icon={<IconPlus />} onClick={() => { setCreateVisible(true); setNewToken(''); setNewNodeName('') }}>
|
<Button type="primary" icon={<IconPlus />}
|
||||||
|
onClick={() => { setWizardFixedNode(undefined); setWizardVisible(true) }}>
|
||||||
添加节点
|
添加节点
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card style={{ marginTop: 16 }}>
|
<Card style={{ marginTop: 16 }}>
|
||||||
<Table
|
<Table columns={columns} data={nodes} rowKey="id" loading={loading} pagination={false}
|
||||||
columns={columns}
|
noDataElement={<Empty description="暂无节点数据,系统将自动创建本机节点" />} />
|
||||||
data={nodes}
|
|
||||||
rowKey="id"
|
|
||||||
loading={loading}
|
|
||||||
pagination={false}
|
|
||||||
noDataElement={<Empty description="暂无节点数据,系统将自动创建本机节点" />}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 添加节点弹窗 */}
|
<AgentInstallWizard
|
||||||
<Modal
|
visible={wizardVisible}
|
||||||
title="添加远程节点"
|
onClose={() => setWizardVisible(false)}
|
||||||
visible={createVisible}
|
onSuccess={fetchNodes}
|
||||||
onCancel={() => setCreateVisible(false)}
|
masterVersion={masterVersion}
|
||||||
style={{ width: 640 }}
|
fixedNode={wizardFixedNode}
|
||||||
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>
|
|
||||||
|
|
||||||
{/* 编辑节点弹窗 */}
|
<Modal title="编辑节点" visible={editVisible}
|
||||||
<Modal
|
onCancel={() => setEditVisible(false)} onOk={handleEdit}
|
||||||
title="编辑节点"
|
okText="保存" cancelText="取消">
|
||||||
visible={editVisible}
|
|
||||||
onCancel={() => setEditVisible(false)}
|
|
||||||
onOk={handleEdit}
|
|
||||||
okText="保存"
|
|
||||||
cancelText="取消"
|
|
||||||
>
|
|
||||||
<div style={{ marginBottom: 8 }}>
|
<div style={{ marginBottom: 8 }}>
|
||||||
<Text type="secondary">节点名称</Text>
|
<Text type="secondary">节点名称</Text>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input placeholder="输入节点名称" value={editName} onChange={setEditName} />
|
||||||
placeholder="输入节点名称"
|
|
||||||
value={editName}
|
|
||||||
onChange={setEditName}
|
|
||||||
/>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user