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

This commit is contained in:
Awuqing
2026-04-19 16:48:54 +08:00
parent 0ea5f12e07
commit 2b86363007

View File

@@ -1,23 +1,26 @@
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>()
const [masterVersion, setMasterVersion] = useState('latest')
const [editVisible, setEditVisible] = useState(false)
const [editNode, setEditNode] = useState<NodeSummary | null>(null)
const [editName, setEditName] = useState('')
@@ -34,22 +37,13 @@ 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 默认值
fetchSystemInfo().then((info) => {
if (info?.version) setMasterVersion(info.version)
}).catch(() => {})
}, [fetchNodes])
const handleDelete = async (id: number) => {
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 = [
{
title: '节点名称',
dataIndex: 'name',
title: '节点名称', dataIndex: 'name',
render: (name: string, record: NodeSummary) => (
<Space>
{record.isLocal ? <IconDesktop style={{ color: 'var(--color-primary-6)' }} /> : <IconCloudDownload />}
@@ -89,60 +103,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 +157,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>
)