diff --git a/.gitignore b/.gitignore index d80c98e..578783b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ build/ .env.test.local .env.production.local .gitignore +.vercelignore # Logs logs diff --git a/backend/routes/cables.js b/backend/routes/cables.js index 0f32b36..65e502b 100644 --- a/backend/routes/cables.js +++ b/backend/routes/cables.js @@ -3,6 +3,29 @@ const router = express.Router(); const { Op } = require('sequelize'); const Cable = require('../models/Cable'); const Device = require('../models/Device'); +const DevicePort = require('../models/DevicePort'); + +// 辅助函数:更新端口状态 +async function updatePortStatus(deviceId, portName, status) { + try { + await DevicePort.update( + { status }, + { where: { deviceId, portName } } + ); + } catch (error) { + console.error(`更新端口状态失败: ${deviceId}:${portName} -> ${status}`, error); + } +} + +// 辅助函数:将端口状态设为occupied +async function occupyPort(deviceId, portName) { + await updatePortStatus(deviceId, portName, 'occupied'); +} + +// 辅助函数:将端口状态恢复为free +async function freePort(deviceId, portName) { + await updatePortStatus(deviceId, portName, 'free'); +} router.get('/', async (req, res) => { try { @@ -136,33 +159,146 @@ router.get('/rack/:rackId', async (req, res) => { } }); -router.post('/', async (req, res) => { +// 检查接线冲突 +router.post('/check-conflict', async (req, res) => { try { - const { cableId, sourceDeviceId, sourcePort, targetDeviceId, targetPort, cableType, cableLength, status, description } = req.body; - + const { sourceDeviceId, sourcePort, targetDeviceId, targetPort, excludeCableId } = req.body; + if (!sourceDeviceId || !sourcePort || !targetDeviceId || !targetPort) { return res.status(400).json({ error: '缺少必填字段' }); } - - if (sourceDeviceId === targetDeviceId) { - return res.status(400).json({ error: '源设备和目标设备不能相同' }); - } - - const existingCable = await Cable.findOne({ + + const conflicts = []; + + // 检查源端口冲突 + const sourceConflict = await Cable.findOne({ where: { [Op.or]: [ { sourceDeviceId, sourcePort }, - { targetDeviceId, targetPort } - ] - } + { targetDeviceId: sourceDeviceId, targetPort: sourcePort } + ], + ...(excludeCableId && { cableId: { [Op.ne]: excludeCableId } }) + }, + include: [ + { + model: Device, + as: 'sourceDevice', + attributes: ['deviceId', 'name', 'type'] + }, + { + model: Device, + as: 'targetDevice', + attributes: ['deviceId', 'name', 'type'] + } + ] }); - - if (existingCable) { - return res.status(400).json({ error: '端口已被占用' }); + + if (sourceConflict) { + conflicts.push({ + type: 'source', + port: sourcePort, + deviceId: sourceDeviceId, + existingCable: sourceConflict + }); } - + + // 检查目标端口冲突 + const targetConflict = await Cable.findOne({ + where: { + [Op.or]: [ + { sourceDeviceId: targetDeviceId, sourcePort: targetPort }, + { targetDeviceId, targetPort } + ], + ...(excludeCableId && { cableId: { [Op.ne]: excludeCableId } }) + }, + include: [ + { + model: Device, + as: 'sourceDevice', + attributes: ['deviceId', 'name', 'type'] + }, + { + model: Device, + as: 'targetDevice', + attributes: ['deviceId', 'name', 'type'] + } + ] + }); + + if (targetConflict) { + conflicts.push({ + type: 'target', + port: targetPort, + deviceId: targetDeviceId, + existingCable: targetConflict + }); + } + + res.json({ + hasConflict: conflicts.length > 0, + conflicts + }); + } catch (error) { + console.error('检查接线冲突失败:', error); + res.status(500).json({ error: error.message }); + } +}); + +router.post('/', async (req, res) => { + try { + const { cableId, sourceDeviceId, sourcePort, targetDeviceId, targetPort, cableType, cableLength, status, description, force } = req.body; + + if (!sourceDeviceId || !sourcePort || !targetDeviceId || !targetPort) { + return res.status(400).json({ error: '缺少必填字段' }); + } + + if (sourceDeviceId === targetDeviceId) { + return res.status(400).json({ error: '源设备和目标设备不能相同' }); + } + + // 如果不是强制模式,检查冲突 + if (!force) { + const existingCable = await Cable.findOne({ + where: { + [Op.or]: [ + { sourceDeviceId, sourcePort }, + { targetDeviceId, targetPort } + ] + } + }); + + if (existingCable) { + return res.status(409).json({ + error: '端口已被占用', + conflict: true, + existingCable + }); + } + } + + // 如果是强制模式,先断开原有连接 + if (force) { + const existingCables = await Cable.findAll({ + where: { + [Op.or]: [ + { sourceDeviceId, sourcePort }, + { targetDeviceId: sourceDeviceId, targetPort: sourcePort }, + { sourceDeviceId: targetDeviceId, sourcePort: targetPort }, + { targetDeviceId, targetPort } + ] + } + }); + + for (const cable of existingCables) { + await cable.destroy(); + // 释放原有端口 + await freePort(cable.sourceDeviceId, cable.sourcePort); + await freePort(cable.targetDeviceId, cable.targetPort); + } + } + const autoCableId = cableId || `CABLE-${Date.now()}-${Math.floor(Math.random() * 10000)}`; - + const cable = await Cable.create({ cableId: autoCableId, sourceDeviceId, @@ -174,7 +310,7 @@ router.post('/', async (req, res) => { status: status || 'normal', description }); - + const createdCable = await Cable.findByPk(cable.cableId, { include: [ { @@ -189,7 +325,11 @@ router.post('/', async (req, res) => { } ] }); - + + // 自动将源端口和目标端口状态设为occupied + await occupyPort(sourceDeviceId, sourcePort); + await occupyPort(targetDeviceId, targetPort); + res.status(201).json(createdCable); } catch (error) { console.error('创建接线失败:', error); @@ -250,6 +390,10 @@ router.post('/batch', async (req, res) => { description: cableData.description }); + // 自动将源端口和目标端口状态设为occupied + await occupyPort(cableData.sourceDeviceId, cableData.sourcePort); + await occupyPort(cableData.targetDeviceId, cableData.targetPort); + results.success++; } catch (error) { results.failed++; @@ -270,6 +414,15 @@ router.post('/batch', async (req, res) => { router.put('/:cableId', async (req, res) => { try { + // 获取更新前的接线信息 + const oldCable = await Cable.findByPk(req.params.cableId); + + if (!oldCable) { + return res.status(404).json({ error: '接线不存在' }); + } + + const { sourceDeviceId: oldSourceDeviceId, sourcePort: oldSourcePort, targetDeviceId: oldTargetDeviceId, targetPort: oldTargetPort } = oldCable; + const [updated] = await Cable.update(req.body, { where: { cableId: req.params.cableId } }); @@ -289,6 +442,22 @@ router.put('/:cableId', async (req, res) => { } ] }); + + const { sourceDeviceId, sourcePort, targetDeviceId, targetPort } = cable; + + // 同步更新端口状态 + // 源端口变更:释放旧端口,占用新端口 + if (oldSourceDeviceId !== sourceDeviceId || oldSourcePort !== sourcePort) { + await freePort(oldSourceDeviceId, oldSourcePort); + await occupyPort(sourceDeviceId, sourcePort); + } + + // 目标端口变更:释放旧端口,占用新端口 + if (oldTargetDeviceId !== targetDeviceId || oldTargetPort !== targetPort) { + await freePort(oldTargetDeviceId, oldTargetPort); + await occupyPort(targetDeviceId, targetPort); + } + res.json(cable); } else { res.status(404).json({ error: '接线不存在' }); @@ -301,11 +470,24 @@ router.put('/:cableId', async (req, res) => { router.delete('/:cableId', async (req, res) => { try { + // 先获取接线信息,用于后续恢复端口状态 + const cable = await Cable.findByPk(req.params.cableId); + + if (!cable) { + return res.status(404).json({ error: '接线不存在' }); + } + + const { sourceDeviceId, sourcePort, targetDeviceId, targetPort } = cable; + const deleted = await Cable.destroy({ where: { cableId: req.params.cableId } }); if (deleted) { + // 自动将源端口和目标端口状态恢复为free + await freePort(sourceDeviceId, sourcePort); + await freePort(targetDeviceId, targetPort); + res.status(204).json(); } else { res.status(404).json({ error: '接线不存在' }); @@ -324,10 +506,21 @@ router.delete('/batch', async (req, res) => { return res.status(400).json({ error: '请提供有效的接线ID列表' }); } + // 先获取所有要删除的接线信息,用于后续恢复端口状态 + const cables = await Cable.findAll({ + where: { cableId: { [Op.in]: cableIds } } + }); + const deletedCount = await Cable.destroy({ where: { cableId: { [Op.in]: cableIds } } }); + // 自动将所有相关端口状态恢复为free + for (const cable of cables) { + await freePort(cable.sourceDeviceId, cable.sourcePort); + await freePort(cable.targetDeviceId, cable.targetPort); + } + res.json({ message: `批量删除成功,已删除 ${deletedCount} 条接线`, deletedCount diff --git a/frontend/src/components/DeviceDetailDrawer.jsx b/frontend/src/components/DeviceDetailDrawer.jsx index 947b153..5bf370d 100644 --- a/frontend/src/components/DeviceDetailDrawer.jsx +++ b/frontend/src/components/DeviceDetailDrawer.jsx @@ -183,9 +183,13 @@ function DeviceDetailDrawer({ device, visible, onClose, cables, onRefreshCables, return ( - - 设备详情 - {device.name} + + + 设备详情 - {device.name} } placement="right" diff --git a/frontend/src/components/PortPanel.jsx b/frontend/src/components/PortPanel.jsx new file mode 100644 index 0000000..7948c43 --- /dev/null +++ b/frontend/src/components/PortPanel.jsx @@ -0,0 +1,517 @@ +import React, { useState } from 'react'; +import { Tooltip, Badge, Divider, Pagination } from 'antd'; +import { LinkOutlined, SwapRightOutlined, AimOutlined, NodeIndexOutlined } from '@ant-design/icons'; + +const PortPanel = ({ ports, deviceName, deviceId, cables = [], devices = [], onPortClick, compact = false }) => { + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(48); // 默认每页48个端口 + + // 按端口名称排序(升序) + const sortedPorts = [...ports].sort((a, b) => { + // 尝试按数字部分排序,支持格式如:1/0/1, eth0/1, GigabitEthernet1/0/1 等 + const extractNumbers = (str) => { + const matches = str.match(/\d+/g); + return matches ? matches.map(Number) : []; + }; + + const numsA = extractNumbers(a.portName); + const numsB = extractNumbers(b.portName); + + // 逐个比较数字部分 + for (let i = 0; i < Math.min(numsA.length, numsB.length); i++) { + if (numsA[i] !== numsB[i]) { + return numsA[i] - numsB[i]; + } + } + + // 如果数字部分相同,按字符串排序 + return a.portName.localeCompare(b.portName); + }); + + // 分页数据 + const totalPorts = sortedPorts.length; + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + const paginatedPorts = sortedPorts.slice(startIndex, endIndex); + + // 获取端口状态颜色 + const getPortStatusColor = (status) => { + switch (status) { + case 'free': + return '#6b7280'; // 灰色 - 空闲 + case 'occupied': + return '#10b981'; // 绿色 - 占用 + case 'fault': + return '#ef4444'; // 红色 - 故障 + case 'disabled': + return '#374151'; // 深灰色 - 禁用 + default: + return '#6b7280'; + } + }; + + // 获取端口状态文本 + const getPortStatusText = (status) => { + switch (status) { + case 'free': + return '空闲'; + case 'occupied': + return '已连接'; + case 'fault': + return '故障'; + case 'disabled': + return '禁用'; + default: + return '空闲'; + } + }; + + // 获取端口类型图标 - 使用更真实的端口符号 + const getPortTypeIcon = (portType) => { + switch (portType) { + case 'RJ45': + return '⬡'; // 六边形表示网口 + case 'SFP': + case 'SFP+': + case 'SFP28': + return '▭'; // 矩形表示SFP + case 'QSFP': + case 'QSFP28': + return '▯'; // 宽矩形表示QSFP + default: + return '⬡'; + } + }; + + // 获取简化端口显示名称(只显示数字) + const getPortDisplayName = (portName) => { + // 提取最后的数字 + const match = portName.match(/(\d+)$/); + if (match) { + return match[1]; + } + // 如果没有数字,返回原名称 + return portName; + }; + + // 获取线缆类型文本 + const getCableTypeText = (cableType) => { + const typeMap = { + 'ethernet': '网线', + 'fiber': '光纤', + 'copper': '铜缆', + 'power': '电源线' + }; + return typeMap[cableType] || cableType || '未知'; + }; + + // 获取线缆类型颜色 + const getCableTypeColor = (cableType) => { + const colorMap = { + 'ethernet': '#52c41a', + 'fiber': '#1890ff', + 'copper': '#faad14', + 'power': '#ff4d4f' + }; + return colorMap[cableType] || '#999'; + }; + + // 查找端口关联的接线 + const findPortCable = (port) => { + if (!cables || cables.length === 0) return null; + + return cables.find(cable => + (cable.sourceDeviceId === deviceId && cable.sourcePortId === port.portId) || + (cable.targetDeviceId === deviceId && cable.targetPortId === port.portId) || + (cable.sourceDeviceId === deviceId && cable.sourcePort === port.portName) || + (cable.targetDeviceId === deviceId && cable.targetPort === port.portName) + ); + }; + + // 获取连接的对端信息 + const getPeerInfo = (cable, currentPort) => { + if (!cable) return null; + + const isSource = cable.sourceDeviceId === deviceId || + (cable.sourcePortId && cable.sourcePortId === currentPort.portId) || + cable.sourcePort === currentPort.portName; + + if (isSource) { + // 当前是源端,返回目标端信息 + const targetDevice = devices.find(d => d.deviceId === cable.targetDeviceId); + return { + deviceName: targetDevice?.name || cable.targetDeviceId, + deviceId: cable.targetDeviceId, + portName: cable.targetPort || cable.targetPortId, + direction: 'out' + }; + } else { + // 当前是目标端,返回源端信息 + const sourceDevice = devices.find(d => d.deviceId === cable.sourceDeviceId); + return { + deviceName: sourceDevice?.name || cable.sourceDeviceId, + deviceId: cable.sourceDeviceId, + portName: cable.sourcePort || cable.sourcePortId, + direction: 'in' + }; + } + }; + + // 渲染端口详情提示 + const renderPortTooltip = (port) => { + const cable = findPortCable(port); + const peerInfo = cable ? getPeerInfo(cable, port) : null; + + return ( +
+ {/* 端口基本信息 */} +
+ + {port.portName} +
+
+
端口类型: {port.portType}
+
端口速率: {port.portSpeed}
+
状态: + + {getPortStatusText(port.status)} + +
+ {port.vlanId &&
VLAN: {port.vlanId}
} + {port.description &&
描述: {port.description}
} +
+ + {/* 接线信息 */} + {cable && peerInfo && ( + <> + +
+ + 接线详情 +
+
+ {/* 线缆类型和长度 */} +
+ + {getCableTypeText(cable.cableType)} + + {cable.cableLength && ( + + {cable.cableLength}m + + )} +
+ + {/* 连接方向 */} +
+
+
+ {peerInfo.direction === 'out' ? '📤' : '📥'} +
+
+ {peerInfo.direction === 'out' ? '输出' : '输入'} +
+
+ +
+
+ {peerInfo.deviceName} +
+
+ 端口: {peerInfo.portName} +
+
+ ID: {peerInfo.deviceId} +
+
+
+ + {/* 线缆标签/备注 */} + {cable.label && ( +
+ 标签: {cable.label} +
+ )} + {cable.notes && ( +
+ 备注: {cable.notes} +
+ )} +
+ + )} + + {/* 空闲端口提示 */} + {port.status === 'free' && !cable && ( + <> + +
+ + 端口空闲,暂无接线 +
+ + )} +
+ ); + }; + + return ( +
+ {/* 设备标题 - compact 模式下隐藏 */} + {!compact && ( +
+
+
+ 🔌 +
+
+
+ {deviceName || '交换机'} +
+
+ {sortedPorts.length} 个端口 +
+
+
+ + {/* 状态图例 */} +
+
+
+ 空闲 +
+
+
+ 已连接 +
+
+
+ 故障 +
+
+
+ )} + + {/* 端口网格 - 固定每行24个端口 */} +
+ {paginatedPorts.map((port) => { + const statusColor = getPortStatusColor(port.status); + const isClickable = onPortClick && port.status !== 'disabled'; + const cable = findPortCable(port); + + return ( + +
isClickable && onPortClick(port)} + style={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: '4px', + cursor: isClickable ? 'pointer' : 'not-allowed', + transition: 'all 0.2s ease', + position: 'relative', + minWidth: '0' + }} + > + {/* LED 指示灯 - 在端口上方 */} +
+ + {/* 端口主体 - 矩形样式 */} +
+ {/* 端口内部图标 */} +
+ {getPortTypeIcon(port.portType)} +
+ + {/* 接线指示标记 */} + {cable && ( +
+ )} +
+ + {/* 端口名称 - 在端口下方 */} +
+ {getPortDisplayName(port.portName)} +
+
+ + ); + })} +
+ + {/* 分页 */} + {totalPorts > pageSize && ( +
+ { + setCurrentPage(page); + if (size) setPageSize(size); + }} + showSizeChanger + showQuickJumper + showTotal={(total) => `共 ${total} 个端口`} + pageSizeOptions={['24', '48', '96']} + size="small" + style={{ + color: 'rgba(255, 255, 255, 0.8)' + }} + /> +
+ )} + + {/* 添加脉冲动画 */} + +
+ ); +}; + +export default PortPanel; diff --git a/frontend/src/components/ServerBackplanePanel.jsx b/frontend/src/components/ServerBackplanePanel.jsx new file mode 100644 index 0000000..8a7a8a8 --- /dev/null +++ b/frontend/src/components/ServerBackplanePanel.jsx @@ -0,0 +1,590 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Badge, Space, Typography, Spin, Empty, Button, Tooltip, Tag, Modal } from 'antd'; +import { + CloudServerOutlined, + PlusOutlined, + ReloadOutlined, + ApiOutlined, + ThunderboltOutlined, + DesktopOutlined, + UsbOutlined, + MonitorOutlined, + SettingOutlined +} from '@ant-design/icons'; +import PortPanel from './PortPanel'; +import axios from 'axios'; + +const { Text } = Typography; + +const designTokens = { + colors: { + primary: { main: '#667eea', gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }, + success: '#10b981', + error: '#ef4444', + warning: '#f59e0b', + metal: { light: '#9ca3af', DEFAULT: '#6b7280', dark: '#4b5563' }, + slot: { empty: '#d1d5db', occupied: '#3b82f6' } + } +}; + +/** + * 服务器背板可视化组件 + * 按照真实服务器背板布局展示网卡和端口 + * + * @param {string} deviceId - 设备ID + * @param {string} deviceName - 设备名称 + * @param {Object[]} cables - 接线列表 + * @param {Object[]} allDevices - 所有设备列表 + * @param {Function} onPortClick - 端口点击回调 + * @param {Function} onManageNetworkCards - 网卡管理回调 + */ +const ServerBackplanePanel = ({ + deviceId, + deviceName, + cables, + allDevices, + onPortClick, + onManageNetworkCards +}) => { + const [cards, setCards] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedSlot, setSelectedSlot] = useState(null); + + // 获取网卡及端口数据 + const fetchData = useCallback(async () => { + if (!deviceId) return; + + try { + setLoading(true); + const response = await axios.get(`/api/network-cards/device/${deviceId}/with-ports`); + const cardsData = response.data || []; + setCards(cardsData); + } catch (error) { + console.error('获取网卡数据失败:', error); + setCards([]); + } finally { + setLoading(false); + } + }, [deviceId]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // 按类型和槽位号对网卡进行分类 + const categorizeCards = () => { + const onboard = []; // 板载网卡 + const management = []; // 管理口 + const expansionSlots = []; // 扩展插槽 + + cards.forEach(card => { + const slotNum = card.slotNumber; + const name = (card.name || '').toLowerCase(); + + // 判断网卡类型 + if (name.includes('idrac') || name.includes('ilo') || name.includes('bmc') || name.includes('mgmt') || name.includes('管理')) { + management.push({ ...card, type: 'management' }); + } else if (slotNum === 0 || name.includes('onboard') || name.includes('板载') || name.includes('内置')) { + onboard.push({ ...card, type: 'onboard' }); + } else { + expansionSlots.push({ ...card, type: 'expansion', slotIndex: slotNum }); + } + }); + + // 按槽位号排序扩展插槽 + expansionSlots.sort((a, b) => (a.slotNumber || 0) - (b.slotNumber || 0)); + + return { onboard, management, expansionSlots }; + }; + + const { onboard, management, expansionSlots } = categorizeCards(); + + // 渲染管理口区域(左侧) + const renderManagementArea = () => { + const mgmtCard = management[0]; + + return ( +
+
MGMT
+ {mgmtCard ? ( +
setSelectedSlot(mgmtCard)} + style={{ + width: '48px', + height: '48px', + background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', + borderRadius: '4px', + border: `2px solid ${mgmtCard.ports?.length > 0 ? designTokens.colors.success : '#6b7280'}`, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + transition: 'all 0.2s', + boxShadow: '0 2px 4px rgba(0,0,0,0.3)' + }} + > + + + {mgmtCard.ports?.length || 0}口 + +
+ ) : ( +
+ +
+ )} + + {/* 其他接口占位 */} +
+ VGA +
+
+ USB +
+
+ ); + }; + + // 渲染板载网卡区域 + const renderOnboardArea = () => { + const onboardCard = onboard[0]; + + return ( +
+
+ 板载网卡 (Onboard) + {onboardCard && ( + + )} +
+ + {onboardCard ? ( +
setSelectedSlot(onboardCard)} + style={{ + background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', + borderRadius: '6px', + padding: '12px', + border: `2px solid ${onboardCard.ports?.length > 0 ? designTokens.colors.primary.main : '#6b7280'}`, + cursor: 'pointer', + transition: 'all 0.2s' + }} + > + {/* 4个RJ45端口布局 */} +
+ {[0, 1, 2, 3].map((idx) => { + const port = onboardCard.ports?.[idx]; + const hasPort = !!port; + const isOccupied = hasPort && port.status === 'occupied'; + + return ( + +
+ {/* LED指示灯 */} +
+ + + {hasPort ? idx + 1 : '-'} + +
+ + ); + })} +
+
+ ) : ( +
+ + 添加板载网卡 +
+ )} +
+ ); + }; + + // 渲染扩展插槽区域 + const renderExpansionSlots = () => { + // 标准2U服务器通常有4-8个PCIe插槽 + const totalSlots = 6; + const slots = []; + + for (let i = 1; i <= totalSlots; i++) { + const card = expansionSlots.find(c => c.slotNumber === i); + slots.push({ slotNumber: i, card }); + } + + return ( +
+
+ PCIe 扩展插槽 + + + /{totalSlots} + +
+ +
+ {slots.map(({ slotNumber, card }) => ( + +
card && setSelectedSlot(card)} + style={{ + width: '70px', + height: '90px', + background: card + ? 'linear-gradient(145deg, #1f2937 0%, #111827 100%)' + : '#374151', + borderRadius: '4px', + border: `2px solid ${card ? designTokens.colors.slot.occupied : designTokens.colors.slot.empty}`, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'space-between', + padding: '6px', + cursor: card ? 'pointer' : 'default', + transition: 'all 0.2s', + boxShadow: card ? '0 2px 8px rgba(59, 130, 246, 0.3)' : 'none' + }} + > + Slot {slotNumber} + + {card ? ( + <> + +
+ {card.ports?.slice(0, 4).map((port, idx) => ( +
+ ))} + {card.ports?.length > 4 && ( + +{card.ports.length - 4} + )} +
+ {card.ports?.length || 0}口 + + ) : ( + <> +
+ 空闲 + + )} +
+ + ))} +
+
+ ); + }; + + // 渲染电源区域(右侧) + const renderPowerArea = () => { + return ( +
+ 电源 + {[1, 2].map((psu) => ( +
+ + PSU {psu} +
+
+ ))} +
+ ); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* 工具栏 */} +
+ + !c.isUngrouped).length} style={{ backgroundColor: designTokens.colors.primary.main }} /> + 个网卡 + acc + (card.ports?.length || 0), 0)} + style={{ backgroundColor: '#667eea' }} + /> + 个端口 + + + + + +
+ + {/* 服务器背板主体 */} +
+ {/* 服务器标识 */} +
+ + + {deviceName || '服务器'} - 背板视图 + +
+ + {/* 背板布局 */} +
+ {/* 左侧:管理口区域 */} + {renderManagementArea()} + + {/* 中间左:板载网卡 */} + {renderOnboardArea()} + + {/* 中间:扩展插槽 */} + {renderExpansionSlots()} + + {/* 右侧:电源 */} + {renderPowerArea()} +
+
+ + {/* 选中插槽的端口详情模态框 */} + + + + {selectedSlot?.name} + {selectedSlot?.slotNumber > 0 && ` (Slot ${selectedSlot.slotNumber})`} + +
+ } + open={!!selectedSlot} + onCancel={() => setSelectedSlot(null)} + footer={null} + width={700} + destroyOnClose + > + {selectedSlot && ( +
+
+ + 类型: {selectedSlot.type === 'onboard' ? '板载网卡' : selectedSlot.type === 'management' ? '管理口' : '扩展网卡'} + {selectedSlot.description && 描述: {selectedSlot.description}} +
+ 端口统计: + + 空闲: {selectedSlot.stats?.free || 0} + 占用: {selectedSlot.stats?.occupied || 0} + {selectedSlot.stats?.fault > 0 && 故障: {selectedSlot.stats.fault}} + 总计: {selectedSlot.ports?.length || 0} + +
+
+
+ + {selectedSlot.ports && selectedSlot.ports.length > 0 ? ( + + ) : ( + + 该网卡暂无端口 +
+ + + } + /> + )} +
+ )} + +
+ ); +}; + +export default ServerBackplanePanel; diff --git a/frontend/src/components/VirtualDeviceList.jsx b/frontend/src/components/VirtualDeviceList.jsx new file mode 100644 index 0000000..054e9a3 --- /dev/null +++ b/frontend/src/components/VirtualDeviceList.jsx @@ -0,0 +1,345 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { Button, Empty, Spin, Badge, Typography, Space, Checkbox, Tooltip } from 'antd'; +import { DownOutlined, UpOutlined, EyeOutlined, EyeInvisibleOutlined, PlusOutlined, CloudServerOutlined } from '@ant-design/icons'; +import ServerBackplanePanel from './ServerBackplanePanel'; + +const { Text } = Typography; + +/** + * 虚拟设备列表组件 + * 用于优化大量设备面板的渲染性能 + * + * @param {Object[]} devices - 设备列表 + * @param {Object} groupedPorts - 按设备分组的端口数据 + * @param {Object[]} cables - 接线列表 + * @param {Object[]} allDevices - 所有设备列表(用于查找设备信息) + * @param {Function} onPortClick - 端口点击回调 + * @param {Function} onAddPort - 添加端口回调 (device) => void + * @param {Function} onManageNetworkCards - 网卡管理回调 (device) => void + * @param {number} initialVisibleCount - 初始显示数量 + * @param {number} loadMoreCount - 每次加载更多数量 + */ +const VirtualDeviceList = ({ + devices, + groupedPorts, + cables, + allDevices, + onPortClick, + onAddPort, + onManageNetworkCards, + initialVisibleCount = 5, + loadMoreCount = 5 +}) => { + const [visibleCount, setVisibleCount] = useState(initialVisibleCount); + const [loading, setLoading] = useState(false); + const [expandedDevices, setExpandedDevices] = useState({}); + const [showAll, setShowAll] = useState(false); + const containerRef = useRef(null); + const observerRef = useRef(null); + + // 初始化展开状态 + useEffect(() => { + const initialExpanded = {}; + devices.slice(0, initialVisibleCount).forEach((device, index) => { + initialExpanded[device.deviceId] = index < 3; // 前3个默认展开 + }); + setExpandedDevices(initialExpanded); + }, [devices, initialVisibleCount]); + + // 无限滚动观察器 + useEffect(() => { + if (showAll) return; + + const options = { + root: null, + rootMargin: '100px', + threshold: 0.1 + }; + + observerRef.current = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting && !loading && visibleCount < devices.length) { + loadMore(); + } + }); + }, options); + + const loadMoreTrigger = document.getElementById('load-more-trigger'); + if (loadMoreTrigger) { + observerRef.current.observe(loadMoreTrigger); + } + + return () => { + if (observerRef.current) { + observerRef.current.disconnect(); + } + }; + }, [visibleCount, devices.length, loading, showAll]); + + const loadMore = useCallback(() => { + if (loading || visibleCount >= devices.length) return; + + setLoading(true); + // 模拟异步加载,实际可以直接同步更新 + setTimeout(() => { + setVisibleCount(prev => Math.min(prev + loadMoreCount, devices.length)); + setLoading(false); + }, 100); + }, [loading, visibleCount, devices.length, loadMoreCount]); + + const handleShowAll = useCallback(() => { + // 先显示所有设备 + setVisibleCount(devices.length); + // 展开所有设备 + const allExpanded = {}; + devices.forEach(device => { + allExpanded[device.deviceId] = true; + }); + setExpandedDevices(allExpanded); + setShowAll(true); + }, [devices]); + + const handleCollapseAll = useCallback(() => { + // 收起所有面板(折叠所有设备),但保持当前显示的设备数量 + const allCollapsed = {}; + devices.forEach(device => { + allCollapsed[device.deviceId] = false; + }); + setExpandedDevices(allCollapsed); + setShowAll(false); + }, [devices]); + + const toggleDeviceExpand = (deviceId) => { + setExpandedDevices(prev => ({ + ...prev, + [deviceId]: !prev[deviceId] + })); + }; + + const visibleDevices = devices.slice(0, visibleCount); + const hasMore = visibleCount < devices.length; + + if (devices.length === 0) { + return ( + + ); + } + + return ( +
+ {/* 控制栏 */} +
+ +
+ 设备列表 + +
+ + 显示 {visibleDevices.length} / {devices.length} + +
+ + + + +
+ + {/* 设备面板列表 */} + {visibleDevices.map((device) => { + const deviceId = device.deviceId; + const data = groupedPorts[deviceId] || { device, ports: [] }; + const isExpanded = expandedDevices[deviceId]; + const portCount = data.ports?.length || 0; + const occupiedCount = data.ports?.filter(p => p.status === 'occupied').length || 0; + + return ( +
+ {/* 设备标题栏 */} +
toggleDeviceExpand(deviceId)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '16px 20px', + background: isExpanded ? '#f1f5f9' : '#fff', + cursor: 'pointer', + borderBottom: isExpanded ? '1px solid #e2e8f0' : 'none', + transition: 'background 0.2s' + }} + onMouseEnter={(e) => { + e.currentTarget.style.background = '#f1f5f9'; + }} + onMouseLeave={(e) => { + if (!isExpanded) { + e.currentTarget.style.background = '#fff'; + } + }} + > +
+
+ {device.type?.toLowerCase()?.includes('server') ? '🖥️' : + device.type?.toLowerCase()?.includes('switch') ? '🔀' : + device.type?.toLowerCase()?.includes('router') ? '🌐' : '📦'} +
+
+
+ {device.name || '未知设备'} +
+
+ {device.deviceId} · {device.type || '未知类型'} +
+
+
+ + + + + + 已用 + + + + 端口 + + + + {/* 网卡管理按钮 - 只有服务器显示 */} + {device.type?.toLowerCase()?.includes('server') && ( + + )} + + {/* 添加端口按钮 */} + + +
+ + {/* 面板内容 - 可折叠 */} + {isExpanded && ( +
+ onManageNetworkCards && onManageNetworkCards(device)} + /> +
+ )} +
+ ); + })} + + {/* 加载更多触发器 */} + {hasMore && !showAll && ( +
+ {loading ? ( + + ) : ( + + 向下滚动加载更多 ({devices.length - visibleCount} 个设备) + + )} +
+ )} + + {/* 已显示全部提示 */} + {!hasMore && devices.length > initialVisibleCount && ( +
+ 已显示全部 {devices.length} 个设备 +
+ )} +
+ ); +}; + +export default VirtualDeviceList; diff --git a/frontend/src/pages/CableManagement.jsx b/frontend/src/pages/CableManagement.jsx index cda4377..906f3df 100644 --- a/frontend/src/pages/CableManagement.jsx +++ b/frontend/src/pages/CableManagement.jsx @@ -73,16 +73,17 @@ function CableManagement() { try { setLoading(true); const params = {}; - + if (filters.switchDeviceId) params.sourceDeviceId = filters.switchDeviceId; if (filters.status !== 'all') params.status = filters.status; if (filters.cableType !== 'all') params.cableType = filters.cableType; - + const response = await axios.get('/api/cables', { params }); - setCables(response.data.cables || []); - + const cablesData = response.data.cables || []; + setCables(cablesData); + const grouped = {}; - response.data.cables.forEach(cable => { + cablesData.forEach(cable => { const switchId = cable.sourceDeviceId; if (!grouped[switchId]) { grouped[switchId] = { @@ -93,22 +94,36 @@ function CableManagement() { grouped[switchId].cables.push(cable); }); setGroupedCables(grouped); + + // 自动为每个交换机加载端口数据 + const switchIds = Object.keys(grouped); + for (const switchId of switchIds) { + if (!devicePorts[switchId]) { + try { + const portsResponse = await axios.get(`/api/device-ports/device/${switchId}`); + setDevicePorts(prev => ({ ...prev, [switchId]: portsResponse.data || [] })); + } catch (error) { + console.error(`获取交换机 ${switchId} 端口失败:`, error); + } + } + } } catch (error) { message.error('获取接线列表失败'); console.error('获取接线列表失败:', error); } finally { setLoading(false); } - }, [filters]); + }, [filters, devicePorts]); const fetchDevices = useCallback(async () => { try { - const response = await axios.get('/api/devices', { params: { pageSize: 1000 } }); + const response = await axios.get('/api/devices', { params: { pageSize: 100 } }); const allDevices = response.data.devices || []; const switches = allDevices.filter(device => device.type === 'switch'); setDevices(allDevices); setSwitchDevices(switches); } catch (error) { + message.error('获取设备列表失败'); console.error('获取设备列表失败:', error); } }, []); @@ -189,27 +204,87 @@ function CableManagement() { } }; + const [conflictModalVisible, setConflictModalVisible] = useState(false); + const [conflictInfo, setConflictInfo] = useState(null); + const [pendingSubmitValues, setPendingSubmitValues] = useState(null); + const handleSubmit = async () => { try { const values = await form.validateFields(); - + + // 如果是编辑模式,直接提交 if (editingCable) { await axios.put(`/api/cables/${editingCable.cableId}`, values); message.success('更新成功'); - } else { + setModalVisible(false); + form.resetFields(); + fetchCables(); + return; + } + + // 创建模式:先检查冲突 + try { + const checkResponse = await axios.post('/api/cables/check-conflict', { + sourceDeviceId: values.sourceDeviceId, + sourcePort: values.sourcePort, + targetDeviceId: values.targetDeviceId, + targetPort: values.targetPort + }); + + if (checkResponse.data.hasConflict) { + setConflictInfo(checkResponse.data.conflicts); + setPendingSubmitValues(values); + setConflictModalVisible(true); + return; + } + + // 无冲突,直接创建 await axios.post('/api/cables', values); message.success('创建成功'); + setModalVisible(false); + form.resetFields(); + fetchCables(); + } catch (error) { + if (error.response?.status === 409) { + // 冲突错误 + setConflictInfo([{ + type: 'unknown', + existingCable: error.response.data.existingCable + }]); + setPendingSubmitValues(values); + setConflictModalVisible(true); + } else { + throw error; + } } - - setModalVisible(false); - form.resetFields(); - fetchCables(); } catch (error) { message.error(editingCable ? '更新失败' : '创建失败'); console.error('提交失败:', error); } }; + const handleForceSubmit = async () => { + try { + if (!pendingSubmitValues) return; + + await axios.post('/api/cables', { + ...pendingSubmitValues, + force: true + }); + + message.success('接线已强制接管并创建成功'); + setConflictModalVisible(false); + setModalVisible(false); + form.resetFields(); + setPendingSubmitValues(null); + setConflictInfo(null); + fetchCables(); + } catch (error) { + message.error('强制接管失败'); + console.error('强制接管失败:', error); + } + }; + const handleImport = () => { setImportModalVisible(true); setImportPreview([]); @@ -562,9 +637,12 @@ function CableManagement() { onChange={(value) => setFilters(prev => ({ ...prev, switchDeviceId: value }))} allowClear showSearch - filterOption={(input, option) => - option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0 - } + filterOption={(input, option) => { + const device = switchDevices.find(d => d.deviceId === option.value); + if (!device) return false; + const searchText = `${device.name} ${device.deviceId}`.toLowerCase(); + return searchText.indexOf(input.toLowerCase()) >= 0; + }} > {switchDevices.map(device => ( ))} - + - option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0 - } + filterOption={(input, option) => { + const device = switchDevices.find(d => d.deviceId === option.value); + if (!device) return false; + const searchText = `${device.name} ${device.deviceId}`.toLowerCase(); + return searchText.indexOf(input.toLowerCase()) >= 0; + }} onChange={(value) => { fetchDevicePorts(value); form.setFieldsValue({ sourcePort: undefined }); @@ -780,18 +861,22 @@ function CableManagement() { ))} - + - - + - - + - - + + + {/* 冲突提示弹窗 */} + { + setConflictModalVisible(false); + setConflictInfo(null); + setPendingSubmitValues(null); + }} + footer={[ + , + + ]} + width={600} + > + {conflictInfo && ( +
+
+ ⚠️ + 检测到端口冲突,以下端口已被占用: +
+ {conflictInfo.map((conflict, index) => ( + +
+ + {conflict.type === 'source' ? '源端口' : conflict.type === 'target' ? '目标端口' : '端口'} + + {conflict.port} +
+ {conflict.existingCable && ( +
+
当前连接:
+
+
+ 源设备:{conflict.existingCable.sourceDevice?.name || conflict.existingCable.sourceDeviceId} + ({conflict.existingCable.sourcePort}) +
+
+ 目标设备:{conflict.existingCable.targetDevice?.name || conflict.existingCable.targetDeviceId} + ({conflict.existingCable.targetPort}) +
+
+ 线缆类型:{getCableTypeTag(conflict.existingCable.cableType)} +
+
+
+ )} +
+ ))} +
+ 💡 + + 点击"强制接管"将断开原有连接并创建新接线。此操作不可恢复! + +
+
+ )} +
); } diff --git a/frontend/src/pages/PortManagement.jsx b/frontend/src/pages/PortManagement.jsx index f70d5d3..4c62d13 100644 --- a/frontend/src/pages/PortManagement.jsx +++ b/frontend/src/pages/PortManagement.jsx @@ -1,9 +1,14 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { Table, Button, Modal, Form, Input, Select, message, Card, Space, Popconfirm, Tag, Tooltip, InputNumber, Collapse, Empty, Spin, Upload, Progress, Checkbox } from 'antd'; -import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined, ExportOutlined, ImportOutlined, DownloadOutlined, UploadOutlined as UploadIcon } from '@ant-design/icons'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { Table, Button, Modal, Form, Input, Select, message, Card, Space, Popconfirm, Tag, Tooltip, InputNumber, Collapse, Empty, Spin, Upload, Progress, Checkbox, Tabs, Badge, List, Typography } from 'antd'; +import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined, ExportOutlined, ImportOutlined, DownloadOutlined, UploadOutlined as UploadIcon, AppstoreOutlined, UnorderedListOutlined, FilterOutlined, EyeOutlined, CompressOutlined, CloudServerOutlined } from '@ant-design/icons'; import axios from 'axios'; import * as XLSX from 'xlsx'; import Papa from 'papaparse'; +import PortPanel from '../components/PortPanel'; +import VirtualDeviceList from '../components/VirtualDeviceList'; +import NetworkCardPanel from '../components/NetworkCardPanel'; +import NetworkCardCreateModal from '../components/NetworkCardCreateModal'; +import PortCreateModal from '../components/PortCreateModal'; const { Option } = Select; const { Panel } = Collapse; @@ -48,6 +53,7 @@ const designTokens = { function PortManagement() { const [ports, setPorts] = useState([]); const [devices, setDevices] = useState([]); + const [cables, setCables] = useState([]); const [groupedPorts, setGroupedPorts] = useState({}); const [loading, setLoading] = useState(false); const [filters, setFilters] = useState({ @@ -59,7 +65,7 @@ function PortManagement() { const [modalVisible, setModalVisible] = useState(false); const [editingPort, setEditingPort] = useState(null); const [form] = Form.useForm(); - + const [importModalVisible, setImportModalVisible] = useState(false); const [importFileList, setImportFileList] = useState([]); const [importPreview, setImportPreview] = useState([]); @@ -68,10 +74,30 @@ function PortManagement() { const [skipExisting, setSkipExisting] = useState(false); const [updateExisting, setUpdateExisting] = useState(false); + // 视图模式:list 或 panel + const [viewMode, setViewMode] = useState('list'); + + // 面板视图优化状态 + const [panelFilters, setPanelFilters] = useState({ + deviceType: 'all', + searchText: '', + showOnlyOccupied: false + }); + const [visibleDeviceCount, setVisibleDeviceCount] = useState(10); + const [expandedDevices, setExpandedDevices] = useState({}); + + // 网卡管理相关状态 + const [networkCardModalVisible, setNetworkCardModalVisible] = useState(false); + const [portCreateModalVisible, setPortCreateModalVisible] = useState(false); + const [selectedDeviceForNic, setSelectedDeviceForNic] = useState(null); + const [refreshTrigger, setRefreshTrigger] = useState(0); + const fetchPorts = useCallback(async () => { try { setLoading(true); - const params = {}; + const params = { + pageSize: 1000 // 获取所有端口,不分页 + }; if (filters.deviceId) params.deviceId = filters.deviceId; if (filters.status !== 'all') params.status = filters.status; @@ -90,17 +116,28 @@ function PortManagement() { const fetchDevices = useCallback(async () => { try { - const response = await axios.get('/api/devices', { params: { pageSize: 1000 } }); + const response = await axios.get('/api/devices', { params: { pageSize: 100 } }); setDevices(response.data.devices || response.data || []); } catch (error) { + message.error('获取设备列表失败'); console.error('获取设备列表失败:', error); } }, []); + const fetchCables = useCallback(async () => { + try { + const response = await axios.get('/api/cables'); + setCables(response.data.cables || response.data || []); + } catch (error) { + console.error('获取接线列表失败:', error); + } + }, []); + useEffect(() => { fetchPorts(); fetchDevices(); - }, [fetchPorts, fetchDevices]); + fetchCables(); + }, [fetchPorts, fetchDevices, fetchCables]); useEffect(() => { const grouped = {}; @@ -114,6 +151,25 @@ function PortManagement() { } grouped[deviceId].ports.push(port); }); + + // 对每个设备的端口按名称升序排序 + Object.keys(grouped).forEach(deviceId => { + grouped[deviceId].ports.sort((a, b) => { + const extractNumbers = (str) => { + const matches = str.match(/\d+/g); + return matches ? matches.map(Number) : []; + }; + const numsA = extractNumbers(a.portName); + const numsB = extractNumbers(b.portName); + for (let i = 0; i < Math.min(numsA.length, numsB.length); i++) { + if (numsA[i] !== numsB[i]) { + return numsA[i] - numsB[i]; + } + } + return a.portName.localeCompare(b.portName); + }); + }); + setGroupedPorts(grouped); }, [ports, devices]); @@ -136,6 +192,41 @@ function PortManagement() { setModalVisible(true); }; + const handleAddPortForDevice = (device) => { + setEditingPort(null); + form.resetFields(); + // 自动选中当前设备 + form.setFieldsValue({ + deviceId: device.deviceId + }); + setModalVisible(true); + }; + + // 打开网卡管理模态框 + const handleManageNetworkCards = (device) => { + setSelectedDeviceForNic(device); + setNetworkCardModalVisible(true); + }; + + // 打开添加网卡模态框 + const handleAddNetworkCard = (device) => { + setSelectedDeviceForNic(device); + setPortCreateModalVisible(true); + }; + + // 网卡/端口创建成功回调 + const handleNicSuccess = () => { + message.success('操作成功'); + setRefreshTrigger(prev => prev + 1); + fetchPorts(); + }; + + const handlePortSuccess = () => { + message.success('端口添加成功'); + setRefreshTrigger(prev => prev + 1); + fetchPorts(); + }; + const handleEdit = (port) => { setEditingPort(port); form.setFieldsValue({ @@ -162,6 +253,21 @@ function PortManagement() { } }; + // 解析端口名称范围,例如 "1/0/1-1/0/48" -> ["1/0/1", "1/0/2", ..., "1/0/48"] + const parsePortRange = (portName) => { + const rangeMatch = portName.match(/^(.*?)\/(\d+)-\1\/(\d+)$/); + if (rangeMatch) { + const prefix = rangeMatch[1]; + const start = parseInt(rangeMatch[2]); + const end = parseInt(rangeMatch[3]); + + if (start <= end && end - start < 100) { // 限制最多100个端口 + return Array.from({ length: end - start + 1 }, (_, i) => `${prefix}/${start + i}`); + } + } + return [portName]; // 如果不是范围格式,返回原名称 + }; + const handleSubmit = async () => { try { const values = await form.validateFields(); @@ -170,8 +276,35 @@ function PortManagement() { await axios.put(`/api/device-ports/${editingPort.portId}`, values); message.success('更新成功'); } else { - await axios.post('/api/device-ports', values); - message.success('创建成功'); + // 解析端口名称范围 + const portNames = parsePortRange(values.portName); + + if (portNames.length > 1) { + // 批量创建端口 + const portsData = portNames.map((name, index) => ({ + portId: `PORT-${Date.now()}-${index}`, + deviceId: values.deviceId, + portName: name, + portType: values.portType, + portSpeed: values.portSpeed, + status: values.status, + vlanId: values.vlanId, + description: values.description + })); + + const response = await axios.post('/api/device-ports/batch', { ports: portsData }); + const { success, failed } = response.data; + + if (failed > 0) { + message.warning(`批量创建完成!成功 ${success} 个,失败 ${failed} 个`); + } else { + message.success(`成功创建 ${success} 个端口`); + } + } else { + // 单个创建 + await axios.post('/api/device-ports', values); + message.success('创建成功'); + } } setModalVisible(false); @@ -480,9 +613,12 @@ function PortManagement() { onChange={(value) => setFilters(prev => ({ ...prev, deviceId: value }))} allowClear showSearch - filterOption={(input, option) => - option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0 - } + filterOption={(input, option) => { + const device = devices.find(d => d.deviceId === option.value); + if (!device) return false; + const searchText = `${device.name} ${device.deviceId}`.toLowerCase(); + return searchText.indexOf(input.toLowerCase()) >= 0; + }} > {devices.map(device => ( ))} - + - option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0 - } + filterOption={(input, option) => { + const device = devices.find(d => d.deviceId === option.value); + if (!device) return false; + const searchText = `${device.name} ${device.deviceId}`.toLowerCase(); + return searchText.indexOf(input.toLowerCase()) >= 0; + }} > {devices.map(device => (
); }