feat(拓扑图): 新增网络拓扑图功能模块
This commit is contained in:
@@ -189,7 +189,6 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setCurrentStep(0);
|
||||
setSourceDevice(null);
|
||||
setTargetDevice(null);
|
||||
setSourcePort(null);
|
||||
setTargetPort(null);
|
||||
@@ -197,22 +196,30 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed
|
||||
setSelectedCableLength(3);
|
||||
setCableLabel('');
|
||||
setCableDescription('');
|
||||
setSourcePorts([]);
|
||||
setTargetPorts([]);
|
||||
setConflicts([]);
|
||||
setCompatibilityWarning(null);
|
||||
setDevices([]);
|
||||
setCablesData([]);
|
||||
|
||||
fetchDevices('', 'switch').then(deviceList => {
|
||||
if (initialSourceDevice?.deviceId) {
|
||||
const device = deviceList.find(d => d.deviceId === initialSourceDevice.deviceId);
|
||||
if (device) {
|
||||
setSourceDevice(device);
|
||||
fetchDevicePorts(device.deviceId, 'source');
|
||||
}
|
||||
}
|
||||
});
|
||||
if (initialSourceDevice?.deviceId) {
|
||||
const deviceWithId = {
|
||||
deviceId: initialSourceDevice.deviceId,
|
||||
name: initialSourceDevice.name || initialSourceDevice.deviceId,
|
||||
type: initialSourceDevice.type || 'switch',
|
||||
model: initialSourceDevice.model,
|
||||
status: initialSourceDevice.status,
|
||||
Rack: initialSourceDevice.Rack,
|
||||
ipAddress: initialSourceDevice.ipAddress,
|
||||
position: initialSourceDevice.position,
|
||||
};
|
||||
setSourceDevice(deviceWithId);
|
||||
fetchDevicePorts(deviceWithId.deviceId, 'source');
|
||||
fetchDevices('', 'switch');
|
||||
} else {
|
||||
setSourceDevice(null);
|
||||
setSourcePorts([]);
|
||||
fetchDevices('', 'switch');
|
||||
}
|
||||
fetchCables();
|
||||
}
|
||||
}, [visible, initialSourceDevice, fetchDevices, fetchDevicePorts, fetchCables]);
|
||||
@@ -677,152 +684,165 @@ const Step1SourceDevice = ({
|
||||
onPortSelect,
|
||||
onDevicesChange,
|
||||
}) => {
|
||||
const isSourcePreSelected = !!sourceDevice;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px 0' }}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<CloudServerOutlined style={{ color: '#1890ff' }} />
|
||||
<span>步骤 1: 选择源设备</span>
|
||||
<span>步骤 1: 选择源设备端口</span>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{ marginBottom: '20px' }}
|
||||
>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: '13px' }}>
|
||||
选择接线的起点设备
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<FilterableDeviceSelect
|
||||
filterType="switch"
|
||||
onDeviceListChange={onDevicesChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
gap: '12px',
|
||||
maxHeight: 300,
|
||||
overflowY: 'auto',
|
||||
padding: '8px',
|
||||
background: designTokens.colors.background,
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
{fetchingDevices ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', gridColumn: '1 / -1' }}>
|
||||
<Spin size="large" />
|
||||
{!isSourcePreSelected && (
|
||||
<>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: '13px' }}>
|
||||
选择接线的起点设备
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
devices.map(device => (
|
||||
<motion.div
|
||||
key={device.deviceId}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Card
|
||||
onClick={() => onDeviceSelect(device)}
|
||||
hoverable
|
||||
style={{
|
||||
border: sourceDevice?.deviceId === device.deviceId
|
||||
? `2px solid ${designTokens.colors.primary}`
|
||||
: '1px solid #d9d9d9',
|
||||
background: sourceDevice?.deviceId === device.deviceId
|
||||
? 'rgba(24,144,255,0.08)'
|
||||
: '#fff',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
|
||||
<div
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<FilterableDeviceSelect
|
||||
filterType="switch"
|
||||
onDeviceListChange={onDevicesChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
gap: '12px',
|
||||
maxHeight: 300,
|
||||
overflowY: 'auto',
|
||||
padding: '8px',
|
||||
background: designTokens.colors.background,
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
{fetchingDevices ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', gridColumn: '1 / -1' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : (
|
||||
devices.map(device => (
|
||||
<motion.div
|
||||
key={device.deviceId}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Card
|
||||
onClick={() => onDeviceSelect(device)}
|
||||
hoverable
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '10px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '24px',
|
||||
flexShrink: 0,
|
||||
border: sourceDevice?.deviceId === device.deviceId
|
||||
? `2px solid ${designTokens.colors.primary}`
|
||||
: '1px solid #d9d9d9',
|
||||
background: sourceDevice?.deviceId === device.deviceId
|
||||
? 'rgba(24,144,255,0.08)'
|
||||
: '#fff',
|
||||
}}
|
||||
>
|
||||
{device.type === 'server' ? '🖥️' : device.type === 'switch' ? '📡' : device.type === 'router' ? '🔀' : device.type === 'storage' ? '💾' : '📦'}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
|
||||
<div style={{ fontWeight: 600, color: '#262626', fontSize: '15px' }}>
|
||||
{device.name || device.deviceId}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '10px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '24px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{device.type === 'server' ? '🖥️' : device.type === 'switch' ? '📡' : device.type === 'router' ? '🔀' : device.type === 'storage' ? '💾' : '📦'}
|
||||
</div>
|
||||
{device.status && getStatusTag(device.status)}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#8c8c8c', marginBottom: '4px' }}>
|
||||
<span style={{ marginRight: '12px' }}>
|
||||
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>ID:</span>
|
||||
{device.deviceId}
|
||||
</span>
|
||||
<span>
|
||||
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>类型:</span>
|
||||
{device.type === 'server' ? '服务器' : device.type === 'switch' ? '交换机' : device.type === 'router' ? '路由器' : device.type === 'storage' ? '存储设备' : device.type}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#8c8c8c', lineHeight: '1.5' }}>
|
||||
{device.Rack?.Room?.name && (
|
||||
<span style={{ marginRight: '12px' }}>
|
||||
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>机房:</span>
|
||||
{device.Rack.Room.name}
|
||||
</span>
|
||||
)}
|
||||
{device.Rack?.name && (
|
||||
<span style={{ marginRight: '12px' }}>
|
||||
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>机柜:</span>
|
||||
{device.Rack.name}
|
||||
</span>
|
||||
)}
|
||||
{device.position && (
|
||||
<span style={{ marginRight: '12px' }}>
|
||||
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>U位:</span>
|
||||
U{device.position}
|
||||
</span>
|
||||
)}
|
||||
{device.ipAddress && (
|
||||
<span>
|
||||
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>IP:</span>
|
||||
{device.ipAddress}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{device.model && (
|
||||
<div style={{ fontSize: '11px', color: '#bfbfbf', marginTop: '4px' }}>
|
||||
<span style={{ color: '#8c8c8c', marginRight: '4px' }}>型号:</span>
|
||||
{device.model}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
|
||||
<div style={{ fontWeight: 600, color: '#262626', fontSize: '15px' }}>
|
||||
{device.name || device.deviceId}
|
||||
</div>
|
||||
{device.status && getStatusTag(device.status)}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#8c8c8c', marginBottom: '4px' }}>
|
||||
<span style={{ marginRight: '12px' }}>
|
||||
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>ID:</span>
|
||||
{device.deviceId}
|
||||
</span>
|
||||
<span>
|
||||
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>类型:</span>
|
||||
{device.type === 'server' ? '服务器' : device.type === 'switch' ? '交换机' : device.type === 'router' ? '路由器' : device.type === 'storage' ? '存储设备' : device.type}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#8c8c8c', lineHeight: '1.5' }}>
|
||||
{device.Rack?.Room?.name && (
|
||||
<span style={{ marginRight: '12px' }}>
|
||||
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>机房:</span>
|
||||
{device.Rack.Room.name}
|
||||
</span>
|
||||
)}
|
||||
{device.Rack?.name && (
|
||||
<span style={{ marginRight: '12px' }}>
|
||||
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>机柜:</span>
|
||||
{device.Rack.name}
|
||||
</span>
|
||||
)}
|
||||
{device.position && (
|
||||
<span style={{ marginRight: '12px' }}>
|
||||
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>U位:</span>
|
||||
U{device.position}
|
||||
</span>
|
||||
)}
|
||||
{device.ipAddress && (
|
||||
<span>
|
||||
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>IP:</span>
|
||||
{device.ipAddress}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{device.model && (
|
||||
<div style={{ fontSize: '11px', color: '#bfbfbf', marginTop: '4px' }}>
|
||||
<span style={{ color: '#8c8c8c', marginRight: '4px' }}>型号:</span>
|
||||
{device.model}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{sourceDevice?.deviceId === device.deviceId && (
|
||||
<CheckCircleOutlined style={{ color: designTokens.colors.primary, fontSize: '22px', flexShrink: 0 }} />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{sourceDevice?.deviceId === device.deviceId && (
|
||||
<CheckCircleOutlined style={{ color: designTokens.colors.primary, fontSize: '22px', flexShrink: 0 }} />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{sourceDevice && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
style={{ marginTop: '20px' }}
|
||||
style={{ marginTop: isSourcePreSelected ? 0 : '20px' }}
|
||||
>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<DatabaseOutlined style={{ color: '#1890ff' }} />
|
||||
<Typography.Text style={{ fontWeight: 600, color: '#262626' }}>
|
||||
源设备已选择: {sourceDevice.name}
|
||||
源设备: {sourceDevice.name}
|
||||
</Typography.Text>
|
||||
{isSourcePreSelected && (
|
||||
<Tag color="blue" style={{ marginLeft: '8px' }}>已选择</Tag>
|
||||
)}
|
||||
<Tag color="blue" style={{ marginLeft: 'auto' }}>
|
||||
{sourcePorts.filter(p => p.status === 'free').length} 个空闲端口
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -833,14 +853,6 @@ const Step1SourceDevice = ({
|
||||
border: '1px solid #d9d9d9',
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<DatabaseOutlined style={{ color: '#1890ff' }} />
|
||||
<Typography.Text style={{ fontWeight: 600 }}>端口选择</Typography.Text>
|
||||
<Tag color="blue" style={{ marginLeft: 'auto' }}>
|
||||
{sourcePorts.filter(p => p.status === 'free').length} 个空闲端口
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{sourcePorts.length === 0 ? (
|
||||
<Empty description="该设备暂无端口数据" />
|
||||
) : (
|
||||
@@ -848,9 +860,6 @@ const Step1SourceDevice = ({
|
||||
<div style={{ marginBottom: '8px', fontSize: '12px', color: '#8c8c8c' }}>
|
||||
端口总数: {sourcePorts.length} | 空闲端口: {sourcePorts.filter(p => p.status === 'free').length}
|
||||
</div>
|
||||
<div style={{ marginBottom: '8px', fontSize: '11px', color: '#bfbfbf' }}>
|
||||
端口状态分布: {sourcePorts.map(p => p.status).filter((v, i, a) => a.indexOf(v) === i).join(', ')}
|
||||
</div>
|
||||
{sourcePort && (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -62,7 +62,8 @@ function parsePortRange(portName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prefix = startPart.replace(startNumMatch[0], '');
|
||||
const startIdx = startPart.length - startNumMatch[0].length;
|
||||
const prefix = startPart.substring(0, startIdx);
|
||||
const portCount = endNum - startNum + 1;
|
||||
|
||||
const ports = [];
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { Card, Select, Space, Button, Row, Col, Statistic, Tag, Typography } from 'antd';
|
||||
import {
|
||||
ReloadOutlined,
|
||||
AppstoreOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
function TopologyControls({
|
||||
switchDevices,
|
||||
selectedSwitchId,
|
||||
onSwitchChange,
|
||||
loading,
|
||||
onRefresh,
|
||||
statistics
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
<AppstoreOutlined />
|
||||
<span>选择交换机</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={onRefresh}
|
||||
loading={loading}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
style={{ marginBottom: 16, borderRadius: 8 }}
|
||||
bodyStyle={{ padding: 16 }}
|
||||
>
|
||||
<Select
|
||||
placeholder="搜索并选择交换机..."
|
||||
value={selectedSwitchId || undefined}
|
||||
onChange={onSwitchChange}
|
||||
style={{ width: '100%', marginBottom: 16 }}
|
||||
allowClear
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
option.label?.toLowerCase().includes(input.toLowerCase()) ?? true
|
||||
}
|
||||
>
|
||||
{switchDevices.map(device => (
|
||||
<Option key={device.deviceId} value={device.deviceId} label={device.name}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>{device.name}</span>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>{device.deviceId}</Text>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{statistics && (
|
||||
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid #f0f0f0' }}>
|
||||
<Row gutter={[8, 8]}>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="设备数"
|
||||
value={statistics.totalDevices || 0}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="连接数"
|
||||
value={statistics.totalCables || 0}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>连接状态:</Text>
|
||||
<Space size={4} style={{ marginTop: 4 }}>
|
||||
<Tag color="success" style={{ fontSize: 11 }}>{statistics.normalCables || 0} 正常</Tag>
|
||||
<Tag color="error" style={{ fontSize: 11 }}>{statistics.faultCables || 0} 故障</Tag>
|
||||
<Tag style={{ fontSize: 11 }}>{statistics.disconnectedCables || 0} 未连接</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopologyControls;
|
||||
@@ -0,0 +1,264 @@
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
Handle,
|
||||
Position
|
||||
} from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
import { Card, Tag, Badge, Typography } from 'antd';
|
||||
import {
|
||||
CloudServerOutlined,
|
||||
AppstoreOutlined,
|
||||
DatabaseOutlined,
|
||||
SwapOutlined,
|
||||
SafetyOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { SwitchNode, ServerNode, RouterNode, StorageNode, GenericNode, FirewallNode } from './nodes';
|
||||
|
||||
const { Text: AntText } = Typography;
|
||||
|
||||
const DEVICE_COLORS = {
|
||||
switch: '#1890ff',
|
||||
router: '#722ed1',
|
||||
server: '#52c41a',
|
||||
storage: '#fa8c16',
|
||||
firewall: '#eb595a',
|
||||
default: '#8c8c8c'
|
||||
};
|
||||
|
||||
const DEVICE_ICONS = {
|
||||
switch: AppstoreOutlined,
|
||||
router: SwapOutlined,
|
||||
server: CloudServerOutlined,
|
||||
storage: DatabaseOutlined,
|
||||
firewall: SafetyOutlined,
|
||||
default: CloudServerOutlined
|
||||
};
|
||||
|
||||
const CABLE_COLORS = {
|
||||
ethernet: '#1890ff',
|
||||
fiber: '#13c2c2',
|
||||
copper: '#fa8c16'
|
||||
};
|
||||
|
||||
const STATUS_COLORS = {
|
||||
online: '#52c41a',
|
||||
offline: '#d9d9d9',
|
||||
fault: '#ff4d4f',
|
||||
warning: '#faad14'
|
||||
};
|
||||
|
||||
function DeviceNode({ data }) {
|
||||
if (!data) {
|
||||
return (
|
||||
<Card size="small" style={{ width: 180 }}>
|
||||
<AntText type="secondary">无数据</AntText>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeType = data.type || 'default';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} style={{ background: '#555', width: 8, height: 8 }} />
|
||||
{nodeType === 'switch' && <SwitchNode data={data} />}
|
||||
{nodeType === 'server' && <ServerNode data={data} />}
|
||||
{nodeType === 'router' && <RouterNode data={data} />}
|
||||
{nodeType === 'storage' && <StorageNode data={data} />}
|
||||
{nodeType === 'firewall' && <FirewallNode data={data} />}
|
||||
{(nodeType === 'default' || !['switch', 'server', 'router', 'storage', 'firewall'].includes(nodeType)) && <GenericNode data={data} />}
|
||||
<Handle type="source" position={Position.Right} style={{ background: '#555', width: 8, height: 8 }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes = {
|
||||
device: DeviceNode
|
||||
};
|
||||
|
||||
function TopologyGraph({ nodes, edges, onNodeClick, onEdgeClick, selectedNode, selectedEdge }) {
|
||||
const [flowNodes, setFlowNodes, onNodesChange] = useNodesState([]);
|
||||
const [flowEdges, setFlowEdges, onEdgesChange] = useEdgesState([]);
|
||||
const [hoveredNodeId, setHoveredNodeId] = useState(null);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!nodes || nodes.length === 0) return;
|
||||
|
||||
setFlowNodes(prevNodes => {
|
||||
const newNodes = nodes.map(node => {
|
||||
const existingNode = prevNodes.find(n => n.id === node.id);
|
||||
const position = existingNode?.position || node.position || { x: Math.random() * 400, y: Math.random() * 300 };
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
type: 'device',
|
||||
position,
|
||||
data: {
|
||||
...node,
|
||||
selected: selectedNode?.id === node.id,
|
||||
hovered: hoveredNodeId === node.id
|
||||
},
|
||||
selected: selectedNode?.id === node.id
|
||||
};
|
||||
});
|
||||
return newNodes;
|
||||
});
|
||||
initializedRef.current = true;
|
||||
}, [nodes, selectedNode, hoveredNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!edges || edges.length === 0) return;
|
||||
|
||||
setFlowEdges(edges.map(edge => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: 'default',
|
||||
animated: edge.status === 'fault',
|
||||
style: {
|
||||
stroke: edge.status === 'fault' ? '#ff4d4f' :
|
||||
edge.status === 'disconnected' ? '#d9d9d9' :
|
||||
CABLE_COLORS[edge.cableType] || '#8c8c8c',
|
||||
strokeWidth: edge.status === 'fault' ? 3 : 2,
|
||||
strokeDasharray: edge.status === 'disconnected' ? '5,5' : '0',
|
||||
},
|
||||
data: edge,
|
||||
selected: selectedEdge?.id === edge.id
|
||||
})));
|
||||
}, [edges, selectedEdge]);
|
||||
|
||||
const onNodeClickHandler = useCallback((event, node) => {
|
||||
if (onNodeClick) {
|
||||
onNodeClick(node);
|
||||
}
|
||||
}, [onNodeClick]);
|
||||
|
||||
const onEdgeClickHandler = useCallback((event, edge) => {
|
||||
if (onEdgeClick) {
|
||||
onEdgeClick(edge);
|
||||
}
|
||||
}, [onEdgeClick]);
|
||||
|
||||
const onNodeMouseEnterHandler = useCallback((event, node) => {
|
||||
setHoveredNodeId(node.id);
|
||||
}, []);
|
||||
|
||||
const onNodeMouseLeaveHandler = useCallback(() => {
|
||||
setHoveredNodeId(null);
|
||||
}, []);
|
||||
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||
<AntText type="secondary">暂无拓扑数据</AntText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={flowNodes}
|
||||
edges={flowEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClickHandler}
|
||||
onEdgeClick={onEdgeClickHandler}
|
||||
onNodeMouseEnter={onNodeMouseEnterHandler}
|
||||
onNodeMouseLeave={onNodeMouseLeaveHandler}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
attributionPosition="bottom-left"
|
||||
style={{ background: '#fafafa' }}
|
||||
defaultEdgeOptions={{
|
||||
type: 'default',
|
||||
animated: false
|
||||
}}
|
||||
>
|
||||
<Background color="#e0e0e0" gap={20} />
|
||||
<Controls
|
||||
showZoom={true}
|
||||
showFitView={true}
|
||||
showInteractive={false}
|
||||
/>
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
const type = node.data?.type;
|
||||
switch (type) {
|
||||
case 'switch': return '#1890ff';
|
||||
case 'router': return '#722ed1';
|
||||
case 'server': return '#52c41a';
|
||||
case 'storage': return '#fa8c16';
|
||||
case 'firewall': return '#eb595a';
|
||||
default: return '#8c8c8c';
|
||||
}
|
||||
}}
|
||||
maskColor="rgba(0,0,0,0.1)"
|
||||
style={{ border: '1px solid #e0e0e0', borderRadius: 8 }}
|
||||
/>
|
||||
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
background: 'rgba(255,255,255,0.9)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #e0e0e0',
|
||||
fontSize: 12,
|
||||
zIndex: 5
|
||||
}}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 600, color: '#262626' }}>设备类型</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<div style={{ width: 12, height: 12, background: '#1890ff', borderRadius: 2 }} />
|
||||
<span>交换机</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<div style={{ width: 12, height: 12, background: '#722ed1', borderRadius: 2 }} />
|
||||
<span>路由器</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<div style={{ width: 12, height: 12, background: '#52c41a', borderRadius: 2 }} />
|
||||
<span>服务器</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<div style={{ width: 12, height: 12, background: '#fa8c16', borderRadius: 2 }} />
|
||||
<span>存储</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<div style={{ width: 12, height: 12, background: '#eb595a', borderRadius: 2 }} />
|
||||
<span>防火墙</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, marginBottom: 4, fontWeight: 600, color: '#262626' }}>线缆类型</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<div style={{ width: 20, height: 2, background: '#1890ff' }} />
|
||||
<span>网线</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<div style={{ width: 20, height: 2, background: '#13c2c2' }} />
|
||||
<span>光纤</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<div style={{ width: 20, height: 2, background: '#fa8c16' }} />
|
||||
<span>铜缆</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<div style={{ width: 20, height: 2, background: '#ff4d4f', borderStyle: 'dashed' }} />
|
||||
<span>故障</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ReactFlow>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopologyGraph;
|
||||
@@ -0,0 +1,227 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Modal, Spin, message, Alert, Space } from 'antd';
|
||||
import {
|
||||
SwapOutlined,
|
||||
CloudServerOutlined
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import { motion } from 'framer-motion';
|
||||
import { designTokens } from '../../config/theme';
|
||||
import TopologyGraph from './TopologyGraph';
|
||||
import TopologySidebar from './TopologySidebar';
|
||||
import TopologyControls from './TopologyControls';
|
||||
import { useTopologyLayout } from './hooks/useTopologyLayout';
|
||||
|
||||
function TopologyModal({ visible, onClose }) {
|
||||
const [switchDevices, setSwitchDevices] = useState([]);
|
||||
const [selectedSwitchId, setSelectedSwitchId] = useState(null);
|
||||
const [topologyData, setTopologyData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedNode, setSelectedNode] = useState(null);
|
||||
const [selectedEdge, setSelectedEdge] = useState(null);
|
||||
const [sidebarVisible, setSidebarVisible] = useState(false);
|
||||
|
||||
const { calculateLayout } = useTopologyLayout();
|
||||
|
||||
const fetchSwitchDevices = useCallback(async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/devices/all', {
|
||||
params: { pageSize: 50000, type: 'switch' }
|
||||
});
|
||||
setSwitchDevices(response.data.devices || []);
|
||||
} catch (err) {
|
||||
console.error('获取交换机列表失败:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
fetchSwitchDevices();
|
||||
}
|
||||
}, [visible, fetchSwitchDevices]);
|
||||
|
||||
const handleSwitchChange = useCallback(async (switchId) => {
|
||||
setSelectedSwitchId(switchId);
|
||||
setSelectedNode(null);
|
||||
setSelectedEdge(null);
|
||||
setTopologyData(null);
|
||||
setError(null);
|
||||
|
||||
if (!switchId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get(`/api/topology/switch/${switchId}`, {
|
||||
params: { maxNodes: 100 }
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
setTopologyData(response.data.data);
|
||||
} else {
|
||||
setError(response.data.error || '获取拓扑数据失败');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.error || '获取拓扑数据失败';
|
||||
setError(errorMessage);
|
||||
console.error('获取拓扑数据失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleNodeClick = useCallback((node) => {
|
||||
setSelectedNode(node.data || node);
|
||||
setSelectedEdge(null);
|
||||
setSidebarVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleEdgeClick = useCallback((edge) => {
|
||||
setSelectedEdge(edge.data || edge);
|
||||
setSelectedNode(null);
|
||||
setSidebarVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleSidebarClose = useCallback(() => {
|
||||
setSidebarVisible(false);
|
||||
setSelectedNode(null);
|
||||
setSelectedEdge(null);
|
||||
}, []);
|
||||
|
||||
const layoutedElements = React.useMemo(() => {
|
||||
if (!topologyData) return { nodes: [], edges: [] };
|
||||
|
||||
const centerDevice = topologyData.centerDevice;
|
||||
const connectedNodes = topologyData.nodes || [];
|
||||
const edges = topologyData.edges || [];
|
||||
|
||||
const allNodes = centerDevice ? [centerDevice, ...connectedNodes] : connectedNodes;
|
||||
return calculateLayout(allNodes, edges, {
|
||||
layoutType: 'TB',
|
||||
centerNodeId: centerDevice?.deviceId
|
||||
});
|
||||
}, [topologyData, calculateLayout]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelectedSwitchId(null);
|
||||
setTopologyData(null);
|
||||
setError(null);
|
||||
setSelectedNode(null);
|
||||
setSelectedEdge(null);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
background: designTokens.colors.primary.gradient,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff'
|
||||
}}
|
||||
>
|
||||
<SwapOutlined />
|
||||
</div>
|
||||
<span style={{ fontSize: 18, fontWeight: 600 }}>接线拓扑图</span>
|
||||
</div>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
width="90%"
|
||||
style={{ top: 20 }}
|
||||
bodyStyle={{ padding: '16px 24px', height: 'calc(100vh - 180px)' }}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
<div style={{ display: 'flex', height: '100%', gap: 16 }}>
|
||||
<div style={{ width: 280, flexShrink: 0 }}>
|
||||
<TopologyControls
|
||||
switchDevices={switchDevices}
|
||||
selectedSwitchId={selectedSwitchId}
|
||||
onSwitchChange={handleSwitchChange}
|
||||
loading={loading}
|
||||
onRefresh={fetchSwitchDevices}
|
||||
statistics={topologyData?.statistics}
|
||||
/>
|
||||
|
||||
{topologyData?.statistics && (
|
||||
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', marginTop: 8 }}>
|
||||
数据更新时间: {new Date().toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, position: 'relative', borderRadius: 8, overflow: 'hidden', border: '1px solid #e0e0e0' }}>
|
||||
{loading && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: 10,
|
||||
background: 'rgba(255,255,255,0.9)',
|
||||
padding: 24,
|
||||
borderRadius: 8
|
||||
}}>
|
||||
<Spin size="large" tip="加载拓扑数据..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{ padding: 16 }}>
|
||||
<Alert
|
||||
type="error"
|
||||
message={error}
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && !selectedSwitchId && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<CloudServerOutlined style={{ fontSize: 48, color: '#ccc' }} />
|
||||
<div style={{ marginTop: 16, color: '#999' }}>
|
||||
请从左侧选择交换机以生成拓扑图
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ width: '100%', height: '100%' }}>
|
||||
<TopologyGraph
|
||||
nodes={layoutedElements.nodes}
|
||||
edges={layoutedElements.edges}
|
||||
onNodeClick={handleNodeClick}
|
||||
onEdgeClick={handleEdgeClick}
|
||||
selectedNode={selectedNode}
|
||||
selectedEdge={selectedEdge}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TopologySidebar
|
||||
visible={sidebarVisible}
|
||||
onClose={handleSidebarClose}
|
||||
selectedNode={selectedNode}
|
||||
selectedEdge={selectedEdge}
|
||||
data={topologyData}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopologyModal;
|
||||
@@ -0,0 +1,333 @@
|
||||
import React from 'react';
|
||||
import { Drawer, Card, Descriptions, Tag, Badge, Row, Col, Statistic, Typography, Space, Progress } from 'antd';
|
||||
import {
|
||||
CloudServerOutlined,
|
||||
AppstoreOutlined,
|
||||
DatabaseOutlined,
|
||||
SwapOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const DEVICE_COLORS = {
|
||||
switch: '#1890ff',
|
||||
router: '#722ed1',
|
||||
server: '#52c41a',
|
||||
storage: '#fa8c16',
|
||||
default: '#8c8c8c'
|
||||
};
|
||||
|
||||
const DEVICE_ICONS = {
|
||||
switch: AppstoreOutlined,
|
||||
router: SwapOutlined,
|
||||
server: CloudServerOutlined,
|
||||
storage: DatabaseOutlined,
|
||||
default: CloudServerOutlined
|
||||
};
|
||||
|
||||
const CABLE_COLORS = {
|
||||
ethernet: '#1890ff',
|
||||
fiber: '#13c2c2',
|
||||
copper: '#fa8c16'
|
||||
};
|
||||
|
||||
function TopologySidebar({ visible, onClose, selectedNode, selectedEdge, data }) {
|
||||
if (!selectedNode && !selectedEdge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderDeviceDetail = () => {
|
||||
if (!selectedNode) return null;
|
||||
|
||||
const IconComponent = DEVICE_ICONS[selectedNode.type] || DEVICE_ICONS.default;
|
||||
const nodeColor = DEVICE_COLORS[selectedNode.type] || DEVICE_COLORS.default;
|
||||
const portCount = selectedNode.portCount || {};
|
||||
const usedPercent = portCount.total > 0 ? Math.round((portCount.used / portCount.total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '24px 16px',
|
||||
background: `linear-gradient(135deg, ${nodeColor}15 0%, ${nodeColor}05 100%)`,
|
||||
borderRadius: 12,
|
||||
marginBottom: 20,
|
||||
border: `1px solid ${nodeColor}30`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 16,
|
||||
background: `${nodeColor}20`,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 12
|
||||
}}
|
||||
>
|
||||
<IconComponent style={{ fontSize: 32, color: nodeColor }} />
|
||||
</div>
|
||||
<Title level={4} style={{ margin: '8px 0 4px' }}>{selectedNode.name}</Title>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{selectedNode.deviceId}</Text>
|
||||
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'center', gap: 8 }}>
|
||||
<Tag color={nodeColor} style={{ borderRadius: 4, margin: 0 }}>
|
||||
{selectedNode.type === 'switch' ? '交换机' :
|
||||
selectedNode.type === 'router' ? '路由器' :
|
||||
selectedNode.type === 'server' ? '服务器' :
|
||||
selectedNode.type === 'storage' ? '存储' : '设备'}
|
||||
</Tag>
|
||||
{selectedNode.isCenter && (
|
||||
<Tag color="gold" style={{ borderRadius: 4, margin: 0 }}>拓扑中心</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 16, borderRadius: 8 }}>
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label={<Text type="secondary">设备型号</Text>}>
|
||||
<Text>{selectedNode.model || '-'}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={<Text type="secondary">IP地址</Text>}>
|
||||
<Text code>{selectedNode.ipAddress || '-'}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={<Text type="secondary">所属机房</Text>}>
|
||||
<Text>{selectedNode.roomName || '-'}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={<Text type="secondary">所属机柜</Text>}>
|
||||
<Text>{selectedNode.rackName || '-'}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={<Text type="secondary">U位</Text>}>
|
||||
<Text>{typeof selectedNode.position === 'string' || typeof selectedNode.position === 'number'
|
||||
? selectedNode.position
|
||||
: selectedNode.position?.x !== undefined
|
||||
? `坐标(${selectedNode.position.x}, ${selectedNode.position.y})`
|
||||
: '-'}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={<Text type="secondary">状态</Text>}>
|
||||
<Badge
|
||||
status={selectedNode.status === 'online' ? 'success' :
|
||||
selectedNode.status === 'fault' ? 'error' : 'default'}
|
||||
text={selectedNode.status === 'online' ? '在线' :
|
||||
selectedNode.status === 'fault' ? '故障' : '离线'}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{portCount.total > 0 && (
|
||||
<Card size="small" title={<Text style={{ fontSize: 13 }}>端口统计</Text>} style={{ marginBottom: 16, borderRadius: 8 }}>
|
||||
<Row gutter={[8, 12]}>
|
||||
<Col span={24}>
|
||||
<Progress
|
||||
percent={usedPercent}
|
||||
strokeColor={nodeColor}
|
||||
trailColor="#f0f0f0"
|
||||
size="small"
|
||||
format={(percent) => (
|
||||
<span style={{ fontSize: 11, color: '#8c8c8c' }}>
|
||||
{portCount.used}/{portCount.total} 已用 ({percent}%)
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title={<Text style={{ fontSize: 11 }}>总数</Text>}
|
||||
value={portCount.total || 0}
|
||||
valueStyle={{ fontSize: 18, color: '#262626' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title={<Text style={{ fontSize: 11 }}>已用</Text>}
|
||||
value={portCount.used || 0}
|
||||
valueStyle={{ fontSize: 18, color: '#1890ff' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title={<Text style={{ fontSize: 11 }}>空闲</Text>}
|
||||
value={portCount.free || 0}
|
||||
valueStyle={{ fontSize: 18, color: '#52c41a' }}
|
||||
/>
|
||||
</Col>
|
||||
{portCount.fault > 0 && (
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title={<Text style={{ fontSize: 11 }}>故障</Text>}
|
||||
value={portCount.fault || 0}
|
||||
valueStyle={{ fontSize: 18, color: '#ff4d4f' }}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEdgeDetail = () => {
|
||||
if (!selectedEdge) return null;
|
||||
|
||||
const centerDevice = data?.centerDevice;
|
||||
const sourceDevice = selectedEdge.source === centerDevice?.deviceId
|
||||
? centerDevice
|
||||
: data?.nodes?.find(n => n.id === selectedEdge.source);
|
||||
const targetDevice = selectedEdge.target === centerDevice?.deviceId
|
||||
? centerDevice
|
||||
: data?.nodes?.find(n => n.id === selectedEdge.target);
|
||||
const cableColor = CABLE_COLORS[selectedEdge.cableType] || '#8c8c8c';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
padding: '24px 16px',
|
||||
background: `linear-gradient(135deg, ${cableColor}15 0%, ${cableColor}05 100%)`,
|
||||
borderRadius: 12,
|
||||
marginBottom: 20,
|
||||
border: `1px solid ${cableColor}30`
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||
<div style={{ textAlign: 'center', flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 8,
|
||||
background: `${DEVICE_COLORS[sourceDevice?.type] || '#8c8c8c'}20`,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 8
|
||||
}}
|
||||
>
|
||||
{React.createElement(DEVICE_ICONS[sourceDevice?.type] || DEVICE_ICONS.default, {
|
||||
style: { fontSize: 24, color: DEVICE_COLORS[sourceDevice?.type] || '#8c8c8c' }
|
||||
})}
|
||||
</div>
|
||||
<div style={{ fontWeight: 600, fontSize: 12 }}>{sourceDevice?.name || selectedEdge.source}</div>
|
||||
<Tag size="small" style={{ marginTop: 4 }}>{selectedEdge.sourcePort}</Tag>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 2,
|
||||
background: cableColor,
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -4,
|
||||
top: -3,
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: `8px solid ${cableColor}`,
|
||||
borderTop: '4px solid transparent',
|
||||
borderBottom: '4px solid transparent'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Tag color={cableColor} style={{ fontSize: 10, borderRadius: 4 }}>
|
||||
{selectedEdge.cableType === 'ethernet' ? '网线' :
|
||||
selectedEdge.cableType === 'fiber' ? '光纤' :
|
||||
selectedEdge.cableType === 'copper' ? '铜缆' : selectedEdge.cableType}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center', flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 8,
|
||||
background: `${DEVICE_COLORS[targetDevice?.type] || '#8c8c8c'}20`,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 8
|
||||
}}
|
||||
>
|
||||
{React.createElement(DEVICE_ICONS[targetDevice?.type] || DEVICE_ICONS.default, {
|
||||
style: { fontSize: 24, color: DEVICE_COLORS[targetDevice?.type] || '#8c8c8c' }
|
||||
})}
|
||||
</div>
|
||||
<div style={{ fontWeight: 600, fontSize: 12 }}>{targetDevice?.name || selectedEdge.target}</div>
|
||||
<Tag size="small" style={{ marginTop: 4 }}>{selectedEdge.targetPort}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 16, borderRadius: 8 }}>
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label={<Text type="secondary">线缆ID</Text>}>
|
||||
<Text code>{selectedEdge.cableId}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={<Text type="secondary">线缆类型</Text>}>
|
||||
<Tag color={cableColor}>
|
||||
{selectedEdge.cableType === 'ethernet' ? '网线' :
|
||||
selectedEdge.cableType === 'fiber' ? '光纤' :
|
||||
selectedEdge.cableType === 'copper' ? '铜缆' : selectedEdge.cableType}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={<Text type="secondary">线缆长度</Text>}>
|
||||
<Text>{selectedEdge.cableLength ? `${selectedEdge.cableLength}m` : '-'}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={<Text type="secondary">线缆标签</Text>}>
|
||||
<Text>{selectedEdge.cableLabel || '-'}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={<Text type="secondary">状态</Text>}>
|
||||
<Badge
|
||||
status={selectedEdge.status === 'normal' ? 'success' :
|
||||
selectedEdge.status === 'fault' ? 'error' : 'default'}
|
||||
text={selectedEdge.status === 'normal' ? '正常' :
|
||||
selectedEdge.status === 'fault' ? '故障' : '未连接'}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={<Text type="secondary">安装时间</Text>}>
|
||||
<Text>{selectedEdge.installedAt ? new Date(selectedEdge.installedAt).toLocaleDateString() : '-'}</Text>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{selectedEdge.description && (
|
||||
<Card size="small" title={<Text style={{ fontSize: 13 }}>描述</Text>} style={{ borderRadius: 8 }}>
|
||||
<Text>{selectedEdge.description}</Text>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<Space>
|
||||
{selectedNode
|
||||
? React.createElement(DEVICE_ICONS[selectedNode?.type] || DEVICE_ICONS.default, {})
|
||||
: React.createElement(SwapOutlined, {})}
|
||||
<span>{selectedNode ? '设备详情' : '连接详情'}</span>
|
||||
</Space>
|
||||
}
|
||||
placement="right"
|
||||
width={340}
|
||||
open={visible}
|
||||
onClose={onClose}
|
||||
destroyOnClose
|
||||
>
|
||||
{selectedNode && renderDeviceDetail()}
|
||||
{selectedEdge && renderEdgeDetail()}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopologySidebar;
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
export function useTopologyData() {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const fetchTopologyBySwitch = useCallback(async (switchId, options = {}) => {
|
||||
if (!switchId) {
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const params = { maxNodes: options.maxNodes || 100 };
|
||||
const response = await axios.get(`/api/topology/switch/${switchId}`, { params });
|
||||
|
||||
if (response.data.success) {
|
||||
setData(response.data.data);
|
||||
} else {
|
||||
setError(response.data.error || '获取拓扑数据失败');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.error || err.message || '获取拓扑数据失败';
|
||||
setError(errorMessage);
|
||||
console.error('获取拓扑数据失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchTopologyByRack = useCallback(async (rackId, options = {}) => {
|
||||
if (!rackId) {
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const params = { maxNodes: options.maxNodes || 100 };
|
||||
const response = await axios.get(`/api/topology/rack/${rackId}`, { params });
|
||||
|
||||
if (response.data.success) {
|
||||
setData(response.data.data);
|
||||
} else {
|
||||
setError(response.data.error || '获取拓扑数据失败');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.error || err.message || '获取拓扑数据失败';
|
||||
setError(errorMessage);
|
||||
console.error('获取拓扑数据失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearData = useCallback(() => {
|
||||
setData(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
fetchTopologyBySwitch,
|
||||
fetchTopologyByRack,
|
||||
clearData
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useCallback } from 'react';
|
||||
import dagre from 'dagre';
|
||||
|
||||
const NODE_WIDTH = 180;
|
||||
const NODE_HEIGHT = 80;
|
||||
|
||||
export function useTopologyLayout() {
|
||||
const calculateLayout = useCallback((nodes, edges, options = {}) => {
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return { nodes: [], edges: [] };
|
||||
}
|
||||
|
||||
const { layoutType = 'TB', centerNodeId = null } = options;
|
||||
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setGraph({
|
||||
rankdir: layoutType,
|
||||
nodesep: 60,
|
||||
ranksep: 100,
|
||||
marginx: 30,
|
||||
marginy: 30
|
||||
});
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
nodes.forEach(node => {
|
||||
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
|
||||
});
|
||||
|
||||
edges.forEach(edge => {
|
||||
g.setEdge(edge.source, edge.target);
|
||||
});
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
const positionedNodes = nodes.map(node => {
|
||||
const nodeData = g.node(node.id);
|
||||
if (!nodeData) return node;
|
||||
|
||||
let x = nodeData.x - NODE_WIDTH / 2;
|
||||
let y = nodeData.y - NODE_HEIGHT / 2;
|
||||
|
||||
if (centerNodeId && node.id === centerNodeId) {
|
||||
x = nodeData.x - NODE_WIDTH / 2;
|
||||
y = nodeData.y - NODE_HEIGHT / 2;
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
position: { x, y }
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
nodes: positionedNodes,
|
||||
edges: edges.map(edge => ({
|
||||
...edge,
|
||||
source: edge.source,
|
||||
target: edge.target
|
||||
}))
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getLayoutedElements = useCallback((centerDevice, connectedNodes, edges, options = {}) => {
|
||||
const allNodes = centerDevice ? [centerDevice, ...connectedNodes] : connectedNodes;
|
||||
const layouted = calculateLayout(allNodes, edges, options);
|
||||
return layouted;
|
||||
}, [calculateLayout]);
|
||||
|
||||
return {
|
||||
calculateLayout,
|
||||
getLayoutedElements
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as TopologyGraph } from './TopologyGraph';
|
||||
export { default as TopologySidebar } from './TopologySidebar';
|
||||
export { default as TopologyControls } from './TopologyControls';
|
||||
export { default as TopologyModal } from './TopologyModal';
|
||||
export { useTopologyData } from './hooks/useTopologyData';
|
||||
export { useTopologyLayout } from './hooks/useTopologyLayout';
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
const NODE_WIDTH = 200;
|
||||
const NODE_HEIGHT = 100;
|
||||
|
||||
const STATUS_COLORS = {
|
||||
online: '#52c41a',
|
||||
offline: '#d9d9d9',
|
||||
fault: '#ff4d4f',
|
||||
warning: '#faad14'
|
||||
};
|
||||
|
||||
function RouterNode({ data }) {
|
||||
if (!data) {
|
||||
return (
|
||||
<div style={{
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
background: '#f0f0f0',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
无数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeColor = '#eb595a';
|
||||
const statusColor = STATUS_COLORS[data.status] || STATUS_COLORS.offline;
|
||||
const isCenter = data.isCenter;
|
||||
const isSelected = data.selected;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
background: '#fff',
|
||||
borderRadius: 8,
|
||||
border: isSelected
|
||||
? `2px solid ${nodeColor}`
|
||||
: isCenter
|
||||
? `2px solid ${nodeColor}`
|
||||
: `1px solid #d9d9d9`,
|
||||
boxShadow: isCenter
|
||||
? `0 4px 16px ${nodeColor}40`
|
||||
: isSelected
|
||||
? `0 4px 12px ${nodeColor}30`
|
||||
: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '8px' }}>
|
||||
<img
|
||||
src="/png/防火墙.png"
|
||||
alt="防火墙"
|
||||
style={{ width: 'auto', height: '60px', objectFit: 'contain' }}
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
height: 32,
|
||||
background: '#fff1f0',
|
||||
padding: '4px 8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<Tooltip title={data.name || '防火墙'} placement="bottomLeft">
|
||||
<div style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 'bold',
|
||||
color: '#262626',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
cursor: 'pointer'
|
||||
}}>
|
||||
{data.name?.substring(0, 14) || '防火墙'}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: statusColor,
|
||||
display: 'inline-block'
|
||||
}} />
|
||||
{isCenter && (
|
||||
<span style={{
|
||||
fontSize: 9,
|
||||
color: '#faad14',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
★
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RouterNode;
|
||||
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
|
||||
const NODE_WIDTH = 200;
|
||||
const NODE_HEIGHT = 80;
|
||||
|
||||
const STATUS_COLORS = {
|
||||
online: '#52c41a',
|
||||
offline: '#d9d9d9',
|
||||
fault: '#ff4d4f',
|
||||
warning: '#faad14'
|
||||
};
|
||||
|
||||
const TYPE_COLORS = {
|
||||
switch: '#1890ff',
|
||||
router: '#722ed1',
|
||||
server: '#52c41a',
|
||||
storage: '#fa8c16',
|
||||
default: '#8c8c8c'
|
||||
};
|
||||
|
||||
function GenericNode({ data }) {
|
||||
if (!data) {
|
||||
return (
|
||||
<div style={{
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
background: '#f0f0f0',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
无数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeColor = TYPE_COLORS[data.type] || TYPE_COLORS.default;
|
||||
const statusColor = STATUS_COLORS[data.status] || STATUS_COLORS.offline;
|
||||
const isCenter = data.isCenter;
|
||||
const isSelected = data.selected;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
background: '#fff',
|
||||
borderRadius: 8,
|
||||
border: isSelected
|
||||
? `2px solid ${nodeColor}`
|
||||
: isCenter
|
||||
? `2px solid ${nodeColor}`
|
||||
: `1px solid #d9d9d9`,
|
||||
boxShadow: isCenter
|
||||
? `0 4px 16px ${nodeColor}40`
|
||||
: isSelected
|
||||
? `0 4px 12px ${nodeColor}30`
|
||||
: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width={NODE_WIDTH}
|
||||
height={NODE_HEIGHT}
|
||||
viewBox={`0 0 ${NODE_WIDTH} ${NODE_HEIGHT}`}
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
<rect
|
||||
x="10"
|
||||
y="10"
|
||||
width={NODE_WIDTH - 20}
|
||||
height={NODE_HEIGHT - 20}
|
||||
rx="4"
|
||||
fill="#fafafa"
|
||||
stroke={nodeColor}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
<rect
|
||||
x="20"
|
||||
y="20"
|
||||
width="50"
|
||||
height={NODE_HEIGHT - 40}
|
||||
rx="4"
|
||||
fill={nodeColor}
|
||||
opacity="0.2"
|
||||
/>
|
||||
|
||||
<rect
|
||||
x="25"
|
||||
y="25"
|
||||
width="40"
|
||||
height="30"
|
||||
rx="2"
|
||||
fill={nodeColor}
|
||||
/>
|
||||
|
||||
<circle cx="35" cy="35" r="6" fill="#fff" />
|
||||
<circle cx="35" cy="35" r="3" fill="#fff" opacity="0.5" />
|
||||
|
||||
<line x1="75" y1="20" x2="75" y2={NODE_HEIGHT - 20} stroke="#d9d9d9" strokeWidth="1" />
|
||||
|
||||
<text
|
||||
x="85"
|
||||
y="35"
|
||||
fill="#262626"
|
||||
fontSize="12"
|
||||
fontWeight="bold"
|
||||
>
|
||||
<title>{data.name || '设备'}</title>
|
||||
{data.name?.substring(0, 10) || '设备'}
|
||||
</text>
|
||||
<text
|
||||
x="85"
|
||||
y="50"
|
||||
fill="#8c8c8c"
|
||||
fontSize="10"
|
||||
>
|
||||
{data.type === 'switch' ? '交换机' :
|
||||
data.type === 'router' ? '路由器' :
|
||||
data.type === 'server' ? '服务器' :
|
||||
data.type === 'storage' ? '存储' : '设备'}
|
||||
</text>
|
||||
|
||||
{isCenter && (
|
||||
<>
|
||||
<circle cx={NODE_WIDTH - 20} cy="15" r="8" fill={nodeColor} />
|
||||
<text
|
||||
x={NODE_WIDTH - 20}
|
||||
y="19"
|
||||
fill="#fff"
|
||||
fontSize="8"
|
||||
fontWeight="bold"
|
||||
textAnchor="middle"
|
||||
>
|
||||
★
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
|
||||
<circle
|
||||
cx={NODE_WIDTH - 15}
|
||||
cy={NODE_HEIGHT - 15}
|
||||
r="4"
|
||||
fill={statusColor}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GenericNode;
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
const NODE_WIDTH = 200;
|
||||
const NODE_HEIGHT = 100;
|
||||
|
||||
const STATUS_COLORS = {
|
||||
online: '#52c41a',
|
||||
offline: '#d9d9d9',
|
||||
fault: '#ff4d4f',
|
||||
warning: '#faad14'
|
||||
};
|
||||
|
||||
function RouterNode({ data }) {
|
||||
if (!data) {
|
||||
return (
|
||||
<div style={{
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
background: '#f0f0f0',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
无数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeColor = '#722ed1';
|
||||
const statusColor = STATUS_COLORS[data.status] || STATUS_COLORS.offline;
|
||||
const isCenter = data.isCenter;
|
||||
const isSelected = data.selected;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
background: '#fff',
|
||||
borderRadius: 8,
|
||||
border: isSelected
|
||||
? `2px solid ${nodeColor}`
|
||||
: isCenter
|
||||
? `2px solid ${nodeColor}`
|
||||
: `1px solid #d9d9d9`,
|
||||
boxShadow: isCenter
|
||||
? `0 4px 16px ${nodeColor}40`
|
||||
: isSelected
|
||||
? `0 4px 12px ${nodeColor}30`
|
||||
: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '8px' }}>
|
||||
<img
|
||||
src="/png/路由器.png"
|
||||
alt="路由器"
|
||||
style={{ width: 'auto', height: '60px', objectFit: 'contain' }}
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
height: 32,
|
||||
background: '#f9f0ff',
|
||||
padding: '4px 8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<Tooltip title={data.name || '路由器'} placement="bottomLeft">
|
||||
<div style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 'bold',
|
||||
color: '#262626',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
cursor: 'pointer'
|
||||
}}>
|
||||
{data.name?.substring(0, 14) || '路由器'}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: statusColor,
|
||||
display: 'inline-block'
|
||||
}} />
|
||||
{isCenter && (
|
||||
<span style={{
|
||||
fontSize: 9,
|
||||
color: '#faad14',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
★
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RouterNode;
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
const NODE_WIDTH = 200;
|
||||
const NODE_HEIGHT = 100;
|
||||
|
||||
const STATUS_COLORS = {
|
||||
online: '#52c41a',
|
||||
offline: '#d9d9d9',
|
||||
fault: '#ff4d4f',
|
||||
warning: '#faad14'
|
||||
};
|
||||
|
||||
function ServerNode({ data }) {
|
||||
if (!data) {
|
||||
return (
|
||||
<div style={{
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
background: '#f0f0f0',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
无数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeColor = '#52c41a';
|
||||
const statusColor = STATUS_COLORS[data.status] || STATUS_COLORS.offline;
|
||||
const isCenter = data.isCenter;
|
||||
const isSelected = data.selected;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
background: '#fff',
|
||||
borderRadius: 8,
|
||||
border: isSelected
|
||||
? `2px solid ${nodeColor}`
|
||||
: isCenter
|
||||
? `2px solid ${nodeColor}`
|
||||
: `1px solid #d9d9d9`,
|
||||
boxShadow: isCenter
|
||||
? `0 4px 16px ${nodeColor}40`
|
||||
: isSelected
|
||||
? `0 4px 12px ${nodeColor}30`
|
||||
: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '8px' }}>
|
||||
<img
|
||||
src="/png/服务器.png"
|
||||
alt="服务器"
|
||||
style={{ width: 'auto', height: '60px', objectFit: 'contain' }}
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
height: 32,
|
||||
background: '#f6ffed',
|
||||
padding: '4px 8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<Tooltip title={data.name || '服务器'} placement="bottomLeft">
|
||||
<div style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 'bold',
|
||||
color: '#262626',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
cursor: 'pointer'
|
||||
}}>
|
||||
{data.name?.substring(0, 14) || '服务器'}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: statusColor,
|
||||
display: 'inline-block'
|
||||
}} />
|
||||
{isCenter && (
|
||||
<span style={{
|
||||
fontSize: 9,
|
||||
color: '#faad14',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
★
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServerNode;
|
||||
@@ -0,0 +1,226 @@
|
||||
import React from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
const NODE_WIDTH = 200;
|
||||
const NODE_HEIGHT = 110;
|
||||
|
||||
const STATUS_COLORS = {
|
||||
online: '#52c41a',
|
||||
offline: '#d9d9d9',
|
||||
fault: '#ff4d4f',
|
||||
warning: '#faad14'
|
||||
};
|
||||
|
||||
function StorageNode({ data }) {
|
||||
if (!data) {
|
||||
return (
|
||||
<div style={{
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
background: '#f0f0f0',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
无数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeColor = '#fa8c16';
|
||||
const statusColor = STATUS_COLORS[data.status] || STATUS_COLORS.offline;
|
||||
const isCenter = data.isCenter;
|
||||
const isSelected = data.selected;
|
||||
|
||||
const diskCount = 8;
|
||||
const activeDisks = Math.floor((data.portCount?.used || 12) / 4);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
background: '#fff',
|
||||
borderRadius: 8,
|
||||
border: isSelected
|
||||
? `2px solid ${nodeColor}`
|
||||
: isCenter
|
||||
? `2px solid ${nodeColor}`
|
||||
: `1px solid #d9d9d9`,
|
||||
boxShadow: isCenter
|
||||
? `0 4px 16px ${nodeColor}40`
|
||||
: isSelected
|
||||
? `0 4px 12px ${nodeColor}30`
|
||||
: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width={NODE_WIDTH}
|
||||
height={NODE_HEIGHT}
|
||||
viewBox={`0 0 ${NODE_WIDTH} ${NODE_HEIGHT}`}
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
<rect
|
||||
x="10"
|
||||
y="10"
|
||||
width={NODE_WIDTH - 20}
|
||||
height={NODE_HEIGHT - 20}
|
||||
rx="4"
|
||||
fill="#fff7e6"
|
||||
stroke={nodeColor}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
<rect
|
||||
x="15"
|
||||
y="15"
|
||||
width={NODE_WIDTH - 30}
|
||||
height="55"
|
||||
rx="3"
|
||||
fill="#fff"
|
||||
stroke={nodeColor}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
<rect
|
||||
x="20"
|
||||
y="20"
|
||||
width="35"
|
||||
height="45"
|
||||
rx="2"
|
||||
fill="#262626"
|
||||
/>
|
||||
<rect x="23" y="23" width="29" height="4" rx="1" fill="#52c41a" />
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x="23"
|
||||
y={29 + i * 6}
|
||||
width="29"
|
||||
height="4"
|
||||
rx="1"
|
||||
fill={i < activeDisks ? nodeColor : '#595959'}
|
||||
/>
|
||||
))}
|
||||
|
||||
<rect
|
||||
x="60"
|
||||
y="20"
|
||||
width="35"
|
||||
height="45"
|
||||
rx="2"
|
||||
fill="#262626"
|
||||
/>
|
||||
<rect x="63" y="23" width="29" height="4" rx="1" fill="#52c41a" />
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x="63"
|
||||
y={29 + i * 6}
|
||||
width="29"
|
||||
height="4"
|
||||
rx="1"
|
||||
fill={i < activeDisks - 2 ? nodeColor : '#595959'}
|
||||
/>
|
||||
))}
|
||||
|
||||
<rect
|
||||
x="100"
|
||||
y="20"
|
||||
width="35"
|
||||
height="45"
|
||||
rx="2"
|
||||
fill="#262626"
|
||||
/>
|
||||
<rect x="103" y="23" width="29" height="4" rx="1" fill="#52c41a" />
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x="103"
|
||||
y={29 + i * 6}
|
||||
width="29"
|
||||
height="4"
|
||||
rx="1"
|
||||
fill={i < activeDisks - 4 ? nodeColor : '#595959'}
|
||||
/>
|
||||
))}
|
||||
|
||||
<rect
|
||||
x="140"
|
||||
y="20"
|
||||
width="35"
|
||||
height="45"
|
||||
rx="2"
|
||||
fill="#262626"
|
||||
/>
|
||||
<rect x="143" y="23" width="29" height="4" rx="1" fill="#52c41a" />
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x="143"
|
||||
y={29 + i * 6}
|
||||
width="29"
|
||||
height="4"
|
||||
rx="1"
|
||||
fill={i < activeDisks - 6 ? nodeColor : '#595959'}
|
||||
/>
|
||||
))}
|
||||
|
||||
<line x1="15" y1="75" x2={NODE_WIDTH - 15} y2="75" stroke="#d9d9d9" strokeWidth="1" />
|
||||
|
||||
<rect x="15" y="80" width="80" height="5" rx="1" fill="#262626" />
|
||||
<rect x="100" y="80" width="40" height="5" rx="1" fill="#52c41a" />
|
||||
|
||||
<text
|
||||
x="15"
|
||||
y="98"
|
||||
fill="#262626"
|
||||
fontSize="10"
|
||||
fontWeight="bold"
|
||||
>
|
||||
<title>{data.name || '存储设备'}</title>
|
||||
{data.name?.substring(0, 14) || '存储设备'}
|
||||
</text>
|
||||
|
||||
<text
|
||||
x={NODE_WIDTH - 15}
|
||||
y="98"
|
||||
fill="#8c8c8c"
|
||||
fontSize="9"
|
||||
textAnchor="end"
|
||||
>
|
||||
{data.model || 'Storage'}
|
||||
</text>
|
||||
|
||||
{isCenter && (
|
||||
<>
|
||||
<circle cx={NODE_WIDTH - 20} cy="12" r="8" fill={nodeColor} />
|
||||
<text
|
||||
x={NODE_WIDTH - 20}
|
||||
y="16"
|
||||
fill="#fff"
|
||||
fontSize="8"
|
||||
fontWeight="bold"
|
||||
textAnchor="middle"
|
||||
>
|
||||
★
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
|
||||
<circle
|
||||
cx={NODE_WIDTH - 15}
|
||||
cy={NODE_HEIGHT - 12}
|
||||
r="4"
|
||||
fill={statusColor}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StorageNode;
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
const NODE_WIDTH = 200;
|
||||
const NODE_HEIGHT = 120;
|
||||
|
||||
const STATUS_COLORS = {
|
||||
online: '#52c41a',
|
||||
offline: '#d9d9d9',
|
||||
fault: '#ff4d4f',
|
||||
warning: '#faad14'
|
||||
};
|
||||
|
||||
function SwitchNode({ data }) {
|
||||
if (!data) {
|
||||
return (
|
||||
<div style={{
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
background: '#f0f0f0',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
无数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeColor = '#1890ff';
|
||||
const statusColor = STATUS_COLORS[data.status] || STATUS_COLORS.offline;
|
||||
const isCenter = data.isCenter;
|
||||
const isSelected = data.selected;
|
||||
const portCount = data.portCount || { total: 24, used: 12, free: 12 };
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
background: '#fff',
|
||||
borderRadius: 8,
|
||||
border: isSelected
|
||||
? `2px solid ${nodeColor}`
|
||||
: isCenter
|
||||
? `2px solid ${nodeColor}`
|
||||
: `1px solid #d9d9d9`,
|
||||
boxShadow: isCenter
|
||||
? `0 4px 16px ${nodeColor}40`
|
||||
: isSelected
|
||||
? `0 4px 12px ${nodeColor}30`
|
||||
: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '8px' }}>
|
||||
<img
|
||||
src="/png/交换机.png"
|
||||
alt="交换机"
|
||||
style={{ width: 'auto', height: '70px', objectFit: 'contain' }}
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
height: 36,
|
||||
background: '#e6f7ff',
|
||||
padding: '4px 8px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<Tooltip title={data.name || '交换机'} placement="bottomLeft">
|
||||
<div style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 'bold',
|
||||
color: '#262626',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: 'pointer'
|
||||
}}>
|
||||
{data.name?.substring(0, 16) || '交换机'}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 2 }}>
|
||||
<span style={{
|
||||
fontSize: 9,
|
||||
color: '#8c8c8c'
|
||||
}}>
|
||||
端口: {portCount.used}/{portCount.total}
|
||||
</span>
|
||||
<span style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: statusColor,
|
||||
display: 'inline-block'
|
||||
}} />
|
||||
{isCenter && (
|
||||
<span style={{
|
||||
fontSize: 9,
|
||||
color: '#faad14',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
★ 中心
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SwitchNode;
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as SwitchNode } from './SwitchNode';
|
||||
export { default as ServerNode } from './ServerNode';
|
||||
export { default as RouterNode } from './RouterNode';
|
||||
export { default as StorageNode } from './StorageNode';
|
||||
export { default as GenericNode } from './GenericNode';
|
||||
export { default as FirewallNode } from './FirewallNode';
|
||||
Reference in New Issue
Block a user