功能: NodesPage 集成一键部署 Wizard 与操作列改造

This commit is contained in:
Awuqing
2026-04-19 16:48:54 +08:00
parent 0ea5f12e07
commit 2b86363007
+87 -142
View File
@@ -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 }}>
Token24 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>
) )