feat(拓扑图): 新增网络拓扑图功能模块
This commit is contained in:
@@ -105,6 +105,10 @@ const routesConfig = [
|
|||||||
file: 'dangerousOperations.js',
|
file: 'dangerousOperations.js',
|
||||||
path: '/api/dangerous-operations',
|
path: '/api/dangerous-operations',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
file: 'topology.js',
|
||||||
|
path: '/api/topology',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
module.exports = routesConfig;
|
module.exports = routesConfig;
|
||||||
|
|||||||
@@ -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;
|
||||||
Generated
+33
@@ -11,8 +11,10 @@
|
|||||||
"@ant-design/icons": "^6.1.0",
|
"@ant-design/icons": "^6.1.0",
|
||||||
"@react-three/drei": "^9.122.0",
|
"@react-three/drei": "^9.122.0",
|
||||||
"@react-three/fiber": "^8.18.0",
|
"@react-three/fiber": "^8.18.0",
|
||||||
|
"@types/dagre": "^0.7.54",
|
||||||
"antd": "^5.8.6",
|
"antd": "^5.8.6",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
|
"dagre": "^0.8.5",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"framer-motion": "^12.34.0",
|
"framer-motion": "^12.34.0",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
@@ -2823,6 +2825,12 @@
|
|||||||
"@types/d3-selection": "*"
|
"@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": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
@@ -4172,6 +4180,16 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/data-urls": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz",
|
||||||
@@ -5328,6 +5346,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/has-bigints": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||||
@@ -6249,6 +6276,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
|
|||||||
@@ -16,8 +16,10 @@
|
|||||||
"@ant-design/icons": "^6.1.0",
|
"@ant-design/icons": "^6.1.0",
|
||||||
"@react-three/drei": "^9.122.0",
|
"@react-three/drei": "^9.122.0",
|
||||||
"@react-three/fiber": "^8.18.0",
|
"@react-three/fiber": "^8.18.0",
|
||||||
|
"@types/dagre": "^0.7.54",
|
||||||
"antd": "^5.8.6",
|
"antd": "^5.8.6",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
|
"dagre": "^0.8.5",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"framer-motion": "^12.34.0",
|
"framer-motion": "^12.34.0",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -189,7 +189,6 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
setCurrentStep(0);
|
setCurrentStep(0);
|
||||||
setSourceDevice(null);
|
|
||||||
setTargetDevice(null);
|
setTargetDevice(null);
|
||||||
setSourcePort(null);
|
setSourcePort(null);
|
||||||
setTargetPort(null);
|
setTargetPort(null);
|
||||||
@@ -197,22 +196,30 @@ const CableWizardModal = ({ visible, onClose, onSuccess, initialSourceDevice, ed
|
|||||||
setSelectedCableLength(3);
|
setSelectedCableLength(3);
|
||||||
setCableLabel('');
|
setCableLabel('');
|
||||||
setCableDescription('');
|
setCableDescription('');
|
||||||
setSourcePorts([]);
|
|
||||||
setTargetPorts([]);
|
setTargetPorts([]);
|
||||||
setConflicts([]);
|
setConflicts([]);
|
||||||
setCompatibilityWarning(null);
|
setCompatibilityWarning(null);
|
||||||
setDevices([]);
|
|
||||||
setCablesData([]);
|
setCablesData([]);
|
||||||
|
|
||||||
fetchDevices('', 'switch').then(deviceList => {
|
if (initialSourceDevice?.deviceId) {
|
||||||
if (initialSourceDevice?.deviceId) {
|
const deviceWithId = {
|
||||||
const device = deviceList.find(d => d.deviceId === initialSourceDevice.deviceId);
|
deviceId: initialSourceDevice.deviceId,
|
||||||
if (device) {
|
name: initialSourceDevice.name || initialSourceDevice.deviceId,
|
||||||
setSourceDevice(device);
|
type: initialSourceDevice.type || 'switch',
|
||||||
fetchDevicePorts(device.deviceId, 'source');
|
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();
|
fetchCables();
|
||||||
}
|
}
|
||||||
}, [visible, initialSourceDevice, fetchDevices, fetchDevicePorts, fetchCables]);
|
}, [visible, initialSourceDevice, fetchDevices, fetchDevicePorts, fetchCables]);
|
||||||
@@ -677,152 +684,165 @@ const Step1SourceDevice = ({
|
|||||||
onPortSelect,
|
onPortSelect,
|
||||||
onDevicesChange,
|
onDevicesChange,
|
||||||
}) => {
|
}) => {
|
||||||
|
const isSourcePreSelected = !!sourceDevice;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '20px 0' }}>
|
<div style={{ padding: '20px 0' }}>
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
<Space>
|
<Space>
|
||||||
<CloudServerOutlined style={{ color: '#1890ff' }} />
|
<CloudServerOutlined style={{ color: '#1890ff' }} />
|
||||||
<span>步骤 1: 选择源设备</span>
|
<span>步骤 1: 选择源设备端口</span>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
size="small"
|
size="small"
|
||||||
style={{ marginBottom: '20px' }}
|
style={{ marginBottom: '20px' }}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: '16px' }}>
|
{!isSourcePreSelected && (
|
||||||
<Typography.Text type="secondary" style={{ fontSize: '13px' }}>
|
<>
|
||||||
选择接线的起点设备
|
<div style={{ marginBottom: '16px' }}>
|
||||||
</Typography.Text>
|
<Typography.Text type="secondary" style={{ fontSize: '13px' }}>
|
||||||
</div>
|
选择接线的起点设备
|
||||||
|
</Typography.Text>
|
||||||
<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>
|
</div>
|
||||||
) : (
|
|
||||||
devices.map(device => (
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<motion.div
|
<FilterableDeviceSelect
|
||||||
key={device.deviceId}
|
filterType="switch"
|
||||||
whileHover={{ scale: 1.02 }}
|
onDeviceListChange={onDevicesChange}
|
||||||
whileTap={{ scale: 0.98 }}
|
/>
|
||||||
>
|
</div>
|
||||||
<Card
|
|
||||||
onClick={() => onDeviceSelect(device)}
|
<div
|
||||||
hoverable
|
style={{
|
||||||
style={{
|
display: 'grid',
|
||||||
border: sourceDevice?.deviceId === device.deviceId
|
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||||
? `2px solid ${designTokens.colors.primary}`
|
gap: '12px',
|
||||||
: '1px solid #d9d9d9',
|
maxHeight: 300,
|
||||||
background: sourceDevice?.deviceId === device.deviceId
|
overflowY: 'auto',
|
||||||
? 'rgba(24,144,255,0.08)'
|
padding: '8px',
|
||||||
: '#fff',
|
background: designTokens.colors.background,
|
||||||
}}
|
borderRadius: '8px',
|
||||||
>
|
}}
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
|
>
|
||||||
<div
|
{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={{
|
style={{
|
||||||
width: '48px',
|
border: sourceDevice?.deviceId === device.deviceId
|
||||||
height: '48px',
|
? `2px solid ${designTokens.colors.primary}`
|
||||||
borderRadius: '10px',
|
: '1px solid #d9d9d9',
|
||||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
background: sourceDevice?.deviceId === device.deviceId
|
||||||
display: 'flex',
|
? 'rgba(24,144,255,0.08)'
|
||||||
alignItems: 'center',
|
: '#fff',
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: '24px',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{device.type === 'server' ? '🖥️' : device.type === 'switch' ? '📡' : device.type === 'router' ? '🔀' : device.type === 'storage' ? '💾' : '📦'}
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
|
||||||
</div>
|
<div
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
style={{
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
|
width: '48px',
|
||||||
<div style={{ fontWeight: 600, color: '#262626', fontSize: '15px' }}>
|
height: '48px',
|
||||||
{device.name || device.deviceId}
|
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>
|
</div>
|
||||||
{device.status && getStatusTag(device.status)}
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
</div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
|
||||||
<div style={{ fontSize: '12px', color: '#8c8c8c', marginBottom: '4px' }}>
|
<div style={{ fontWeight: 600, color: '#262626', fontSize: '15px' }}>
|
||||||
<span style={{ marginRight: '12px' }}>
|
{device.name || device.deviceId}
|
||||||
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>ID:</span>
|
</div>
|
||||||
{device.deviceId}
|
{device.status && getStatusTag(device.status)}
|
||||||
</span>
|
</div>
|
||||||
<span>
|
<div style={{ fontSize: '12px', color: '#8c8c8c', marginBottom: '4px' }}>
|
||||||
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>类型:</span>
|
<span style={{ marginRight: '12px' }}>
|
||||||
{device.type === 'server' ? '服务器' : device.type === 'switch' ? '交换机' : device.type === 'router' ? '路由器' : device.type === 'storage' ? '存储设备' : device.type}
|
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>ID:</span>
|
||||||
</span>
|
{device.deviceId}
|
||||||
</div>
|
</span>
|
||||||
<div style={{ fontSize: '11px', color: '#8c8c8c', lineHeight: '1.5' }}>
|
<span>
|
||||||
{device.Rack?.Room?.name && (
|
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>类型:</span>
|
||||||
<span style={{ marginRight: '12px' }}>
|
{device.type === 'server' ? '服务器' : device.type === 'switch' ? '交换机' : device.type === 'router' ? '路由器' : device.type === 'storage' ? '存储设备' : device.type}
|
||||||
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>机房:</span>
|
</span>
|
||||||
{device.Rack.Room.name}
|
</div>
|
||||||
</span>
|
<div style={{ fontSize: '11px', color: '#8c8c8c', lineHeight: '1.5' }}>
|
||||||
)}
|
{device.Rack?.Room?.name && (
|
||||||
{device.Rack?.name && (
|
<span style={{ marginRight: '12px' }}>
|
||||||
<span style={{ marginRight: '12px' }}>
|
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>机房:</span>
|
||||||
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>机柜:</span>
|
{device.Rack.Room.name}
|
||||||
{device.Rack.name}
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
{device.Rack?.name && (
|
||||||
{device.position && (
|
<span style={{ marginRight: '12px' }}>
|
||||||
<span style={{ marginRight: '12px' }}>
|
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>机柜:</span>
|
||||||
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>U位:</span>
|
{device.Rack.name}
|
||||||
U{device.position}
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
{device.position && (
|
||||||
{device.ipAddress && (
|
<span style={{ marginRight: '12px' }}>
|
||||||
<span>
|
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>U位:</span>
|
||||||
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>IP:</span>
|
U{device.position}
|
||||||
{device.ipAddress}
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
{device.ipAddress && (
|
||||||
</div>
|
<span>
|
||||||
{device.model && (
|
<span style={{ color: '#bfbfbf', marginRight: '4px' }}>IP:</span>
|
||||||
<div style={{ fontSize: '11px', color: '#bfbfbf', marginTop: '4px' }}>
|
{device.ipAddress}
|
||||||
<span style={{ color: '#8c8c8c', marginRight: '4px' }}>型号:</span>
|
</span>
|
||||||
{device.model}
|
)}
|
||||||
|
</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 && (
|
||||||
</div>
|
<CheckCircleOutlined style={{ color: designTokens.colors.primary, fontSize: '22px', flexShrink: 0 }} />
|
||||||
{sourceDevice?.deviceId === device.deviceId && (
|
)}
|
||||||
<CheckCircleOutlined style={{ color: designTokens.colors.primary, fontSize: '22px', flexShrink: 0 }} />
|
</div>
|
||||||
)}
|
</Card>
|
||||||
</div>
|
</motion.div>
|
||||||
</Card>
|
))
|
||||||
</motion.div>
|
)}
|
||||||
))
|
</div>
|
||||||
)}
|
</>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{sourceDevice && (
|
{sourceDevice && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, height: 0 }}
|
initial={{ opacity: 0, height: 0 }}
|
||||||
animate={{ opacity: 1, height: 'auto' }}
|
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' }}>
|
<Typography.Text style={{ fontWeight: 600, color: '#262626' }}>
|
||||||
源设备已选择: {sourceDevice.name}
|
源设备: {sourceDevice.name}
|
||||||
</Typography.Text>
|
</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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -833,14 +853,6 @@ const Step1SourceDevice = ({
|
|||||||
border: '1px solid #d9d9d9',
|
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 ? (
|
{sourcePorts.length === 0 ? (
|
||||||
<Empty description="该设备暂无端口数据" />
|
<Empty description="该设备暂无端口数据" />
|
||||||
) : (
|
) : (
|
||||||
@@ -848,9 +860,6 @@ const Step1SourceDevice = ({
|
|||||||
<div style={{ marginBottom: '8px', fontSize: '12px', color: '#8c8c8c' }}>
|
<div style={{ marginBottom: '8px', fontSize: '12px', color: '#8c8c8c' }}>
|
||||||
端口总数: {sourcePorts.length} | 空闲端口: {sourcePorts.filter(p => p.status === 'free').length}
|
端口总数: {sourcePorts.length} | 空闲端口: {sourcePorts.filter(p => p.status === 'free').length}
|
||||||
</div>
|
</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 && (
|
{sourcePort && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ function parsePortRange(portName) {
|
|||||||
return null;
|
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 portCount = endNum - startNum + 1;
|
||||||
|
|
||||||
const ports = [];
|
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';
|
||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
FilterOutlined,
|
FilterOutlined,
|
||||||
ClearOutlined,
|
ClearOutlined,
|
||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
|
ShareAltOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
@@ -55,6 +56,7 @@ import { designTokens } from '../config/theme';
|
|||||||
import { debounce } from '../utils/common';
|
import { debounce } from '../utils/common';
|
||||||
import CloseButton from '../components/CloseButton';
|
import CloseButton from '../components/CloseButton';
|
||||||
import CableWizardModal from '../components/CableWizardModal';
|
import CableWizardModal from '../components/CableWizardModal';
|
||||||
|
import { TopologyModal } from '../components/topology';
|
||||||
|
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
const { Panel } = Collapse;
|
const { Panel } = Collapse;
|
||||||
@@ -145,6 +147,8 @@ function CableManagement() {
|
|||||||
|
|
||||||
const [expandedKeys, setExpandedKeys] = useState([]);
|
const [expandedKeys, setExpandedKeys] = useState([]);
|
||||||
|
|
||||||
|
const [topologyModalVisible, setTopologyModalVisible] = useState(false);
|
||||||
|
|
||||||
const fetchCables = useCallback(async () => {
|
const fetchCables = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -1069,6 +1073,14 @@ function CableManagement() {
|
|||||||
>
|
>
|
||||||
导出
|
导出
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<ShareAltOutlined />}
|
||||||
|
onClick={() => setTopologyModalVisible(true)}
|
||||||
|
size="large"
|
||||||
|
style={{ borderRadius: designTokens.borderRadius.sm }}
|
||||||
|
>
|
||||||
|
生成拓扑图
|
||||||
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<Space>
|
<Space>
|
||||||
<Tooltip title="刷新数据">
|
<Tooltip title="刷新数据">
|
||||||
@@ -1866,6 +1878,11 @@ function CableManagement() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<TopologyModal
|
||||||
|
visible={topologyModalVisible}
|
||||||
|
onClose={() => setTopologyModalVisible(false)}
|
||||||
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user