diff --git a/backend/config/routes.js b/backend/config/routes.js index 4941aea..c991141 100644 --- a/backend/config/routes.js +++ b/backend/config/routes.js @@ -105,6 +105,10 @@ const routesConfig = [ file: 'dangerousOperations.js', path: '/api/dangerous-operations', }, + { + file: 'topology.js', + path: '/api/topology', + }, ]; module.exports = routesConfig; diff --git a/backend/routes/topology.js b/backend/routes/topology.js new file mode 100644 index 0000000..7ec9819 --- /dev/null +++ b/backend/routes/topology.js @@ -0,0 +1,323 @@ +const express = require('express'); +const router = express.Router(); +const { Op } = require('sequelize'); +const Cable = require('../models/Cable'); +const Device = require('../models/Device'); +const DevicePort = require('../models/DevicePort'); +const Rack = require('../models/Rack'); +const Room = require('../models/Room'); + +router.get('/switch/:switchId', async (req, res) => { + try { + const { switchId } = req.params; + const { maxNodes = 100 } = req.query; + + const centerDevice = await Device.findByPk(switchId, { + include: [ + { + model: Rack, + as: 'Rack', + include: [{ model: Room, as: 'Room' }] + } + ] + }); + + if (!centerDevice) { + return res.status(404).json({ success: false, error: '交换机不存在' }); + } + + if (centerDevice.type !== 'switch') { + return res.status(400).json({ success: false, error: '指定设备不是交换机' }); + } + + const cables = await Cable.findAll({ + where: { + [Op.or]: [ + { sourceDeviceId: switchId }, + { targetDeviceId: switchId } + ] + }, + include: [ + { + model: Device, + as: 'sourceDevice', + attributes: ['deviceId', 'name', 'type', 'model', 'status', 'ipAddress', 'rackId', 'position'] + }, + { + model: Device, + as: 'targetDevice', + attributes: ['deviceId', 'name', 'type', 'model', 'status', 'ipAddress', 'rackId', 'position'] + } + ] + }); + + const connectedDeviceIds = new Set(); + cables.forEach(cable => { + if (cable.sourceDeviceId !== switchId) { + connectedDeviceIds.add(cable.sourceDeviceId); + } + if (cable.targetDeviceId !== switchId) { + connectedDeviceIds.add(cable.targetDeviceId); + } + }); + + if (connectedDeviceIds.size > parseInt(maxNodes)) { + return res.status(400).json({ + success: false, + error: `连接设备数量(${connectedDeviceIds.size})超过限制(${maxNodes}),请使用更具体的筛选条件` + }); + } + + const connectedDevices = await Device.findAll({ + where: { deviceId: { [Op.in]: Array.from(connectedDeviceIds) } }, + include: [ + { + model: Rack, + as: 'Rack', + include: [{ model: Room, as: 'Room' }] + } + ] + }); + + const deviceMap = {}; + connectedDevices.forEach(device => { + deviceMap[device.deviceId] = device.toJSON(); + }); + + const centerRack = centerDevice.Rack; + const centerRoom = centerRack?.Room; + + const nodes = [ + { + id: centerDevice.deviceId, + deviceId: centerDevice.deviceId, + name: centerDevice.name, + type: centerDevice.type, + model: centerDevice.model, + status: centerDevice.status, + ipAddress: centerDevice.ipAddress, + rackId: centerDevice.rackId, + rackName: centerRack?.name, + roomId: centerRoom?.roomId, + roomName: centerRoom?.name, + position: centerDevice.position, + isCenter: true + }, + ...connectedDevices.map(device => { + const d = device.toJSON(); + const rack = d.Rack; + const room = rack?.Room; + return { + id: d.deviceId, + deviceId: d.deviceId, + name: d.name, + type: d.type, + model: d.model, + status: d.status, + ipAddress: d.ipAddress, + rackId: d.rackId, + rackName: rack?.name, + roomId: room?.roomId, + roomName: room?.name, + position: d.position, + isCenter: false + }; + }) + ]; + + const edges = cables.map(cable => { + const sourceId = cable.sourceDeviceId === switchId ? cable.targetDeviceId : cable.sourceDeviceId; + return { + id: cable.cableId, + source: cable.sourceDeviceId, + target: cable.targetDeviceId, + sourcePort: cable.sourcePort, + targetPort: cable.targetPort, + cableId: cable.cableId, + cableType: cable.cableType, + cableLength: cable.cableLength, + cableLabel: cable.cableLabel, + cableColor: cable.cableColor, + status: cable.status, + description: cable.description, + installedAt: cable.installedAt + }; + }); + + const portStats = {}; + const allDeviceIds = [switchId, ...Array.from(connectedDeviceIds)]; + const ports = await DevicePort.findAll({ + where: { deviceId: { [Op.in]: allDeviceIds } }, + attributes: ['deviceId', 'status'] + }); + + ports.forEach(port => { + if (!portStats[port.deviceId]) { + portStats[port.deviceId] = { total: 0, used: 0, free: 0, fault: 0 }; + } + portStats[port.deviceId].total++; + if (port.status === 'occupied') portStats[port.deviceId].used++; + else if (port.status === 'free') portStats[port.deviceId].free++; + else if (port.status === 'fault') portStats[port.deviceId].fault++; + }); + + nodes.forEach(node => { + node.portCount = portStats[node.deviceId] || { total: 0, used: 0, free: 0, fault: 0 }; + }); + + const statistics = { + totalDevices: nodes.length, + totalCables: edges.length, + normalCables: edges.filter(e => e.status === 'normal').length, + faultCables: edges.filter(e => e.status === 'fault').length, + disconnectedCables: edges.filter(e => e.status === 'disconnected').length, + byDeviceType: {}, + byCableType: {} + }; + + nodes.forEach(node => { + statistics.byDeviceType[node.type] = (statistics.byDeviceType[node.type] || 0) + 1; + }); + + edges.forEach(edge => { + statistics.byCableType[edge.cableType] = (statistics.byCableType[edge.cableType] || 0) + 1; + }); + + res.json({ + success: true, + data: { + centerDevice: nodes[0], + nodes: nodes.slice(1), + edges, + statistics + } + }); + } catch (error) { + console.error('获取拓扑数据失败:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get('/rack/:rackId', async (req, res) => { + try { + const { rackId } = req.params; + const { maxNodes = 100 } = req.query; + + const devices = await Device.findAll({ + where: { rackId }, + attributes: ['deviceId'] + }); + + const deviceIds = devices.map(d => d.deviceId); + + if (deviceIds.length === 0) { + return res.json({ + success: true, + data: { + nodes: [], + edges: [], + statistics: { + totalDevices: 0, + totalCables: 0, + normalCables: 0, + faultCables: 0, + disconnectedCables: 0 + } + } + }); + } + + const cables = await Cable.findAll({ + where: { + [Op.or]: [ + { sourceDeviceId: { [Op.in]: deviceIds } }, + { targetDeviceId: { [Op.in]: deviceIds } } + ] + }, + include: [ + { + model: Device, + as: 'sourceDevice', + attributes: ['deviceId', 'name', 'type', 'model', 'status', 'ipAddress', 'rackId', 'position'] + }, + { + model: Device, + as: 'targetDevice', + attributes: ['deviceId', 'name', 'type', 'model', 'status', 'ipAddress', 'rackId', 'position'] + } + ] + }); + + const relatedDeviceIds = new Set(deviceIds); + cables.forEach(cable => { + relatedDeviceIds.add(cable.sourceDeviceId); + relatedDeviceIds.add(cable.targetDeviceId); + }); + + if (relatedDeviceIds.size > parseInt(maxNodes)) { + return res.status(400).json({ + success: false, + error: `设备数量(${relatedDeviceIds.size})超过限制(${maxNodes})` + }); + } + + const allDevices = await Device.findAll({ + where: { deviceId: { [Op.in]: Array.from(relatedDeviceIds) } }, + include: [ + { + model: Rack, + as: 'Rack', + include: [{ model: Room, as: 'Room' }] + } + ] + }); + + const nodes = allDevices.map(device => { + const d = device.toJSON(); + const rack = d.Rack; + const room = rack?.Room; + return { + id: d.deviceId, + deviceId: d.deviceId, + name: d.name, + type: d.type, + model: d.model, + status: d.status, + ipAddress: d.ipAddress, + rackId: d.rackId, + rackName: rack?.name, + roomId: room?.roomId, + roomName: room?.name, + position: d.position, + isCenter: deviceIds.includes(d.deviceId) + }; + }); + + const edges = cables.map(cable => ({ + id: cable.cableId, + source: cable.sourceDeviceId, + target: cable.targetDeviceId, + sourcePort: cable.sourcePort, + targetPort: cable.targetPort, + cableId: cable.cableId, + cableType: cable.cableType, + cableLength: cable.cableLength, + status: cable.status + })); + + const statistics = { + totalDevices: nodes.length, + totalCables: edges.length, + normalCables: edges.filter(e => e.status === 'normal').length, + faultCables: edges.filter(e => e.status === 'fault').length, + disconnectedCables: edges.filter(e => e.status === 'disconnected').length + }; + + res.json({ success: true, data: { nodes, edges, statistics } }); + } catch (error) { + console.error('获取机柜拓扑数据失败:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +module.exports = router; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b6411b8..565b034 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,8 +11,10 @@ "@ant-design/icons": "^6.1.0", "@react-three/drei": "^9.122.0", "@react-three/fiber": "^8.18.0", + "@types/dagre": "^0.7.54", "antd": "^5.8.6", "axios": "^1.13.6", + "dagre": "^0.8.5", "dayjs": "^1.11.19", "framer-motion": "^12.34.0", "html2canvas": "^1.4.1", @@ -2823,6 +2825,12 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/dagre": { + "version": "0.7.54", + "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz", + "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==", + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -4172,6 +4180,16 @@ "node": ">=12" } }, + "node_modules/dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "license": "MIT", + "dependencies": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, "node_modules/data-urls": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", @@ -5328,6 +5346,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -6249,6 +6276,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index b715f8b..cd36079 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,8 +16,10 @@ "@ant-design/icons": "^6.1.0", "@react-three/drei": "^9.122.0", "@react-three/fiber": "^8.18.0", + "@types/dagre": "^0.7.54", "antd": "^5.8.6", "axios": "^1.13.6", + "dagre": "^0.8.5", "dayjs": "^1.11.19", "framer-motion": "^12.34.0", "html2canvas": "^1.4.1", diff --git a/frontend/public/png/交换机.png b/frontend/public/png/交换机.png new file mode 100644 index 0000000..9de9a0a Binary files /dev/null and b/frontend/public/png/交换机.png differ diff --git a/frontend/public/png/服务器.png b/frontend/public/png/服务器.png new file mode 100644 index 0000000..7613a29 Binary files /dev/null and b/frontend/public/png/服务器.png differ diff --git a/frontend/public/png/路由器.png b/frontend/public/png/路由器.png new file mode 100644 index 0000000..3a3c9f6 Binary files /dev/null and b/frontend/public/png/路由器.png differ diff --git a/frontend/public/png/防火墙.png b/frontend/public/png/防火墙.png new file mode 100644 index 0000000..baa33b1 Binary files /dev/null and b/frontend/public/png/防火墙.png differ diff --git a/frontend/src/components/CableWizardModal.jsx b/frontend/src/components/CableWizardModal.jsx index de39a1a..b639941 100644 --- a/frontend/src/components/CableWizardModal.jsx +++ b/frontend/src/components/CableWizardModal.jsx @@ -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 (
- 步骤 1: 选择源设备 + 步骤 1: 选择源设备端口 } size="small" style={{ marginBottom: '20px' }} > -
- - 选择接线的起点设备 - -
- -
- -
- -
- {fetchingDevices ? ( -
- + {!isSourcePreSelected && ( + <> +
+ + 选择接线的起点设备 +
- ) : ( - devices.map(device => ( - - 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', - }} - > -
-
+ +
+ +
+ {fetchingDevices ? ( +
+ +
+ ) : ( + devices.map(device => ( + + 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' ? '💾' : '📦'} -
-
-
-
- {device.name || device.deviceId} +
+
+ {device.type === 'server' ? '🖥️' : device.type === 'switch' ? '📡' : device.type === 'router' ? '🔀' : device.type === 'storage' ? '💾' : '📦'}
- {device.status && getStatusTag(device.status)} -
-
- - ID: - {device.deviceId} - - - 类型: - {device.type === 'server' ? '服务器' : device.type === 'switch' ? '交换机' : device.type === 'router' ? '路由器' : device.type === 'storage' ? '存储设备' : device.type} - -
-
- {device.Rack?.Room?.name && ( - - 机房: - {device.Rack.Room.name} - - )} - {device.Rack?.name && ( - - 机柜: - {device.Rack.name} - - )} - {device.position && ( - - U位: - U{device.position} - - )} - {device.ipAddress && ( - - IP: - {device.ipAddress} - - )} -
- {device.model && ( -
- 型号: - {device.model} +
+
+
+ {device.name || device.deviceId} +
+ {device.status && getStatusTag(device.status)} +
+
+ + ID: + {device.deviceId} + + + 类型: + {device.type === 'server' ? '服务器' : device.type === 'switch' ? '交换机' : device.type === 'router' ? '路由器' : device.type === 'storage' ? '存储设备' : device.type} + +
+
+ {device.Rack?.Room?.name && ( + + 机房: + {device.Rack.Room.name} + + )} + {device.Rack?.name && ( + + 机柜: + {device.Rack.name} + + )} + {device.position && ( + + U位: + U{device.position} + + )} + {device.ipAddress && ( + + IP: + {device.ipAddress} + + )} +
+ {device.model && ( +
+ 型号: + {device.model} +
+ )}
- )} -
- {sourceDevice?.deviceId === device.deviceId && ( - - )} -
- - - )) - )} -
+ {sourceDevice?.deviceId === device.deviceId && ( + + )} +
+ + + )) + )} +
+ + )} {sourceDevice && ( -
+
+ - 源设备已选择: {sourceDevice.name} + 源设备: {sourceDevice.name} + {isSourcePreSelected && ( + 已选择 + )} + + {sourcePorts.filter(p => p.status === 'free').length} 个空闲端口 +
-
- - 端口选择 - - {sourcePorts.filter(p => p.status === 'free').length} 个空闲端口 - -
- {sourcePorts.length === 0 ? ( ) : ( @@ -848,9 +860,6 @@ const Step1SourceDevice = ({
端口总数: {sourcePorts.length} | 空闲端口: {sourcePorts.filter(p => p.status === 'free').length}
-
- 端口状态分布: {sourcePorts.map(p => p.status).filter((v, i, a) => a.indexOf(v) === i).join(', ')} -
{sourcePort && (
+ + 选择交换机 + + } + extra={ + + @@ -1866,6 +1878,11 @@ function CableManagement() {
)} + + setTopologyModalVisible(false)} + /> ); }