From 63a7f4d785fa4e6afbf493882ba18b682945f9bd Mon Sep 17 00:00:00 2001 From: 2GT-Rich <107219997+2GT-Rich@users.noreply.github.com> Date: Sat, 16 May 2026 16:13:20 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20improve=20network=20topology=20?= =?UTF-8?q?=E2=80=94=20LLDP-authoritative,=20manual=20links,=20orphan=20ha?= =?UTF-8?q?ndling=20(v0.11.6=20Beta)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: 7-step pipeline — per-neighbor best-protocol dedup, bidirectional LLDP pair merging, LLDP-covered set suppression of noisy CDP/MNDP links, shared-segment heuristic limited to genuinely unresolved non-LLDP links - Backend: manual_topology_links table + POST/DELETE endpoints for user-drawn connections - Frontend: ManualEdge custom edge type (purple dashed + × delete button) - Frontend: Connect Mode toggle — blue crosshair handles, onConnect creates DB-persisted link - Frontend: orphan nodes in dedicated bottom row with orange warning banner - Frontend: legend updated, canvas height increased Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 +- backend/package.json | 2 +- backend/src/db/migrate.ts | 12 + backend/src/routes/topology.ts | 368 +++++++++++--- frontend/package.json | 2 +- frontend/src/pages/TopologyPage.tsx | 719 ++++++++++++---------------- frontend/src/services/api.ts | 7 + frontend/src/version.ts | 2 +- 8 files changed, 625 insertions(+), 489 deletions(-) diff --git a/README.md b/README.md index e4492c0..1605315 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface. -![Version](https://img.shields.io/badge/version-0.11.5_Beta-blue) +![Version](https://img.shields.io/badge/version-0.11.6_Beta-blue) ![License](https://img.shields.io/badge/license-AGPLv3-blue) ![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white) diff --git a/backend/package.json b/backend/package.json index c67f747..8e6a636 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.11.5-beta", + "version": "0.11.6-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 3057491..73d19c0 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -347,6 +347,18 @@ CREATE TABLE IF NOT EXISTS device_availability ( ); CREATE INDEX IF NOT EXISTS idx_device_availability_device ON device_availability(device_id, went_offline_at DESC); +-- Manual topology links — user-drawn connections for devices with no auto-discovery +CREATE TABLE IF NOT EXISTS manual_topology_links ( + id SERIAL PRIMARY KEY, + from_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + to_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + label VARCHAR(100), + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(from_device_id, to_device_id) +); +CREATE INDEX IF NOT EXISTS idx_manual_topology_links_from ON manual_topology_links(from_device_id); +CREATE INDEX IF NOT EXISTS idx_manual_topology_links_to ON manual_topology_links(to_device_id); + -- Configuration templates (reusable config sets pushed to devices or groups) CREATE TABLE IF NOT EXISTS config_templates ( id SERIAL PRIMARY KEY, diff --git a/backend/src/routes/topology.ts b/backend/src/routes/topology.ts index acf2247..8f97731 100644 --- a/backend/src/routes/topology.ts +++ b/backend/src/routes/topology.ts @@ -1,5 +1,5 @@ import { Router, Request, Response } from 'express'; -import { query } from '../config/database'; +import { query, queryOne } from '../config/database'; import { requireAuth, requireWrite } from '../middleware/auth'; import { PollerService } from '../services/PollerService'; @@ -11,92 +11,86 @@ export function setPollerService(p: PollerService): void { pollerService = p; } +interface LinkRow { + id: number; + from_device_id: number | null; + from_interface: string | null; + to_interface: string | null; + to_device_id: number | null; + neighbor_address: string | null; + neighbor_identity: string | null; + neighbor_platform: string | null; + neighbor_mac: string | null; + stp_role: string | null; + stp_state: string | null; + bridge_name: string | null; + neighbor_caps: string | null; + link_type: string | null; + discovered_by: string | null; + from_device_name: string | null; + to_device_name: string | null; +} + // GET /api/topology router.get('/', async (_req: Request, res: Response) => { - // Protocol priority: lldp (point-to-point) > cdp > mndp (L2 broadcast, can be indirect) const PROTO_RANK: Record = { lldp: 0, cdp: 1, mndp: 2 }; const protoRank = (p: string | null) => PROTO_RANK[p ?? ''] ?? 3; - interface LinkRow { - id: number; - from_device_id: number | null; - from_interface: string | null; - to_interface: string | null; - to_device_id: number | null; - neighbor_address: string | null; - neighbor_identity: string | null; - neighbor_platform: string | null; - neighbor_mac: string | null; - stp_role: string | null; - stp_state: string | null; - bridge_name: string | null; - neighbor_caps: string | null; - link_type: string | null; - discovered_by: string | null; - from_device_name: string | null; - to_device_name: string | null; - } - - const [devices, allLinks] = await Promise.all([ + const [devices, allLinks, manualLinks] = await Promise.all([ query( - `SELECT id, name, ip_address, model, device_type, status, ros_version, - ip_addresses_jsonb + `SELECT id, name, ip_address, model, device_type, status, ros_version, ip_addresses_jsonb FROM devices ORDER BY name ASC` ), query( `SELECT tl.*, - fd.name as from_device_name, fd.ip_address as from_device_ip, - td.name as to_device_name, td.ip_address as to_device_ip + fd.name AS from_device_name, + td.name AS to_device_name FROM topology_links tl LEFT JOIN devices fd ON fd.id = tl.from_device_id LEFT JOIN devices td ON td.id = tl.to_device_id ORDER BY tl.discovered_at DESC` ), + query<{ id: number; from_device_id: number; to_device_id: number; label: string | null; + from_name: string; to_name: string }>( + `SELECT ml.*, fd.name AS from_name, td.name AS to_name + FROM manual_topology_links ml + JOIN devices fd ON fd.id = ml.from_device_id + JOIN devices td ON td.id = ml.to_device_id` + ), ]); - // Deduplicate: for each (from_device, neighbor) pair keep only the highest-priority - // protocol link. This removes MNDP duplicates when the same neighbor was also found - // via LLDP or CDP on the same or a better-quality path. - const bestLinks = new Map(); + // ── Step 1: Per-(device, neighbor) best-protocol dedup ───────────────────── + const bestByPair = new Map(); for (const link of allLinks as LinkRow[]) { if (!link.from_device_id) continue; const neighborKey = link.to_device_id - ? String(link.to_device_id) - : (link.neighbor_mac || link.neighbor_address || link.neighbor_identity || ''); + ? `d:${link.to_device_id}` + : (link.neighbor_mac + ? `m:${link.neighbor_mac.toLowerCase()}` + : (link.neighbor_address || link.neighbor_identity || '')); + if (!neighborKey) continue; const key = `${link.from_device_id}::${neighborKey}`; - const existing = bestLinks.get(key); + const existing = bestByPair.get(key); if (!existing || protoRank(link.link_type) < protoRank(existing.link_type)) { - bestLinks.set(key, link); + bestByPair.set(key, link); } } - const links = Array.from(bestLinks.values()); + let links = Array.from(bestByPair.values()); - // ── Resolve neighbor IP → managed device (any IP on that device, not just mgmt) ── - // CDP/MNDP often report only an IP. We cache /ip/address per device during sync; - // if a neighbor address matches any managed device's known address, treat the - // link as device-to-device instead of creating an external node. - const stripCidr = (addr: string): string => { - const a = addr.trim(); - const slash = a.indexOf('/'); - return slash === -1 ? a : a.slice(0, slash); - }; - const normIp = (addr: string): string => stripCidr(addr).toLowerCase(); + // ── Step 2: Resolve neighbor IP → managed device via all known addresses ─── + const stripCidr = (a: string) => { const i = a.indexOf('/'); return i === -1 ? a : a.slice(0, i); }; + const normIp = (a: string) => stripCidr(a.trim()).toLowerCase(); const ipToDevice = new Map(); - for (const d of devices as { - id: number; name: string; ip_address: string; - ip_addresses_jsonb?: unknown; - }[]) { + for (const d of devices as { id: number; name: string; ip_address: string; ip_addresses_jsonb?: unknown }[]) { if (d.ip_address) { const k = normIp(d.ip_address); if (k && !ipToDevice.has(k)) ipToDevice.set(k, { id: d.id, name: d.name }); } - const list = d.ip_addresses_jsonb; - if (Array.isArray(list)) { - for (const entry of list) { + if (Array.isArray(d.ip_addresses_jsonb)) { + for (const entry of d.ip_addresses_jsonb) { const raw = typeof entry === 'object' && entry && 'address' in entry - ? String((entry as { address?: string }).address || '') - : ''; + ? String((entry as { address?: string }).address || '') : ''; const k = normIp(raw); if (k && !ipToDevice.has(k)) ipToDevice.set(k, { id: d.id, name: d.name }); } @@ -106,29 +100,89 @@ router.get('/', async (_req: Request, res: Response) => { for (const link of links) { if (link.to_device_id) continue; const na = link.neighbor_address?.trim(); - if (!na || na.includes('%')) continue; // skip zone-id style - const key = normIp(na); - if (!key) continue; - const hit = ipToDevice.get(key); + if (!na || na.includes('%')) continue; + const hit = ipToDevice.get(normIp(na)); if (hit && hit.id !== link.from_device_id) { link.to_device_id = hit.id; link.to_device_name = hit.name; } } - // Build synthetic external nodes from unresolved neighbor entries - const externalMap = new Map(); + // ── Step 3: Build "LLDP-covered" neighbor set ─────────────────────────────── + // Any neighbor identifier (device_id / MAC / IP) that appears as the target of + // an LLDP link. We use this to suppress CDP/MNDP links to the same neighbor — + // LLDP already gives us a precise point-to-point path, no need for the noisy L2 data. + const lldpCoveredDeviceIds = new Set(); + const lldpCoveredMacs = new Set(); + const lldpCoveredAddresses = new Set(); for (const link of links) { - if (link.to_device_id) continue; // resolved — skip - const key = link.neighbor_address || link.neighbor_mac || link.neighbor_identity || ''; + if (link.link_type !== 'lldp') continue; + if (link.to_device_id) lldpCoveredDeviceIds.add(link.to_device_id); + if (link.neighbor_mac) lldpCoveredMacs.add(link.neighbor_mac.toLowerCase()); + if (link.neighbor_address) lldpCoveredAddresses.add(normIp(link.neighbor_address)); + } + + const isLldpCovered = (link: LinkRow): boolean => { + if (link.to_device_id && lldpCoveredDeviceIds.has(link.to_device_id)) return true; + if (link.neighbor_mac && lldpCoveredMacs.has(link.neighbor_mac.toLowerCase())) return true; + if (link.neighbor_address && lldpCoveredAddresses.has(normIp(link.neighbor_address))) return true; + return false; + }; + + // Drop non-LLDP links whose neighbor is already reachable via LLDP elsewhere + links = links.filter((l) => l.link_type === 'lldp' || !isLldpCovered(l)); + + // ── Step 4: Merge bidirectional LLDP pairs ───────────────────────────────── + // For LLDP links A→B and B→A between two managed devices, keep one canonical + // record (lower from_device_id is "from"). Enrich to_interface from the + // reverse record if the forward record is missing it. + const canonicalLldp: LinkRow[] = []; + const mergedPairs = new Set(); + + for (const link of links) { + if (link.link_type !== 'lldp' || !link.to_device_id) { + canonicalLldp.push(link); + continue; + } + const lo = Math.min(link.from_device_id!, link.to_device_id); + const hi = Math.max(link.from_device_id!, link.to_device_id); + const pairKey = `${lo}:${hi}`; + + if (mergedPairs.has(pairKey)) continue; + mergedPairs.add(pairKey); + + // Prefer the direction where from_device_id is the lower id for stability + const forward = link.from_device_id === lo ? link : links.find( + r => r.link_type === 'lldp' && r.from_device_id === lo && r.to_device_id === hi + ); + const reverse = links.find( + r => r.link_type === 'lldp' && r.from_device_id === hi && r.to_device_id === lo + ); + + const canonical = forward ?? link; + + // If to_interface is missing on the canonical record but the reverse has from_interface, fill it in + if (!canonical.to_interface && reverse?.from_interface) { + canonical.to_interface = reverse.from_interface; + } + // If from_interface is missing but reverse has to_interface, fill it in + if (!canonical.from_interface && reverse?.to_interface) { + canonical.from_interface = reverse.to_interface; + } + + canonicalLldp.push(canonical); + } + + links = canonicalLldp; + + // ── Step 5: Build external node map ──────────────────────────────────────── + const externalMap = new Map(); + for (const link of links) { + if (link.to_device_id) continue; + const key = link.neighbor_mac || link.neighbor_address || link.neighbor_identity || ''; if (!key) continue; if (!externalMap.has(key)) { const safeId = key.replace(/[.: ]/g, ''); @@ -143,20 +197,186 @@ router.get('/', async (_req: Request, res: Response) => { } } - res.json({ devices, links, externalNodes: Array.from(externalMap.values()) }); + // ── Step 6: Shared-segment detection (only for truly unresolved non-LLDP links) ── + // After LLDP suppression above, the remaining non-LLDP links are genuinely + // on segments where we couldn't find LLDP. Only group them into shared segments + // when a single port sees ≥2 distinct unresolved neighbors. + + const lldpLinks = links.filter((l) => l.link_type === 'lldp'); + const nonLldpLinks = links.filter((l) => l.link_type !== 'lldp' && !!l.from_device_id); + + const portGroupMap = new Map(); + for (const link of nonLldpLinks) { + const pk = `${link.from_device_id}::${link.from_interface ?? ''}`; + if (!portGroupMap.has(pk)) portGroupMap.set(pk, []); + portGroupMap.get(pk)!.push(link); + } + + const sharedPortKeys = [...portGroupMap.keys()].filter((pk) => portGroupMap.get(pk)!.length >= 2); + const soloNonLldp = [...portGroupMap.values()].filter((g) => g.length < 2).flat(); + + const nKey = (l: LinkRow) => + l.to_device_id ? `d:${l.to_device_id}` : + l.neighbor_mac ? `m:${l.neighbor_mac.toLowerCase()}` : + l.neighbor_address ? `a:${l.neighbor_address}` : + `i:${l.neighbor_identity ?? ''}`; + + // Union-find to merge port groups sharing a common neighbor + const ufParent = new Map(sharedPortKeys.map((k) => [k, k])); + const ufFind = (k: string): string => { + if (ufParent.get(k) !== k) ufParent.set(k, ufFind(ufParent.get(k)!)); + return ufParent.get(k)!; + }; + const ufUnion = (a: string, b: string) => ufParent.set(ufFind(a), ufFind(b)); + + const pkNeighborSets = new Map>(); + for (const pk of sharedPortKeys) { + pkNeighborSets.set(pk, new Set(portGroupMap.get(pk)!.map(nKey))); + } + for (let i = 0; i < sharedPortKeys.length; i++) { + for (let j = i + 1; j < sharedPortKeys.length; j++) { + const setA = pkNeighborSets.get(sharedPortKeys[i])!; + for (const n of pkNeighborSets.get(sharedPortKeys[j])!) { + if (setA.has(n)) { ufUnion(sharedPortKeys[i], sharedPortKeys[j]); break; } + } + } + } + + const segGroups = new Map(); + for (const pk of sharedPortKeys) { + const root = ufFind(pk); + if (!segGroups.has(root)) segGroups.set(root, []); + segGroups.get(root)!.push(pk); + } + + interface SegConn { src: string; dst: string; port: string; } + const segNodes: { id: string; name: string; address: string; platform: string; mac: string; caps: string }[] = []; + const segConns: SegConn[] = []; + + for (const [root, pks] of segGroups) { + const segId = `seg-${root.replace(/[^a-z0-9]/gi, '')}`; + const srcDevPorts = new Map(); + const allDevIds = new Set(); + const extNKeys = new Set(); + + for (const pk of pks) { + const colonIdx = pk.indexOf('::'); + const devId = pk.slice(0, colonIdx); + const port = pk.slice(colonIdx + 2); + srcDevPorts.set(devId, port); + allDevIds.add(devId); + for (const link of portGroupMap.get(pk)!) { + if (link.to_device_id) allDevIds.add(String(link.to_device_id)); + else extNKeys.add(nKey(link)); + } + } + + segNodes.push({ + id: segId, + name: 'Shared Segment', + address: '', + platform: `${allDevIds.size} devices`, + mac: '', + caps: 'segment', + }); + + for (const [devId, port] of srcDevPorts) { + segConns.push({ src: devId, dst: segId, port }); + } + for (const nk of extNKeys) { + const ext = Array.from(externalMap.values()).find((e) => + `m:${(e.mac || '').toLowerCase()}` === nk || + `a:${e.address}` === nk || + `i:${e.name}` === nk + ); + if (ext) segConns.push({ src: ext.id, dst: segId, port: '' }); + } + } + + const activeLinks = [...lldpLinks, ...soloNonLldp]; + const externalNodes = Array.from(externalMap.values()); + const allExtNodes = [...externalNodes, ...segNodes]; + + // ── Step 7: Include manual links as synthetic topology link rows ──────────── + const manualAsLinks: LinkRow[] = (manualLinks as { id: number; from_device_id: number; to_device_id: number; label: string | null; from_name: string; to_name: string }[]).map((ml) => ({ + id: -ml.id, // negative id to avoid collision + from_device_id: ml.from_device_id, + from_interface: ml.label ?? null, + to_interface: null, + to_device_id: ml.to_device_id, + neighbor_address: null, + neighbor_identity: null, + neighbor_platform: null, + neighbor_mac: null, + stp_role: null, + stp_state: null, + bridge_name: null, + neighbor_caps: null, + link_type: 'manual', + discovered_by: null, + from_device_name: ml.from_name, + to_device_name: ml.to_name, + })); + + res.json({ + devices, + links: [...activeLinks, ...manualAsLinks], + externalNodes: allExtNodes, + segConns, + manualLinkIds: (manualLinks as { id: number; from_device_id: number; to_device_id: number }[]).map((ml) => ({ + id: ml.id, + from_device_id: ml.from_device_id, + to_device_id: ml.to_device_id, + })), + }); }); -// POST /api/topology/discover - trigger neighbor discovery on all devices +// POST /api/topology/discover router.post('/discover', requireWrite, async (_req: Request, res: Response) => { const devices = await query<{ id: number }>(`SELECT id FROM devices WHERE status='online'`); - if (pollerService) { for (const d of devices) { await pollerService.scheduleDeviceSync(d.id, 'slow'); } } - res.json({ message: `Discovery triggered for ${devices.length} device(s)` }); }); +// POST /api/topology/manual-links — create a user-drawn connection +router.post('/manual-links', requireWrite, async (req: Request, res: Response) => { + const { from_device_id, to_device_id, label } = req.body as { + from_device_id?: number; to_device_id?: number; label?: string; + }; + if (!from_device_id || !to_device_id) { + res.status(400).json({ error: 'from_device_id and to_device_id are required' }); + return; + } + if (from_device_id === to_device_id) { + res.status(400).json({ error: 'Cannot connect a device to itself' }); + return; + } + + // Ensure both devices exist + const [devA, devB] = await Promise.all([ + queryOne<{ id: number }>(`SELECT id FROM devices WHERE id = $1`, [from_device_id]), + queryOne<{ id: number }>(`SELECT id FROM devices WHERE id = $1`, [to_device_id]), + ]); + if (!devA || !devB) { res.status(404).json({ error: 'Device not found' }); return; } + + const rows = await query<{ id: number; from_device_id: number; to_device_id: number; label: string | null }>( + `INSERT INTO manual_topology_links (from_device_id, to_device_id, label) + VALUES ($1, $2, $3) + ON CONFLICT (from_device_id, to_device_id) DO UPDATE SET label = EXCLUDED.label + RETURNING *`, + [from_device_id, to_device_id, label ?? null] + ); + res.status(201).json(rows[0]); +}); + +// DELETE /api/topology/manual-links/:id +router.delete('/manual-links/:id', requireWrite, async (req: Request, res: Response) => { + await query(`DELETE FROM manual_topology_links WHERE id = $1`, [req.params.id]); + res.json({ ok: true }); +}); + export default router; diff --git a/frontend/package.json b/frontend/package.json index 1860918..fad8303 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.11.5-beta", + "version": "0.11.6-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/pages/TopologyPage.tsx b/frontend/src/pages/TopologyPage.tsx index 666c737..804c5d8 100644 --- a/frontend/src/pages/TopologyPage.tsx +++ b/frontend/src/pages/TopologyPage.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useQuery, useMutation } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { ReactFlow, MiniMap, @@ -10,28 +10,24 @@ import { useNodesState, useEdgesState, addEdge, + EdgeLabelRenderer, + BaseEdge, + getStraightPath, type Node, type Edge, type Connection, + type EdgeProps, BackgroundVariant, + MarkerType, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import { RefreshCw, GitBranch } from 'lucide-react'; +import { RefreshCw, GitBranch, Link2, Link2Off, X, AlertCircle } from 'lucide-react'; import { topologyApi } from '../services/api'; import type { TopologyDevice, TopologyLink, ExternalTopologyNode } from '../types'; import clsx from 'clsx'; import { useThemeStore } from '../store/themeStore'; -// helper to derive device class from LLDP capabilities -function capsLabel(caps: string | undefined): string { - if (!caps) return ''; - const c = caps.toLowerCase(); - if (c.includes('bridge')) return 'Switch'; - if (c.includes('router')) return 'Router'; - if (c.includes('wlan-ap')) return 'AP'; - if (c.includes('telephone')) return 'Phone'; - return ''; -} +// ─── Helpers ───────────────────────────────────────────────────────────────── const statusColor: Record = { online: '#22c55e', @@ -46,114 +42,130 @@ const deviceIcon: Record = { other: '◈', }; -const handleStyle = { opacity: 0, width: 8, height: 8 }; +function capsLabel(caps: string | undefined): string { + if (!caps) return ''; + const c = caps.toLowerCase(); + if (c.includes('bridge')) return 'Switch'; + if (c.includes('router')) return 'Router'; + if (c.includes('wlan-ap')) return 'AP'; + if (c.includes('telephone')) return 'Phone'; + return ''; +} + +const handleStyle = { opacity: 0, width: 10, height: 10, zIndex: 50 }; +const handleStyleVisible = { width: 12, height: 12, background: '#3b82f6', border: '2px solid #fff', zIndex: 50, cursor: 'crosshair' }; + +// ─── Node components ────────────────────────────────────────────────────────── function DeviceNode({ data }: { data: Record }) { - const device = data as unknown as TopologyDevice & { isRootBridge: boolean }; + const device = data as unknown as TopologyDevice & { isRootBridge: boolean; connectMode: boolean; orphan: boolean }; + const hStyle = device.connectMode ? handleStyleVisible : handleStyle; return (
- - - - + + + +
{deviceIcon[device.device_type] || '◈'} - - - {device.name} - - {device.isRootBridge && ( - ★ - )} + + {device.name} + {device.isRootBridge && ★}
{device.ip_address}
- {device.model && ( -
{device.model}
- )} + {device.model &&
{device.model}
} + {device.orphan &&
No known connections
}
); } function ExternalNode({ data }: { data: Record }) { - const node = data as unknown as ExternalTopologyNode; - - // Shared segment synthetic node + const node = data as unknown as ExternalTopologyNode & { connectMode: boolean }; if (node.caps === 'segment') { return ( -
+
⊕ Shared Segment
- {node.platform && ( -
{node.platform}
- )} -
- Unmanaged L2 switch/hub -
+ {node.platform &&
{node.platform}
} +
Unmanaged L2 switch / hub
); } - const cl = capsLabel(node.caps); return ( -
+
◌ - - {node.name}{cl ? ` (${cl})` : ''} - + {node.name}{cl ? ` (${cl})` : ''}
- {node.address && ( -
{node.address}
- )} - {node.platform && ( -
{node.platform}
- )} + {node.address &&
{node.address}
} + {node.platform &&
{node.platform}
}
External
); } -const nodeTypes = { deviceNode: DeviceNode, externalNode: ExternalNode }; +// ─── Manual Edge with delete button ────────────────────────────────────────── + +function ManualEdge({ id, sourceX, sourceY, targetX, targetY, data }: EdgeProps) { + const [edgePath, labelX, labelY] = getStraightPath({ sourceX, sourceY, targetX, targetY }); + const onDelete = (data as { onDelete?: (id: string) => void } | undefined)?.onDelete; + + return ( + <> + + +
+ manual + {onDelete && ( + + )} +
+
+ + ); +} + +const nodeTypes = { deviceNode: DeviceNode, externalNode: ExternalNode }; +const edgeTypes = { manualEdge: ManualEdge }; + +// ─── Layout constants ────────────────────────────────────────────────────────── -/** Layout box (React Flow uses top-left origin for `position`). */ const NODE_W = 160; const NODE_H = 80; -/** Conservative hit box so labels / padding do not overlap adjacent nodes */ const NODE_LAYOUT_W = 220; const NODE_LAYOUT_H = 110; -const H_GAP = 60; // horizontal spacing between sibling nodes -const V_GAP = 90; // vertical spacing between tree levels -const COMPONENT_GAP = 120; // gap between disconnected subgraphs -/** Horizontal slot for tree layout — use layout box width so siblings start far enough apart */ +const H_GAP = 60; +const V_GAP = 90; +const COMPONENT_GAP = 140; const NODE_SLOT = NODE_LAYOUT_W + H_GAP; -const OVERLAP_SEP = 16; // extra gap when separating overlapping nodes +const OVERLAP_SEP = 16; +const ORPHAN_ROW_GAP = 160; -export interface TopologyLayoutOptions { - /** When true, nodes at the same tree depth share one horizontal row (aligned Y). */ - alignByDepth: boolean; -} +// ─── Layout helpers ─────────────────────────────────────────────────────────── function rectsOverlap( a: { x: number; y: number; w: number; h: number }, @@ -162,51 +174,27 @@ function rectsOverlap( return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; } -/** Push nodes apart until bounding boxes (layout size) no longer overlap. */ -function resolveOverlaps( - positions: Map, - opts?: { maxIterations?: number } -): void { - const maxIt = opts?.maxIterations ?? 100; +function resolveOverlaps(positions: Map, maxIt = 100): void { const ids = [...positions.keys()]; if (ids.length < 2) return; - - const w = NODE_LAYOUT_W; - const h = NODE_LAYOUT_H; - + const w = NODE_LAYOUT_W, h = NODE_LAYOUT_H; for (let round = 0; round < maxIt; round++) { let moved = false; - const boxes = ids.map((id) => { - const p = positions.get(id)!; - return { id, x: p.x, y: p.y, w, h }; - }); - boxes.sort((a, b) => (a.y === b.y ? a.x - b.x : a.y - b.y)); - + const boxes = ids.map((id) => { const p = positions.get(id)!; return { id, x: p.x, y: p.y, w, h }; }); + boxes.sort((a, b) => a.y === b.y ? a.x - b.x : a.y - b.y); for (let i = 0; i < boxes.length; i++) { for (let j = i + 1; j < boxes.length; j++) { - const A = boxes[i]; - const B = boxes[j]; + const A = boxes[i], B = boxes[j]; if (!rectsOverlap(A, B)) continue; - const dx = Math.abs((B.x + B.w / 2) - (A.x + A.w / 2)); const dy = Math.abs((B.y + B.h / 2) - (A.y + A.h / 2)); - const pushDown = dy <= dx * 0.85; - - if (pushDown) { - const newY = A.y + A.h + OVERLAP_SEP; - if (newY !== B.y) { - positions.set(B.id, { x: B.x, y: newY }); - B.y = newY; - moved = true; - } + if (dy <= dx * 0.85) { + const ny = A.y + A.h + OVERLAP_SEP; + if (ny !== B.y) { positions.set(B.id, { x: B.x, y: ny }); B.y = ny; moved = true; } } else { const shift = B.x >= A.x ? 1 : -1; - const newX = B.x + shift * (Math.min(w, h) / 2 + OVERLAP_SEP); - if (newX !== B.x) { - positions.set(B.id, { x: newX, y: B.y }); - B.x = newX; - moved = true; - } + const nx = B.x + shift * (Math.min(w, h) / 2 + OVERLAP_SEP); + if (nx !== B.x) { positions.set(B.id, { x: nx, y: B.y }); B.x = nx; moved = true; } } } } @@ -214,152 +202,26 @@ function resolveOverlaps( } } -/** Same-depth nodes in one row, non-overlapping horizontally (strict grid). */ -function layoutAlignedRows( - positions: Map, - depthById: Map -): void { - const rowH = NODE_H + V_GAP; - const byDepth = new Map(); - for (const [id, d] of depthById) { - if (!positions.has(id)) continue; - if (!byDepth.has(d)) byDepth.set(d, []); - byDepth.get(d)!.push(id); - } - const depths = [...byDepth.keys()].sort((a, b) => a - b); - for (const d of depths) { - const rowIds = byDepth.get(d)!; - rowIds.sort((a, b) => (positions.get(a)!.x - positions.get(b)!.x)); - const slot = NODE_LAYOUT_W + H_GAP; - const rowW = rowIds.length * slot - H_GAP; - let x = -rowW / 2; - const y = d * rowH; - for (const id of rowIds) { - positions.set(id, { x, y }); - x += slot; - } - } -} +// ─── Graph builder ──────────────────────────────────────────────────────────── function buildGraph( devices: TopologyDevice[], externalNodes: ExternalTopologyNode[], links: TopologyLink[], - layout: TopologyLayoutOptions + segConns: { src: string; dst: string; port: string }[], + manualLinkIds: { id: number; from_device_id: number; to_device_id: number }[], + connectMode: boolean, + onDeleteManual: (edgeId: string) => void, ): { nodes: Node[]; edges: Edge[] } { - // ── Shared-segment detection ──────────────────────────────────────────────── - // LLDP is 802.1AB point-to-point — always a direct link. - // CDP and MNDP are L2 multicast and flood across unmanaged switches, so a port - // seeing multiple non-LLDP neighbors means they're all on a shared segment, - // not individually wired to this device. + const manualLinks = links.filter((l) => l.link_type === 'manual'); + const activeLinks = links.filter((l) => l.link_type !== 'manual'); + const allExtNodes = externalNodes; - const lldpLinks = links.filter((l) => l.link_type === 'lldp'); - const nonLldpLinks = links.filter((l) => l.link_type !== 'lldp' && !!l.from_device_id); - - // Group non-LLDP links by (from_device_id, from_interface) - const portGroupMap = new Map(); - for (const link of nonLldpLinks) { - const pk = `${link.from_device_id}::${link.from_interface ?? ''}`; - if (!portGroupMap.has(pk)) portGroupMap.set(pk, []); - portGroupMap.get(pk)!.push(link); - } - - // Port groups with ≥2 neighbors → shared segment; 1 neighbor → keep as direct - const sharedPortKeys = [...portGroupMap.keys()].filter((pk) => portGroupMap.get(pk)!.length >= 2); - const soloNonLldp = [...portGroupMap.values()].filter((g) => g.length < 2).flat(); - - // Stable key to identify a neighbor across links - const nKey = (l: TopologyLink) => - l.to_device_id ? `d:${l.to_device_id}` : - l.neighbor_mac ? `m:${l.neighbor_mac.toLowerCase()}` : - l.neighbor_address ? `a:${l.neighbor_address}` : - `i:${l.neighbor_identity ?? ''}`; - - // Union-find: merge port groups that see any common neighbor (same physical segment) - const ufParent = new Map(sharedPortKeys.map((k) => [k, k])); - const ufFind = (k: string): string => { - if (ufParent.get(k) !== k) ufParent.set(k, ufFind(ufParent.get(k)!)); - return ufParent.get(k)!; - }; - const ufUnion = (a: string, b: string) => ufParent.set(ufFind(a), ufFind(b)); - - const pkNeighborSets = new Map>(); - for (const pk of sharedPortKeys) { - pkNeighborSets.set(pk, new Set(portGroupMap.get(pk)!.map(nKey))); - } - for (let i = 0; i < sharedPortKeys.length; i++) { - for (let j = i + 1; j < sharedPortKeys.length; j++) { - const setA = pkNeighborSets.get(sharedPortKeys[i])!; - for (const n of pkNeighborSets.get(sharedPortKeys[j])!) { - if (setA.has(n)) { ufUnion(sharedPortKeys[i], sharedPortKeys[j]); break; } - } - } - } - - // Group port keys by segment root - const segGroups = new Map(); - for (const pk of sharedPortKeys) { - const root = ufFind(pk); - if (!segGroups.has(root)) segGroups.set(root, []); - segGroups.get(root)!.push(pk); - } - - // Build synthetic segment nodes and their connections - interface SegConn { src: string; dst: string; port: string; } - const segNodes: ExternalTopologyNode[] = []; - const segConns: SegConn[] = []; - - for (const [root, pks] of segGroups) { - const segId = `seg-${root.replace(/[^a-z0-9]/gi, '')}`; - - const srcDevPorts = new Map(); // devId → local port - const allDevIds = new Set(); - const extNKeys = new Set(); - - for (const pk of pks) { - const colonIdx = pk.indexOf('::'); - const devId = pk.slice(0, colonIdx); - const port = pk.slice(colonIdx + 2); - srcDevPorts.set(devId, port); - allDevIds.add(devId); - for (const link of portGroupMap.get(pk)!) { - if (link.to_device_id) allDevIds.add(String(link.to_device_id)); - else extNKeys.add(nKey(link)); - } - } - - segNodes.push({ - id: segId, - name: 'Shared Segment', - address: '', - platform: `${allDevIds.size} managed devices`, - mac: '', - caps: 'segment', // sentinel for the ExternalNode renderer - }); - - // Connect each source device to the segment node - for (const [devId, port] of srcDevPorts) { - segConns.push({ src: devId, dst: segId, port }); - } - // Connect unmanaged external neighbors to the segment node - for (const nk of extNKeys) { - const ext = externalNodes.find((e) => - `m:${(e.mac || '').toLowerCase()}` === nk || - `a:${e.address}` === nk || - `i:${e.name}` === nk - ); - if (ext) segConns.push({ src: ext.id, dst: segId, port: '' }); - } - } - - // Active direct links = LLDP + solo non-LLDP (single neighbor on that port) - const activeLinks = [...lldpLinks, ...soloNonLldp]; - const allExtNodes = [...externalNodes, ...segNodes]; - - // ── Adjacency ─────────────────────────────────────────────────────────────── + // ── Build adjacency (for layout) ──────────────────────────────────────────── + const deviceIds = new Set(devices.map((d) => String(d.id))); const adj = new Map>(); - for (const d of devices) adj.set(String(d.id), new Set()); + for (const d of devices) adj.set(String(d.id), new Set()); for (const e of allExtNodes) adj.set(e.id, new Set()); const linkToExtId = new Map(); @@ -381,83 +243,75 @@ function buildGraph( adj.get(src)!.add(dst); adj.get(dst)!.add(src); } } - // Segment connections into adjacency for (const { src, dst } of segConns) { if (adj.has(src) && adj.has(dst)) { adj.get(src)!.add(dst); adj.get(dst)!.add(src); } } + // Manual links also participate in layout so they don't stay visually orphaned + for (const ml of manualLinks) { + const src = String(ml.from_device_id); + const dst = String(ml.to_device_id); + if (adj.has(src) && adj.has(dst)) { + adj.get(src)!.add(dst); adj.get(dst)!.add(src); + } + } - // ── Root selection ────────────────────────────────────────────────────────── + // ── STP root detection ─────────────────────────────────────────────────────── const hasStp = links.some((l) => l.stp_role); - const deviceIds = new Set(devices.map((d) => String(d.id))); + const haveRootPort = new Set(links.filter((l) => l.stp_role === 'root').map((l) => String(l.from_device_id))); + const stpRootId = hasStp ? devices.map((d) => String(d.id)).find((id) => !haveRootPort.has(id)) ?? '' : ''; - // Pick the best root per connected component so forests lay out cleanly. function pickRoot(componentIds: string[]): string { - const stpRoot = hasStp - ? (() => { - const haveRootPort = new Set( - links.filter((l) => l.stp_role === 'root').map((l) => String(l.from_device_id)) - ); - return componentIds.find((id) => deviceIds.has(id) && !haveRootPort.has(id)); - })() - : undefined; - if (stpRoot) return stpRoot; - - const inThisComponent = new Set(componentIds); - // Prefer managed devices with the most in-component neighbors. - let best = componentIds[0]; - let bestScore = -1; + if (hasStp) { + const r = componentIds.find((id) => deviceIds.has(id) && !haveRootPort.has(id)); + if (r) return r; + } + const inComp = new Set(componentIds); + let best = componentIds[0], bestScore = -1; for (const id of componentIds) { - const neighborCount = [...(adj.get(id) || [])].filter((n) => inThisComponent.has(n)).length; - const score = (deviceIds.has(id) ? 1000 : 0) + neighborCount; - if (score > bestScore) { - best = id; - bestScore = score; - } + const n = [...(adj.get(id) || [])].filter((x) => inComp.has(x)).length; + const score = (deviceIds.has(id) ? 1000 : 0) + n; + if (score > bestScore) { best = id; bestScore = score; } } return best; } - // ── Connected components ──────────────────────────────────────────────────── + // ── Connected components ───────────────────────────────────────────────────── const allIds = [...devices.map((d) => String(d.id)), ...allExtNodes.map((e) => e.id)]; const visited = new Set(); const components: string[][] = []; for (const id of allIds) { if (visited.has(id)) continue; - const stack = [id]; - const comp: string[] = []; + const stack = [id], comp: string[] = []; while (stack.length) { const n = stack.pop()!; if (visited.has(n)) continue; - visited.add(n); - comp.push(n); + visited.add(n); comp.push(n); for (const m of adj.get(n) || []) if (!visited.has(m)) stack.push(m); } components.push(comp); } - // Biggest components first — nicer visual priority. components.sort((a, b) => b.length - a.length); - // ── Tidy-tree layout per component (Reingold-Tilford style) ───────────────── - // For each component we build a spanning tree via BFS from its root, then - // assign x positions bottom-up using subtree widths. Siblings are ordered to - // minimize edge crossings by placing each child near the barycenter of its - // own children (a simple but effective heuristic for mostly-tree graphs). + // Identify singleton device nodes (orphans) — separate them into an "orphan row" + const orphanIds = new Set( + components.filter((c) => c.length === 1 && deviceIds.has(c[0])).map((c) => c[0]) + ); + const connectedComponents = components.filter((c) => c.length > 1 || !deviceIds.has(c[0])); + const orphanComponents = components.filter((c) => c.length === 1 && deviceIds.has(c[0])); + // ── Tidy-tree layout ───────────────────────────────────────────────────────── const positions = new Map(); - const depthById = new Map(); - let componentOffsetX = 0; let globalMaxDepth = 0; - for (const comp of components) { + for (const comp of connectedComponents) { if (!comp.length) continue; const compSet = new Set(comp); - const rootId = pickRoot(comp); + const rootId = pickRoot(comp); - // BFS to build a spanning tree (depth + children) for this component. - const depth = new Map(); + const depth = new Map(); const children = new Map(); const bfs = [rootId]; depth.set(rootId, 0); @@ -473,28 +327,18 @@ function buildGraph( } } - // Order children: devices first, then by in-component degree desc, then by - // id for stability. This keeps higher-fanout subtrees centered. for (const kids of children.values()) { kids.sort((a, b) => { - const da = deviceIds.has(a) ? 0 : 1; - const db = deviceIds.has(b) ? 0 : 1; + const da = deviceIds.has(a) ? 0 : 1, db = deviceIds.has(b) ? 0 : 1; if (da !== db) return da - db; - const ga = (adj.get(a)?.size || 0); - const gb = (adj.get(b)?.size || 0); - if (gb !== ga) return gb - ga; - return a.localeCompare(b); + return (adj.get(b)?.size || 0) - (adj.get(a)?.size || 0); }); } - // Post-order: compute subtree "slot width" then assign x. const slotWidth = new Map(); function computeWidth(id: string): number { const kids = children.get(id) || []; - if (!kids.length) { - slotWidth.set(id, 1); - return 1; - } + if (!kids.length) { slotWidth.set(id, 1); return 1; } let total = 0; for (const k of kids) total += computeWidth(k); slotWidth.set(id, Math.max(1, total)); @@ -502,7 +346,6 @@ function buildGraph( } computeWidth(rootId); - // Assign x: each node gets centered over the span of its children. function assign(id: string, leftSlot: number): void { const kids = children.get(id) || []; if (!kids.length) { @@ -513,74 +356,51 @@ function buildGraph( return; } let cursor = leftSlot; - for (const k of kids) { - assign(k, cursor); - cursor += slotWidth.get(k)!; - } - // Center parent over its children's span. - const firstKid = positions.get(kids[0])!; - const lastKid = positions.get(kids[kids.length - 1])!; - positions.set(id, { - x: (firstKid.x + lastKid.x) / 2, - y: (depth.get(id) || 0) * (NODE_H + V_GAP), - }); + for (const k of kids) { assign(k, cursor); cursor += slotWidth.get(k)!; } + const fk = positions.get(kids[0])!, lk = positions.get(kids[kids.length - 1])!; + positions.set(id, { x: (fk.x + lk.x) / 2, y: (depth.get(id) || 0) * (NODE_H + V_GAP) }); } assign(rootId, 0); - // Track overall depth and advance the X offset for the next component. let compMaxDepth = 0; for (const id of comp) { const d = depth.get(id); - if (d !== undefined) depthById.set(id, d); - compMaxDepth = Math.max(compMaxDepth, depth.get(id) || 0); + if (d !== undefined) compMaxDepth = Math.max(compMaxDepth, d); } globalMaxDepth = Math.max(globalMaxDepth, compMaxDepth); - - const widthSlots = slotWidth.get(rootId) || 1; - componentOffsetX += widthSlots * NODE_SLOT + COMPONENT_GAP; + componentOffsetX += (slotWidth.get(rootId) || 1) * NODE_SLOT + COMPONENT_GAP; } - // Center the whole layout horizontally around x=0 for a tidier fitView. + // ── Orphan row at the bottom ───────────────────────────────────────────────── + const orphanY = (globalMaxDepth + 1) * (NODE_H + V_GAP) + ORPHAN_ROW_GAP; + let orphanX = 0; + for (const [oc] of orphanComponents.map((c) => [c[0]])) { + positions.set(oc, { x: orphanX, y: orphanY }); + orphanX += NODE_SLOT; + } + + // Center horizontally if (positions.size) { const xs = [...positions.values()].map((p) => p.x); - const centerShift = (Math.min(...xs) + Math.max(...xs)) / 2; - for (const [id, p] of positions) { - positions.set(id, { x: p.x - centerShift, y: p.y }); - } + const shift = (Math.min(...xs) + Math.max(...xs)) / 2; + for (const [id, p] of positions) positions.set(id, { x: p.x - shift, y: p.y }); } - // Optional strict row alignment; otherwise organic tree + overlap separation. - if (layout.alignByDepth) { - layoutAlignedRows(positions, depthById); - } else { - resolveOverlaps(positions); - } - - // The STP root badge should only be shown for true STP roots, not just the - // picked tree root of each component. - const stpRootId = hasStp - ? (() => { - const haveRootPort = new Set( - links.filter((l) => l.stp_role === 'root').map((l) => String(l.from_device_id)) - ); - return devices.map((d) => String(d.id)).find((id) => !haveRootPort.has(id)) ?? ''; - })() - : ''; + resolveOverlaps(positions); // ── React Flow nodes ───────────────────────────────────────────────────────── - const orphanY = (globalMaxDepth + 1) * (NODE_H + V_GAP); const nodes: Node[] = [ ...devices.map((d) => ({ id: String(d.id), type: 'deviceNode', - position: positions.get(String(d.id)) || { x: 0, y: 0 }, - data: { ...d, isRootBridge: hasStp && String(d.id) === stpRootId } as unknown as Record, + position: positions.get(String(d.id)) ?? { x: 0, y: 0 }, + data: { ...d, isRootBridge: hasStp && String(d.id) === stpRootId, connectMode, orphan: orphanIds.has(String(d.id)) } as unknown as Record, })), ...allExtNodes.map((e) => ({ id: e.id, type: 'externalNode', - position: positions.get(e.id) || { x: 0, y: orphanY }, - data: e as unknown as Record, + position: positions.get(e.id) ?? { x: 0, y: orphanY }, + data: { ...e, connectMode } as unknown as Record, })), ]; @@ -588,7 +408,6 @@ function buildGraph( const seen = new Set(); const edges: Edge[] = []; - // Direct links (LLDP + solo non-LLDP) for (const link of activeLinks) { if (!link.from_device_id) continue; const src = String(link.from_device_id); @@ -599,9 +418,7 @@ function buildGraph( if (seen.has(edgeKey)) continue; seen.add(edgeKey); - let stroke = '#94a3b8'; - let strokeDasharray: string | undefined; - let animated = false; + let stroke = '#94a3b8', strokeDasharray: string | undefined, animated = false; if (link.stp_role === 'root') { stroke = '#3b82f6'; animated = true; } else if (link.stp_role === 'designated') { stroke = '#22c55e'; } else if (link.stp_role === 'alternate' || link.stp_role === 'backup') { @@ -611,7 +428,7 @@ function buildGraph( const stpLabel = link.stp_role ? ` [${link.stp_role}]` : ''; const portLabel = link.from_interface ? (link.to_interface ? `${link.from_interface} ↔ ${link.to_interface}` : link.from_interface) - : ''; + : (link.to_interface ?? ''); edges.push({ id: `edge-${link.id}`, @@ -625,29 +442,58 @@ function buildGraph( }); } - // Shared-segment connection edges + // Shared-segment edges for (const { src, dst, port } of segConns) { const edgeKey = [src, dst].sort().join('--'); if (seen.has(edgeKey)) continue; seen.add(edgeKey); + if (!adj.has(src) || !adj.has(dst)) continue; edges.push({ id: `segedge-${src}-${dst}`, source: src, target: dst, label: port || undefined, - labelStyle: { fontSize: 10, fill: 'var(--topology-seg-edge-label, #b45309)' }, + labelStyle: { fontSize: 10, fill: '#b45309' }, className: 'topology-edge topology-edge--segment', style: { stroke: '#f59e0b', strokeWidth: 2, strokeDasharray: '5,3' }, animated: false, }); } + // Manual edges + for (const ml of manualLinks) { + const src = String(ml.from_device_id); + const dst = String(ml.to_device_id); + const edgeKey = [src, dst].sort().join('--'); + if (seen.has(edgeKey)) continue; + seen.add(edgeKey); + + // Find the real manual link id (positive int) for deletion + const realId = manualLinkIds.find( + (m) => (m.from_device_id === ml.from_device_id && m.to_device_id === ml.to_device_id) || + (m.from_device_id === ml.to_device_id && m.to_device_id === ml.from_device_id) + )?.id; + + edges.push({ + id: `manual-${src}-${dst}`, + source: src, + target: dst, + type: 'manualEdge', + markerEnd: { type: MarkerType.ArrowClosed, color: '#8b5cf6' }, + data: { onDelete: realId !== undefined ? () => onDeleteManual(String(realId)) : undefined }, + }); + } + return { nodes, edges }; } +// ─── Main page component ────────────────────────────────────────────────────── + export default function TopologyPage() { const theme = useThemeStore((s) => s.theme); - const [alignRows, setAlignRows] = useState(false); + const queryClient = useQueryClient(); + const [connectMode, setConnectMode] = useState(false); + const pendingEdgeRef = useRef<{ from_device_id: number; to_device_id: number } | null>(null); const { data, isLoading, refetch } = useQuery({ queryKey: ['topology'], @@ -659,6 +505,22 @@ export default function TopologyPage() { onSuccess: () => setTimeout(() => refetch(), 3000), }); + const createManualLinkMutation = useMutation({ + mutationFn: ({ from_device_id, to_device_id }: { from_device_id: number; to_device_id: number }) => + topologyApi.createManualLink(from_device_id, to_device_id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['topology'] }), + }); + + const deleteManualLinkMutation = useMutation({ + mutationFn: (id: number) => topologyApi.deleteManualLink(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['topology'] }), + }); + + const handleDeleteManual = useCallback((edgeId: string) => { + const id = parseInt(edgeId); + if (!isNaN(id)) deleteManualLinkMutation.mutate(id); + }, [deleteManualLinkMutation]); + const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); @@ -668,9 +530,12 @@ export default function TopologyPage() { (data.devices as TopologyDevice[]) || [], (data.externalNodes as ExternalTopologyNode[]) || [], (data.links as TopologyLink[]) || [], - { alignByDepth: alignRows } + (data.segConns as { src: string; dst: string; port: string }[]) || [], + (data.manualLinkIds as { id: number; from_device_id: number; to_device_id: number }[]) || [], + connectMode, + handleDeleteManual, ); - }, [data, alignRows]); + }, [data, connectMode, handleDeleteManual]); useEffect(() => { setNodes(graph.nodes); @@ -678,35 +543,62 @@ export default function TopologyPage() { }, [graph, setNodes, setEdges]); const onConnect = useCallback( - (connection: Connection) => setEdges((eds) => addEdge(connection, eds)), - [setEdges] + (connection: Connection) => { + const srcId = parseInt(connection.source ?? ''); + const dstId = parseInt(connection.target ?? ''); + if (isNaN(srcId) || isNaN(dstId) || srcId === dstId) return; + + // Optimistically add the edge so the user sees it immediately + setEdges((eds) => addEdge({ + ...connection, + id: `manual-${srcId}-${dstId}-pending`, + type: 'manualEdge', + markerEnd: { type: MarkerType.ArrowClosed, color: '#8b5cf6' }, + data: { onDelete: undefined }, + }, eds)); + + pendingEdgeRef.current = { from_device_id: srcId, to_device_id: dstId }; + createManualLinkMutation.mutate({ from_device_id: srcId, to_device_id: dstId }); + }, + [setEdges, createManualLinkMutation] ); if (isLoading) { - return ( -
Loading topology...
- ); + return
Loading topology…
; } const hasData = (data?.devices?.length ?? 0) > 0; - const hasStp = (data?.links as TopologyLink[])?.some((l) => l.stp_role); + const hasStp = (data?.links as TopologyLink[])?.some((l) => l.stp_role); + const links = (data?.links as TopologyLink[]) ?? []; + const orphanCount = (data?.devices?.length ?? 0) > 0 + ? (data!.devices as TopologyDevice[]).filter((d) => { + const id = String(d.id); + return !links.some((l) => String(l.from_device_id) === id || String(l.to_device_id) === id) && + !((data!.manualLinkIds ?? []) as { from_device_id: number; to_device_id: number }[]).some( + (m) => m.from_device_id === d.id || m.to_device_id === d.id + ); + }).length + : 0; return (
+ {/* Header */}

Network Topology

- +
+ {/* Connect mode hint */} + {connectMode && ( +
+ + Drag from any device's blue handle to another device to manually draw a connection. + Click the red × on a purple dashed edge to remove it. +
+ )} + + {/* Orphan warning */} + {orphanCount > 0 && !connectMode && ( +
+ + {orphanCount} device{orphanCount !== 1 ? 's have' : ' has'} no discovered connections. + Use Connect Mode to draw links manually, or run Discover to re-scan. +
+ )} + {!hasData ? (

No topology data yet

- Add devices and click "Discover" to map your network topology via LLDP neighbors. + Add devices and click "Discover" to map your network via LLDP neighbors.

-
) : ( <> -
+
@@ -772,12 +681,16 @@ export default function TopologyPage() {
- Direct link (LLDP) + LLDP (direct, point-to-point)
Shared segment (CDP/MNDP)
+
+
+ Manual link (user-drawn) +
{hasStp && ( <>
@@ -786,7 +699,7 @@ export default function TopologyPage() {
- Designated (active) + Designated port
@@ -802,20 +715,16 @@ export default function TopologyPage() {
External (unmanaged)
-
-
- Shared segment node -
)} - {/* Link table */} - {(data?.links?.length ?? 0) > 0 && ( + {/* Discovered links table */} + {links.filter((l) => l.link_type !== 'manual').length > 0 && (

- Discovered Links ({(data!.links as TopologyLink[]).length}) + Discovered Links ({links.filter((l) => l.link_type !== 'manual').length})

@@ -832,27 +741,16 @@ export default function TopologyPage() { - {(data!.links as TopologyLink[]).map((link) => ( + {links.filter((l) => l.link_type !== 'manual').map((link) => ( - - {link.from_device_name || '—'} - - - {link.from_interface || '—'} - - - {link.to_interface || '—'} - - - {link.to_device_name || link.neighbor_identity || '—'} - - - {link.neighbor_address || '—'} - + {link.from_device_name || '—'} + {link.from_interface || '—'} + {link.to_interface || '—'} + {link.to_device_name || link.neighbor_identity || '—'} + {link.neighbor_address || '—'} {link.link_type ? ( - {link.stp_role ? ( - ('/topology'), discover: () => api.post('/topology/discover'), + createManualLink: (from_device_id: number, to_device_id: number, label?: string) => + api.post<{ id: number; from_device_id: number; to_device_id: number; label: string | null }>( + '/topology/manual-links', { from_device_id, to_device_id, label } + ), + deleteManualLink: (id: number) => api.delete(`/topology/manual-links/${id}`), }; // ─── Search ────────────────────────────────────────────────────────────────── diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 8ad401a..21247b0 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.11.5 Beta'; +export const APP_VERSION = 'v0.11.6 Beta';