From b20a3d78261f8605c0a06dde12e0dbc8d5fa8583 Mon Sep 17 00:00:00 2001 From: sepehr Date: Thu, 16 Oct 2025 17:33:27 +0300 Subject: [PATCH] feat: add network topology maps for pro users - Add comprehensive network visualization module - Pro feature with advanced topology mapping - Interactive network device mapping - Integration with navigation and routing --- src/app/app-routing.module.ts | 5 + src/app/containers/default-layout/_nav.ts | 6 + src/app/views/maps/code-typescript.txt | 377 +++++++++++++++++ src/app/views/maps/maps-routing.module.ts | 21 + src/app/views/maps/maps.component.html | 82 ++++ src/app/views/maps/maps.component.scss | 256 ++++++++++++ src/app/views/maps/maps.component.ts | 479 ++++++++++++++++++++++ src/app/views/maps/maps.module.ts | 60 +++ 8 files changed, 1286 insertions(+) create mode 100644 src/app/views/maps/code-typescript.txt create mode 100644 src/app/views/maps/maps-routing.module.ts create mode 100644 src/app/views/maps/maps.component.html create mode 100644 src/app/views/maps/maps.component.scss create mode 100644 src/app/views/maps/maps.component.ts create mode 100644 src/app/views/maps/maps.module.ts diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 3c4186a..2b30133 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -49,6 +49,11 @@ const routes: Routes = [ loadChildren: () => import('./views/devices_group/devgroup.module').then((m) => m.DevicesGroupModule) }, + { + path: 'maps', + loadChildren: () => + import('./views/maps/maps.module').then((m) => m.MapsModule) + }, { path: 'authlog', loadChildren: () => diff --git a/src/app/containers/default-layout/_nav.ts b/src/app/containers/default-layout/_nav.ts index d313617..00c76ce 100644 --- a/src/app/containers/default-layout/_nav.ts +++ b/src/app/containers/default-layout/_nav.ts @@ -28,6 +28,12 @@ export const navItems: INavData[] = [ // linkProps: { fragment: 'someAnchor' }, icon: 'fa-solid fa-layer-group' }, + { + name: 'Network Maps', + url: '/maps', + icon:'fa-solid fa-map', + attributes: { 'pro':true } + }, // { // name: 'Tools', // url: '/login', diff --git a/src/app/views/maps/code-typescript.txt b/src/app/views/maps/code-typescript.txt new file mode 100644 index 0000000..64f7093 --- /dev/null +++ b/src/app/views/maps/code-typescript.txt @@ -0,0 +1,377 @@ +import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from "@angular/core"; +import { dataProvider } from "../../providers/mikrowizard/data"; +import { loginChecker } from "../../providers/login_checker"; +import { Router } from "@angular/router"; +import { formatInTimeZone } from "date-fns-tz"; +import { Network } from 'vis-network/peer'; +import { DataSet } from 'vis-data'; + +@Component({ + templateUrl: "maps.component.html", + styleUrls: ["maps.component.scss"], +}) +export class MapsComponent implements OnInit { + public uid: number; + public uname: string; + public ispro: boolean = false; + public tz: string; + public savedPositions: any = {}; + public savedPositionsKey = "network-layout"; + public selectedDevice: any = null; + constructor( + private data_provider: dataProvider, + private router: Router, + private login_checker: loginChecker + ) { + var _self = this; + if (!this.login_checker.isLoggedIn()) { + setTimeout(function () { + _self.router.navigate(["login"]); + }, 100); + } + this.data_provider.getSessionInfo().then((res) => { + _self.uid = res.uid; + _self.uname = res.name; + _self.ispro = res.ISPRO; + if (!_self.ispro) + setTimeout(function () { + _self.router.navigate(["dashboard"]); + }, 100); + _self.tz = res.tz; + }); + } + + @ViewChild('network', { static: true }) networkContainer: ElementRef | undefined; + + mikrotikData: any[] = []; + + + + + ngOnInit(): void { + this.loadFontAwesome(); + this.savedPositions = JSON.parse(localStorage.getItem(this.savedPositionsKey) || "{}"); + this.loadNetworkData(); + } + + loadNetworkData(): void { + this.data_provider.getNetworkMap().then((res) => { + this.mikrotikData = res; + console.dir(res); + setTimeout(() => { + this.createNetworkMap(); + }, 100); + }); + } + + loadFontAwesome() { + if (!document.querySelector('link[href*="font-awesome"]')) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css'; + document.head.appendChild(link); + } + } + +createNetworkMap() { + const container = this.networkContainer?.nativeElement; + if (!container) return; + + let nodes = new DataSet([]); + let edges = new DataSet([]); + let deviceMap: { [key: string]: string } = {}; // uniqueId to nodeId mapping + let allDevices: { [key: string]: any } = {}; // uniqueId to device info mapping + let macToDevice: { [key: string]: string } = {}; // MAC -> uniqueId mapping + const hasSavedPositions = Object.keys(this.savedPositions).length > 0; + let nodeIdCounter = 1; + const getUniqueId = (obj: any): string => { + if (obj.device_id) return `dev_${obj.device_id}`; + if (obj.mac) return `mac_${obj.mac}`; + if (obj.software_id) return `sw_${obj.software_id}`; + if (obj.hostname) return `host_${obj.hostname}`; + return `unknown_${obj.address || Math.random().toString(36).slice(2)}`; + }; + // Collect all devices + this.mikrotikData.forEach((device) => { + const deviceId = device.device_id || `${device.name}_${Date.now()}`; + + if (!allDevices[deviceId]) { + allDevices[deviceId] = { + name: device.name, + type: 'Router', + brand: 'MikroTik' + }; + } + + Object.entries(device.interfaces).forEach(([_, iface]: [string, any]) => { + if (iface.mac) { + macToDevice[iface.mac] = deviceId; + } + + iface.neighbors.forEach((neighbor: any) => { + const neighborId = + neighbor.device_id || + neighbor.software_id || + `${neighbor.hostname}_${neighbor.mac}_${neighbor.address || 'unknown'}`; + + if (!allDevices[neighborId]) { + allDevices[neighborId] = { + name: neighbor.hostname || neighbor.mac || 'Unknown', + type: neighbor.type || 'Router', + brand: neighbor.brand || 'MikroTik' + }; + } + + if (neighbor.mac) { + macToDevice[neighbor.mac] = neighborId; + } + }); + }); + }); + + // Create nodes + Object.entries(allDevices).forEach(([uniqueId, device]: [string, any]) => { + const nodeId = `node_${nodeIdCounter++}`; + deviceMap[uniqueId] = nodeId; + + nodes.add({ + id: nodeId, + label: device.name, + shape: 'image', + image: this.getDeviceIcon(device.type || 'Unknown', device.brand || 'Unknown'), + size: 15, + font: { size: 11, color: '#333', face: 'Arial, sans-serif' }, + ...(hasSavedPositions && this.savedPositions[nodeId] + ? { x: this.savedPositions[nodeId].x, y: this.savedPositions[nodeId].y } + : {}) + } as any); + }); + + // Create edges + this.mikrotikData.forEach((device) => { + Object.entries(device.interfaces).forEach(([ifaceName, iface]: [string, any]) => { + const sourceDeviceId = macToDevice[iface.mac]; + iface.neighbors.forEach((neighbor: any) => { + const targetDeviceId = macToDevice[neighbor.mac]; + + if (deviceMap[sourceDeviceId] && deviceMap[targetDeviceId]) { + const edgeId = `${sourceDeviceId}_${targetDeviceId}`; + const reverseId = `${targetDeviceId}_${sourceDeviceId}`; + + if (!edges.get().find(e => e.id === edgeId || e.id === reverseId)) { + edges.add({ + id: edgeId, + from: deviceMap[sourceDeviceId], + to: deviceMap[targetDeviceId], + label: ifaceName, + color: { color: '#34495e', highlight: '#3498db' }, + width: 3, + smooth: { type: 'continuous', roundness: 0.1 }, + font: { + size: 12, + color: '#2c3e50', + face: 'Arial, sans-serif', + strokeWidth: 1, + strokeColor: '#ffffff', + align: 'horizontal' + } + } as any); + } + } + }); + }); + }); + + const data = { nodes, edges }; + const options = { physics: { enabled: true, stabilization: { iterations: 100 }, barnesHut: { gravitationalConstant: -8000, centralGravity: 0.3, springLength: 200, springConstant: 0.04, damping: 0.09 } }, interaction: { hover: true, dragNodes: true, dragView: true, zoomView: true, hoverConnectedEdges: false, selectConnectedEdges: false, navigationButtons: false, keyboard: false }, nodes: { borderWidth: 3, shadow: true }, edges: { shadow: true, smooth: true, length: 150 }, manipulation: { enabled: false } }; + const network = new Network(container, data, options); + + // Keep your existing events (dragEnd, click, stabilization, etc.) + // No changes needed below + network.on('dragEnd', () => { + const positions = network.getPositions(); + this.savedPositions = positions; + localStorage.setItem(this.savedPositionsKey, JSON.stringify(positions)); + }); + + network.on('click', (event: any) => { + if (event.nodes[0]) { + const clickedNode = nodes.get(event.nodes[0]); + const canvasPosition = network.canvasToDOM(event.pointer.canvas); + const containerRect = container.getBoundingClientRect(); + const mainContainer = document.querySelector('.main-container') as HTMLElement; + const mainRect = mainContainer?.getBoundingClientRect() || containerRect; + + let adjustedX = canvasPosition.x + containerRect.left - mainRect.left + 20; + let adjustedY = canvasPosition.y + containerRect.top - mainRect.top - 50; + + const popupWidth = 280; + const popupHeight = 200; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + if (adjustedX + popupWidth > viewportWidth) adjustedX -= popupWidth + 40; + if (adjustedY + popupHeight > viewportHeight) adjustedY -= popupHeight + 40; + if (adjustedX < 20) adjustedX = 20; + if (adjustedY < 20) adjustedY = 20; + + this.handleNodeClick(clickedNode, { x: adjustedX, y: adjustedY }); + } + }); + + network.on('stabilizationIterationsDone', () => { + network.fit(); + if (!hasSavedPositions) { + const positions = network.getPositions(); + this.savedPositions = positions; + localStorage.setItem(this.savedPositionsKey, JSON.stringify(positions)); + } + }); + + if (hasSavedPositions) { + setTimeout(() => network.fit(), 500); + } +} + + handleNodeClick(node: any, position?: { x: number, y: number }) { + this.selectedDevice = node; + if (position) { + this.selectedDevice.popupPosition = position; + } + } + + closeDeviceDetails() { + this.selectedDevice = null; + } + + getDeviceInterfaces() { + if (!this.selectedDevice) return []; + + const device = this.mikrotikData.find(d => d.name === this.selectedDevice.label); + if (!device) return []; + + return Object.entries(device.interfaces).map(([name, data]: [string, any]) => ({ + name, + address: data.address, + mac: data.mac + })); + } + + getNodeColor(deviceType: string): string { + const colors = { + 'gateway': '#dc3545', + 'router': '#fd7e14', + 'switch': '#6f42c1', + 'ap': '#20c997', + 'cpe': '#0dcaf0' + }; + return (colors as any)[deviceType] || '#6c757d'; + } + + getNodeBorderColor(deviceType: string): string { + const borderColors = { + 'gateway': '#b02a37', + 'router': '#e8681a', + 'switch': '#59359a', + 'ap': '#1aa179', + 'cpe': '#0baccc' + }; + return (borderColors as any)[deviceType] || '#495057'; + } + + getDeviceIcon(deviceType: string, brand: string): string { + const basePath = './assets/Network-Icons-SVG/'; + const type = deviceType.toLowerCase(); + const brandName = brand.toLowerCase(); + + // MikroTik devices + if (brandName === 'mikrotik') { + if (type === 'switch') { + return `${basePath}cumulus-switch-v2.svg`; + } + return `${basePath}cumulus-router-v2.svg`; + } + + // Cisco devices + if (brandName === 'cisco') { + if (type === 'switch') { + return `${basePath}cisco-switch-l2.svg`; + } + return `${basePath}cisco-router.svg`; + } + + // Juniper devices + if (brandName === 'juniper') { + if (type === 'switch') { + return `${basePath}juniper-switch-l2.svg`; + } + return `${basePath}juniper-router.svg`; + } + + // HPE/Aruba devices + if (brandName === 'hpe/aruba' || brandName === 'aruba' || brandName === 'hpe') { + if (type === 'server') { + return `${basePath}generic-server-1.svg`; + } + return `${basePath}arista-switch.svg`; + } + + // Ubiquiti devices + if (brandName === 'ubiquiti' || brandName === 'ubnt') { + if (type === 'switch') { + return `${basePath}generic-switch-l2-v1-colour.svg`; + } + return `${basePath}generic-router-colour.svg`; + } + + // Default icons by type + const defaultIcons = { + 'switch': `${basePath}generic-switch-l2-v1-colour.svg`, + 'router': `${basePath}generic-router-colour.svg`, + 'router/switch': `${basePath}generic-router-colour.svg`, + 'server': `${basePath}generic-server-1.svg`, + 'unknown': `${basePath}generic-router-colour.svg` + }; + + return (defaultIcons as any)[type] || `${basePath}generic-router-colour.svg`; + } + + getDefaultPosition(deviceName: string, index: number): { x: number, y: number } { + const positions = { + 'Core Router': { x: 0, y: 0 }, + 'Edge Router': { x: -200, y: -100 }, + 'Distribution Switch': { x: 200, y: -100 }, + 'Access Point 1': { x: 100, y: 100 }, + 'Access Point 2': { x: 300, y: 100 }, + 'Customer Router 1': { x: 0, y: 200 }, + 'Customer Router 2': { x: 200, y: 200 } + }; + return (positions as any)[deviceName] || { x: index * 100, y: index * 50 }; + } + + webAccess() { + if (!this.selectedDevice) return; + const device = this.mikrotikData.find(d => d.name === this.selectedDevice.label); + if (device) { + const firstInterface = Object.values(device.interfaces)[0] as any; + const ip = firstInterface.address.split('/')[0]; + window.open(`http://${ip}`, '_blank'); + } + } + + showMoreInfo() { + console.log('More info for:', this.selectedDevice); + // Implement modal or detailed view + } + + pingDevice() { + console.log('Ping device:', this.selectedDevice); + // Implement ping functionality + } + + configureDevice() { + console.log('Configure device:', this.selectedDevice); + // Implement configuration interface + } + +} diff --git a/src/app/views/maps/maps-routing.module.ts b/src/app/views/maps/maps-routing.module.ts new file mode 100644 index 0000000..db9975a --- /dev/null +++ b/src/app/views/maps/maps-routing.module.ts @@ -0,0 +1,21 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { MapsComponent } from './maps.component'; + +const routes: Routes = [ + { + path: '', + component: MapsComponent, + data: { + title: $localize`Maps Wall` + } + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class MapsRoutingModule { +} diff --git a/src/app/views/maps/maps.component.html b/src/app/views/maps/maps.component.html new file mode 100644 index 0000000..3319749 --- /dev/null +++ b/src/app/views/maps/maps.component.html @@ -0,0 +1,82 @@ + + + + +
+
+
+
+ +
+
+
+ {{ selectedDevice.label }} + +
+ +
+
+
Type: {{getDeviceInfo()?.type}} ({{getDeviceInfo()?.brand}})
+
Board: {{getDeviceInfo()?.board}}
+
Version: {{getDeviceInfo()?.version}}
+
System: {{getDeviceInfo()?.systemDescription}}
+
IP: {{getPrimaryIP()}}
+
Neighbors: {{getNeighborCount()}}
+
+ +
+
+
+ {{ interface.name }} + ({{ interface.neighbors }} neighbors) +
+ {{ interface.address }} +
+
+ +
+ + + +
+
+
+
+ + + + +
Web Access Options
+ +
+ +

Choose how to access the device:

+
+ + +
+
+ + + +
diff --git a/src/app/views/maps/maps.component.scss b/src/app/views/maps/maps.component.scss new file mode 100644 index 0000000..26682f1 --- /dev/null +++ b/src/app/views/maps/maps.component.scss @@ -0,0 +1,256 @@ +:host { + .network-container { + height: calc(100vh - 165px); + margin: 0; + position: relative; + } + + .network-col { + padding: 0; + } + + .network-card { + height: 100%; + border: none; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + border-radius: 12px; + overflow: hidden; + position: relative; + } + + .refresh-btn { + position: absolute; + top: 10px; + right: 10px; + z-index: 1000; + padding: 6px 12px; + font-size: 12px; + } + + .network-canvas { + width: 100%; + height: calc(100vh - 200px); + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + border-radius: 8px; + border: 1px solid #dee2e6; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + + ::ng-deep .vis-network { + cursor: default; + + .vis-item { + cursor: pointer; + + &:hover { + cursor: pointer; + } + + &:active { + cursor: grabbing; + } + } + } + } + + .floating-sidebar { + position: fixed; + z-index: 10000; + animation: slideIn 0.3s ease; + } + + .device-panel { + background: rgba(44, 62, 80, 0.95); + backdrop-filter: blur(10px); + border-radius: 8px; + border: 1px solid rgba(52, 73, 94, 0.8); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + width: 280px; + max-height: calc(100vh - 40px); + color: white; + display: flex; + flex-direction: column; + } + + .panel-header { + padding: 12px 16px; + border-bottom: 1px solid rgba(52, 73, 94, 0.5); + display: flex; + justify-content: space-between; + align-items: center; + } + + .device-name { + font-weight: 600; + font-size: 14px; + color: #ecf0f1; + } + + .close-btn { + color: #bdc3c7; + border: none; + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background: rgba(255, 255, 255, 0.2); + color: white; + } + } + + .panel-content { + padding: 16px; + overflow-y: auto; + flex: 1; + + /* Override global scrollbar styles */ + scrollbar-width: thin !important; + scrollbar-color: #bdc3c7 rgba(52, 73, 94, 0.3) !important; + + /* Custom scrollbar styling for webkit */ + &::-webkit-scrollbar { + width: 8px !important; + } + + &::-webkit-scrollbar-track { + background: rgba(52, 73, 94, 0.3) !important; + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: #bdc3c7 !important; + border-radius: 4px; + + &:hover { + background: #ecf0f1 !important; + } + } + } + + .interfaces-section { + margin-bottom: 16px; + margin-top: 3px; + + .interface-row { + max-height: none; + } + } + + .section-title { + font-size: 12px; + font-weight: 600; + color: #95a5a6; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .interface-row { + display: flex; + flex-direction: column; + padding: 1px 0; + border-bottom: 1px solid rgba(52, 73, 94, 0.3); + font-size: 12px; + gap: 2px; + + &:last-child { + border-bottom: none; + } + } + + .if-header { + display: flex; + justify-content: space-between; + align-items: center; + } + + .if-name { + color: #3498db; + font-weight: 500; + } + + .if-ip { + color: #ecf0f1; + font-family: monospace; + word-break: break-all; + } + + .if-neighbors { + color: #95a5a6; + font-size: 11px; + } + + .actions-section { + display: flex; + gap: 8px; + + &:has(button:only-child) { + button { + flex: 1; + } + } + + &:not(:has(button:only-child)) { + display: grid; + grid-template-columns: 1fr 1fr; + } + } + + .compact-btn { + padding: 6px 8px; + font-size: 11px; + border-radius: 4px; + display: flex; + align-items: center; + gap: 4px; + justify-content: center; + transition: all 0.2s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + } + } + + @keyframes slideIn { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } + } +} + +.mdc-line-ripple.mdc-line-ripple--deactivating.ng-star-inserted { + display: none!important; +} + +::ng-deep .main-container{ + padding:0!important; + margin-top:-10px; +} + +::ng-deep .header{ + margin-bottom: 0.9rem!important; +} + +@media only screen and (max-width: 768px) { + :host .floating-sidebar { + position: fixed; + top: 10px; + right: 10px; + left: 10px; + width: auto; + } + + :host .device-panel { + width: 100%; + } +} \ No newline at end of file diff --git a/src/app/views/maps/maps.component.ts b/src/app/views/maps/maps.component.ts new file mode 100644 index 0000000..d545e8a --- /dev/null +++ b/src/app/views/maps/maps.component.ts @@ -0,0 +1,479 @@ +import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from "@angular/core"; +import { dataProvider } from "../../providers/mikrowizard/data"; +import { loginChecker } from "../../providers/login_checker"; +import { Router } from "@angular/router"; +import { formatInTimeZone } from "date-fns-tz"; +import { Network } from 'vis-network/peer'; +import { DataSet } from 'vis-data'; + +@Component({ + templateUrl: "maps.component.html", + styleUrls: ["maps.component.scss"], +}) +export class MapsComponent implements OnInit { + public uid: number; + public uname: string; + public ispro: boolean = false; + public tz: string; + public savedPositions: any = {}; + public savedPositionsKey = "network-layout"; + public selectedDevice: any = null; + public showWebAccessModal: boolean = false; + public showMoreInfoModal: boolean = false; + public currentDeviceInfo: any = null; + constructor( + private data_provider: dataProvider, + private router: Router, + private login_checker: loginChecker + ) { + var _self = this; + if (!this.login_checker.isLoggedIn()) { + setTimeout(function () { + _self.router.navigate(["login"]); + }, 100); + } + this.data_provider.getSessionInfo().then((res) => { + _self.uid = res.uid; + _self.uname = res.name; + _self.ispro = res.ISPRO; + if (!_self.ispro) + setTimeout(function () { + _self.router.navigate(["dashboard"]); + }, 100); + _self.tz = res.tz; + }); + } + + @ViewChild('network', { static: true }) networkContainer: ElementRef | undefined; + + mikrotikData: any[] = []; + + ngOnInit(): void { + this.loadFontAwesome(); + this.savedPositions = JSON.parse(localStorage.getItem(this.savedPositionsKey) || "{}"); + this.loadNetworkData(); + } + + loadNetworkData(): void { + this.data_provider.getNetworkMap().then((res) => { + this.mikrotikData = res; + console.dir(res); + setTimeout(() => { + this.createNetworkMap(); + }, 100); + }); + } + + refreshData(): void { + this.selectedDevice = null; + this.loadNetworkData(); + } + + + + loadFontAwesome() { + if (!document.querySelector('link[href*="font-awesome"]')) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css'; + document.head.appendChild(link); + } + } + + createNetworkMap() { + const container = this.networkContainer?.nativeElement; + if (!container) return; + + let nodes = new DataSet([]); + let edges = new DataSet([]); + let deviceMap: { [key: string]: string } = {}; // uniqueId (hostname) to nodeId mapping + let allDevices: { [key: string]: any } = {}; // uniqueId to device info mapping + let macToDevice: { [key: string]: string } = {}; // MAC -> uniqueId (hostname) mapping + const hasSavedPositions = Object.keys(this.savedPositions).length > 0; + let nodeIdCounter = 1; + + // Collect all devices using hostname as consistent unique ID + this.mikrotikData.forEach((device) => { + const deviceId = device.name; // Use name (hostname) as unique ID + + if (!allDevices[deviceId]) { + allDevices[deviceId] = { + name: device.name, + type: 'Router', + brand: 'MikroTik' + }; + } + + Object.entries(device.interfaces).forEach(([_, iface]: [string, any]) => { + if (iface.mac) { + macToDevice[iface.mac] = deviceId; // Map to hostname + } + + if (iface.neighbors && Array.isArray(iface.neighbors)) { + iface.neighbors.forEach((neighbor: any) => { + const neighborId = neighbor.hostname || 'Unknown'; // Use hostname + + if (!allDevices[neighborId]) { + allDevices[neighborId] = { + name: neighbor.hostname || neighbor.mac || 'Unknown', + type: neighbor.type || 'Router', + brand: neighbor.brand || 'MikroTik' + }; + } + + if (neighbor.mac) { + macToDevice[neighbor.mac] = neighborId; // Map to hostname + } + }); + } + }); + }); + + // Create nodes + Object.entries(allDevices).forEach(([uniqueId, device]: [string, any]) => { + const nodeId = `node_${nodeIdCounter++}`; + deviceMap[uniqueId] = nodeId; + + nodes.add({ + id: nodeId, + label: device.name, + shape: 'image', + image: this.getDeviceIcon(device.type || 'Unknown', device.brand || 'Unknown'), + size: 15, + font: { size: 11, color: '#333', face: 'Arial, sans-serif' }, + ...(hasSavedPositions && this.savedPositions[nodeId] + ? { x: this.savedPositions[nodeId].x, y: this.savedPositions[nodeId].y } + : {}) + } as any); + }); + +// Create edges - collect all connections first +let connectionMap: { [key: string]: string[] } = {}; + +this.mikrotikData.forEach((device) => { + const deviceName = device.name; + Object.entries(device.interfaces).forEach(([ifaceName, iface]: [string, any]) => { + const sourceDeviceId = iface.mac ? macToDevice[iface.mac] : deviceName; + + if (iface.neighbors && Array.isArray(iface.neighbors)) { + iface.neighbors.forEach((neighbor: any) => { + const targetDeviceId = neighbor.mac ? macToDevice[neighbor.mac] : null; + + if (deviceMap[sourceDeviceId] && targetDeviceId && deviceMap[targetDeviceId]) { + const connectionKey = [sourceDeviceId, targetDeviceId].sort().join('_'); + const interfacePair = neighbor.interface ? `${ifaceName}↔${neighbor.interface}` : ifaceName; + + if (!connectionMap[connectionKey]) { + connectionMap[connectionKey] = []; + } + + if (!connectionMap[connectionKey].includes(interfacePair)) { + connectionMap[connectionKey].push(interfacePair); + } + } + }); + } + }); +}); + +// Create edges with combined labels +Object.entries(connectionMap).forEach(([connectionKey, interfacePairs]) => { + const [sourceDeviceId, targetDeviceId] = connectionKey.split('_'); + let edgeLabel = interfacePairs.join('\n'); + + // Limit to max 2 interface pairs to avoid overcrowding + if (interfacePairs.length > 2) { + edgeLabel = interfacePairs.slice(0, 2).join('\n') + '\n+' + (interfacePairs.length - 2); + } + + edges.add({ + id: connectionKey, + from: deviceMap[sourceDeviceId], + to: deviceMap[targetDeviceId], + label: edgeLabel, + color: { color: '#34495e', highlight: '#3498db' }, + width: 3, + smooth: { type: 'continuous', roundness: 0.1 }, + font: { + size: 9, + color: '#2c3e50', + face: 'Arial, sans-serif', + strokeWidth: 2, + strokeColor: '#ffffff', + align: 'horizontal' + } + } as any); +}); + const data = { nodes, edges }; + const options = { physics: { enabled: true, stabilization: { iterations: 100 }, barnesHut: { gravitationalConstant: -8000, centralGravity: 0.3, springLength: 200, springConstant: 0.04, damping: 0.09 } }, interaction: { hover: true, dragNodes: true, dragView: true, zoomView: true, hoverConnectedEdges: false, selectConnectedEdges: false, navigationButtons: false, keyboard: false }, nodes: { borderWidth: 3, shadow: true }, edges: { shadow: true, smooth: true, length: 150 }, manipulation: { enabled: false } }; + const network = new Network(container, data, options); + + // Keep your existing events (dragEnd, click, stabilization, etc.) + // No changes needed below + network.on('dragEnd', () => { + const positions = network.getPositions(); + this.savedPositions = positions; + localStorage.setItem(this.savedPositionsKey, JSON.stringify(positions)); + }); + + network.on('click', (event: any) => { + if (event.nodes[0]) { + const clickedNode = nodes.get(event.nodes[0]); + const canvasPosition = network.canvasToDOM(event.pointer.canvas); + const containerRect = container.getBoundingClientRect(); + const mainContainer = document.querySelector('.main-container') as HTMLElement; + const mainRect = mainContainer?.getBoundingClientRect() || containerRect; + + let adjustedX = canvasPosition.x + containerRect.left - mainRect.left + 20; + let adjustedY = canvasPosition.y + containerRect.top - mainRect.top - 50; + + const popupWidth = 280; + const maxPopupHeight = window.innerHeight - 40; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + if (adjustedX + popupWidth > viewportWidth) adjustedX -= popupWidth + 40; + if (adjustedY + maxPopupHeight > viewportHeight) adjustedY = viewportHeight - maxPopupHeight - 20; + if (adjustedX < 20) adjustedX = 20; + if (adjustedY < 20) adjustedY = 20; + + this.handleNodeClick(clickedNode, { x: adjustedX, y: adjustedY }); + } + }); + + network.on('stabilizationIterationsDone', () => { + network.fit(); + if (!hasSavedPositions) { + const positions = network.getPositions(); + this.savedPositions = positions; + localStorage.setItem(this.savedPositionsKey, JSON.stringify(positions)); + } + }); + + if (hasSavedPositions) { + setTimeout(() => network.fit(), 500); + } + } + + handleNodeClick(node: any, position?: { x: number, y: number }) { + this.selectedDevice = node; + if (position) { + this.selectedDevice.popupPosition = position; + } + } + + closeDeviceDetails() { + this.selectedDevice = null; + } + + getDeviceInfo() { + if (!this.selectedDevice) return null; + + const device = this.mikrotikData.find(d => d.name === this.selectedDevice.label); + + if (device) { + // Main device found + const interfaces = Object.entries(device.interfaces).map(([name, data]: [string, any]) => ({ + name, + address: data.address || 'N/A', + mac: data.mac || 'N/A', + neighbors: data.neighbors?.length || 0 + })); + + const interfaceWithNeighbors = Object.values(device.interfaces) + .find((iface: any) => iface.neighbors?.length > 0) as any; + const firstNeighbor = interfaceWithNeighbors?.neighbors?.[0]; + + return { + name: device.name, + deviceId: device.device_id, + type: firstNeighbor?.type || 'Router', + brand: firstNeighbor?.brand || 'MikroTik', + board: firstNeighbor?.board || 'Unknown', + version: firstNeighbor?.version || 'Unknown', + systemDescription: firstNeighbor?.['system-description'] || null, + softwareId: firstNeighbor?.['software-id'] || 'N/A', + interfaces + }; + } else { + // Search in neighbor data + for (const mainDevice of this.mikrotikData) { + for (const iface of Object.values(mainDevice.interfaces)) { + const neighbor = (iface as any).neighbors?.find((n: any) => n.hostname === this.selectedDevice.label); + if (neighbor) { + return { + name: neighbor.hostname, + deviceId: null, + type: neighbor.type || 'Router', + brand: neighbor.brand || 'MikroTik', + board: neighbor.board || 'Unknown', + version: neighbor.version || 'Unknown', + systemDescription: neighbor['system-description'] || null, + softwareId: neighbor['software-id'] || 'N/A', + interfaces: [{ name: neighbor.interface || 'Unknown', address: neighbor.address || 'N/A', mac: neighbor.mac || 'N/A', neighbors: 0 }] + }; + } + } + } + } + + return null; + } + + getNodeColor(deviceType: string): string { + const colors = { + 'gateway': '#dc3545', + 'router': '#fd7e14', + 'switch': '#6f42c1', + 'ap': '#20c997', + 'cpe': '#0dcaf0' + }; + return (colors as any)[deviceType] || '#6c757d'; + } + + getNodeBorderColor(deviceType: string): string { + const borderColors = { + 'gateway': '#b02a37', + 'router': '#e8681a', + 'switch': '#59359a', + 'ap': '#1aa179', + 'cpe': '#0baccc' + }; + return (borderColors as any)[deviceType] || '#495057'; + } + + getDeviceIcon(deviceType: string, brand: string): string { + const basePath = './assets/Network-Icons-SVG/'; + const type = deviceType.toLowerCase(); + const brandName = brand.toLowerCase(); + + // MikroTik devices + if (brandName === 'mikrotik') { + if (type === 'switch') { + return `${basePath}cumulus-switch-v2.svg`; + } + return `${basePath}cumulus-router-v2.svg`; + } + + // Cisco devices + if (brandName === 'cisco') { + if (type === 'switch') { + return `${basePath}cisco-switch-l2.svg`; + } + return `${basePath}cisco-router.svg`; + } + + // Juniper devices + if (brandName === 'juniper') { + if (type === 'switch') { + return `${basePath}juniper-switch-l2.svg`; + } + return `${basePath}juniper-router.svg`; + } + + // HPE/Aruba devices + if (brandName === 'hpe/aruba' || brandName === 'aruba' || brandName === 'hpe') { + if (type === 'server') { + return `${basePath}generic-server-1.svg`; + } + return `${basePath}arista-switch.svg`; + } + + // Ubiquiti devices + if (brandName === 'ubiquiti' || brandName === 'ubnt') { + if (type === 'switch') { + return `${basePath}generic-switch-l2-v1-colour.svg`; + } + return `${basePath}generic-router-colour.svg`; + } + + // Default icons by type + const defaultIcons = { + 'switch': `${basePath}generic-switch-l2-v1-colour.svg`, + 'router': `${basePath}generic-router-colour.svg`, + 'router/switch': `${basePath}generic-router-colour.svg`, + 'server': `${basePath}generic-server-1.svg`, + 'unknown': `${basePath}generic-router-colour.svg` + }; + + return (defaultIcons as any)[type] || `${basePath}generic-router-colour.svg`; + } + + getDefaultPosition(deviceName: string, index: number): { x: number, y: number } { + const positions = { + 'Core Router': { x: 0, y: 0 }, + 'Edge Router': { x: -200, y: -100 }, + 'Distribution Switch': { x: 200, y: -100 }, + 'Access Point 1': { x: 100, y: 100 }, + 'Access Point 2': { x: 300, y: 100 }, + 'Customer Router 1': { x: 0, y: 200 }, + 'Customer Router 2': { x: 200, y: 200 } + }; + return (positions as any)[deviceName] || { x: index * 100, y: index * 50 }; + } + + webAccess() { + if (!this.selectedDevice) return; + this.currentDeviceInfo = this.getDeviceInfo(); + this.showWebAccessModal = true; + this.closeDeviceDetails(); + } + + openProxyAccess() { + if (this.currentDeviceInfo?.deviceId) { + window.open(`/api/proxy/init?devid=${this.currentDeviceInfo.deviceId}`, '_blank'); + } else { + const ip = this.currentDeviceInfo?.interfaces.find((iface: any) => iface.address !== 'N/A')?.address?.split('/')[0]; + if (ip) { + window.open(`/api/proxy/init?dev_ip=${ip}`, '_blank'); + } + } + this.showWebAccessModal = false; + } + + openDirectAccess() { + const ip = this.currentDeviceInfo?.interfaces.find((iface: any) => iface.address !== 'N/A')?.address?.split('/')[0]; + if (ip) { + window.open(`http://${ip}`, '_blank'); + } + this.showWebAccessModal = false; + } + + closeWebAccessModal() { + this.showWebAccessModal = false; + } + + getNeighborCount() { + const deviceInfo = this.getDeviceInfo(); + return deviceInfo?.interfaces.reduce((total, iface) => total + iface.neighbors, 0) || 0; + } + + getPrimaryIP() { + const deviceInfo = this.getDeviceInfo(); + const primaryInterface = deviceInfo?.interfaces.find(iface => iface.address !== 'N/A'); + return primaryInterface?.address?.split('/')[0] || 'N/A'; + } + + getDeviceInterfaces() { + const deviceInfo = this.getDeviceInfo(); + return deviceInfo?.interfaces || []; + } + + showMoreInfo() { + const deviceInfo = this.getDeviceInfo(); + if (deviceInfo?.deviceId) { + window.open(`/#/device-stats;id=${deviceInfo.deviceId}`, '_blank'); + } + } + + pingDevice(devid: number) { + console.log('Ping device:', this.selectedDevice); + // Implement ping functionality + } + + configureDevice(devid: number) { + console.log('Configure device:', this.selectedDevice); + // Implement configuration interface + } + +} \ No newline at end of file diff --git a/src/app/views/maps/maps.module.ts b/src/app/views/maps/maps.module.ts new file mode 100644 index 0000000..beb35e3 --- /dev/null +++ b/src/app/views/maps/maps.module.ts @@ -0,0 +1,60 @@ +import { NgModule } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { ReactiveFormsModule } from "@angular/forms"; +import { FormsModule } from "@angular/forms"; + +import { + ButtonGroupModule, + ButtonModule, + CardModule, + GridModule, + WidgetModule, + ProgressModule, + TemplateIdDirective, + TooltipModule, + BadgeModule, + CarouselModule, + ListGroupModule, + ModalModule, + TableModule, + UtilitiesModule +} from "@coreui/angular"; +import { IconModule } from "@coreui/icons-angular"; + +import { ChartjsModule } from "@coreui/angular-chartjs"; +import { NgScrollbarModule } from 'ngx-scrollbar'; +import { MapsRoutingModule } from "./maps-routing.module"; +import { MapsComponent } from "./maps.component"; +import { ClipboardModule } from "@angular/cdk/clipboard"; +import { InfiniteScrollModule } from 'ngx-infinite-scroll'; + +@NgModule({ + imports: [ + MapsRoutingModule, + CardModule, + WidgetModule, + CommonModule, + GridModule, + ProgressModule, + ReactiveFormsModule, + ButtonModule, + ModalModule, + FormsModule, + TemplateIdDirective, + ButtonModule, + ButtonGroupModule, + ChartjsModule, + CarouselModule, + BadgeModule, + ClipboardModule, + ListGroupModule, + NgScrollbarModule, + TableModule, + TooltipModule, + UtilitiesModule, + InfiniteScrollModule, + IconModule + ], + declarations: [MapsComponent], +}) +export class MapsModule {}