diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 9fc8fb9..1f97262 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -898,3 +898,65 @@ def get_segmentation(db: Annotated[Session, Depends(get_db)]) -> SegmentationOut ], recommendations=result.recommendations, ) + + +# ── /api/topology ────────────────────────────────────────────────────────────── + + +class TopologyNode(BaseModel): + id: int + ip_address: str + label: str | None + hostname: str | None + device_type: str + highest_severity: str | None # critical | high | medium | low | None + port_count: int + security_score: int + is_gateway: bool + + +@router.get("/topology", response_model=list[TopologyNode]) +def get_topology(db: Annotated[Session, Depends(get_db)]) -> list[TopologyNode]: + """Return all devices enriched for network topology visualisation.""" + from app.models.device import Device + + devices = ( + db.execute(select(Device).options(selectinload(Device.risks), selectinload(Device.ports))) + .scalars() + .all() + ) + + # Gateway detection: explicit router type, or first device whose IP ends in .1 + gateway_id: int | None = None + for d in devices: + if d.device_type == "router": + gateway_id = d.id + break + if gateway_id is None: + for d in devices: + if d.ip_address and d.ip_address.endswith(".1"): + gateway_id = d.id + break + + _severity_rank = {"critical": 4, "high": 3, "medium": 2, "low": 1} + + def _highest_severity(d) -> str | None: # noqa: ANN001 — SQLAlchemy instance + severities = [r.severity for r in d.risks if not r.acknowledged_at] + if not severities: + return None + return max(severities, key=lambda s: _severity_rank.get(s, 0)) + + return [ + TopologyNode( + id=d.id, + ip_address=d.ip_address, + label=d.label, + hostname=d.hostname, + device_type=d.device_type or "unknown", + highest_severity=_highest_severity(d), + port_count=len(d.ports), + security_score=_device_security_score(d), + is_gateway=(d.id == gateway_id), + ) + for d in devices + ] diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index de7c8bf..66d84c0 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -870,3 +870,65 @@ def test_segmentation_mixed_risk_pair_detected(client, db_engine): db.delete(srv) db.commit() db.close() + + +# ── /api/topology ────────────────────────────────────────────────────────────── + + +def test_topology_empty(client): + """GET /api/topology returns empty list when no devices.""" + resp = client.get("/api/topology") + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_topology_returns_nodes(client, seeded_db): + """GET /api/topology returns one node per device with required fields.""" + resp = client.get("/api/topology") + assert resp.status_code == 200 + nodes = resp.json() + assert len(nodes) >= 1 + node = nodes[0] + for field in ("id", "ip_address", "device_type", "port_count", "security_score", "is_gateway"): + assert field in node, f"missing field: {field}" + + +def test_topology_gateway_detection_by_ip(client, db_engine): + """GET /api/topology marks device ending in .1 as gateway.""" + from app.models.device import Device + + S = sessionmaker(bind=db_engine) # noqa: N806 -- uppercase matches SQLAlchemy Session convention + db = S() + gw = Device(ip_address="192.168.1.1", device_type="unknown") + other = Device(ip_address="192.168.1.50", device_type="workstation") + db.add_all([gw, other]) + db.commit() + + resp = client.get("/api/topology") + nodes = {n["ip_address"]: n for n in resp.json()} + assert nodes["192.168.1.1"]["is_gateway"] is True + assert nodes["192.168.1.50"]["is_gateway"] is False + + db.delete(gw) + db.delete(other) + db.commit() + db.close() + + +def test_topology_gateway_detection_by_type(client, db_engine): + """GET /api/topology marks device with device_type=router as gateway.""" + from app.models.device import Device + + S = sessionmaker(bind=db_engine) # noqa: N806 -- uppercase matches SQLAlchemy Session convention + db = S() + router = Device(ip_address="10.0.0.254", device_type="router") + db.add(router) + db.commit() + + resp = client.get("/api/topology") + nodes = {n["ip_address"]: n for n in resp.json()} + assert nodes["10.0.0.254"]["is_gateway"] is True + + db.delete(router) + db.commit() + db.close() diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 12970c5..17f76c6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "networkcrawler-frontend", "version": "0.1.0", "dependencies": { + "@xyflow/react": "^12.10.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^7.13.1" @@ -1790,6 +1791,55 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1815,14 +1865,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -2317,6 +2367,38 @@ "dev": true, "license": "MIT" }, + "node_modules/@xyflow/react": { + "version": "12.10.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz", + "integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.75", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.75", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz", + "integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2777,6 +2859,12 @@ "node": ">= 6" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -2929,9 +3017,114 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -6246,6 +6439,15 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -6657,6 +6859,34 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index d3d9bb6..179780a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "format": "prettier --write src" }, "dependencies": { + "@xyflow/react": "^12.10.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^7.13.1" diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 0b875ae..33d9efc 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -24,6 +24,7 @@ function copyViaExecCommand(text: string): void { const navLinks = [ { to: "/", label: "Dashboard", end: true }, { to: "/devices", label: "Devices", end: false }, + { to: "/topology", label: "Topology", end: false }, { to: "/risks", label: "Risks", end: false }, { to: "/changes", label: "Changes", end: false }, { to: "/history", label: "History", end: false }, diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 46ad787..49bd733 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -14,6 +14,7 @@ import { RecommendationsPage, RecommendationDetailPage, SettingsPage, + TopologyPage, } from "./pages"; ReactDOM.createRoot(document.getElementById("root")!).render( @@ -32,6 +33,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render( element={} /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/TopologyPage.tsx b/frontend/src/pages/TopologyPage.tsx new file mode 100644 index 0000000..8ebb7f4 --- /dev/null +++ b/frontend/src/pages/TopologyPage.tsx @@ -0,0 +1,298 @@ +/** + * TopologyPage — interactive network graph built with React Flow (@xyflow/react). + * Gateway node sits at centre; device nodes are arranged in a radial ring, + * colour-coded by highest active risk severity. Clicking a node navigates to + * the device detail page. + * Route: /topology + */ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Background, + Controls, + Handle, + Position, + ReactFlow, + useEdgesState, + useNodesState, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import type { TopologyNode } from "../types/api"; + +// ── Severity → border colour ────────────────────────────────────────────────── + +const SEVERITY_COLOUR: Record = { + critical: "var(--color-accent-danger)", + high: "var(--color-accent-warning)", + medium: "#eab308", + low: "var(--color-accent-positive)", +}; + +const DEVICE_TYPE_ICON: Record = { + router: "🌐", + server: "🖥️", + workstation: "💻", + iot: "📡", + unknown: "❓", +}; + +// ── Cluster angles by device type (degrees from top) ───────────────────────── + +const CLUSTER_BASE_ANGLE: Record = { + server: 0, + workstation: 90, + iot: 180, + unknown: 270, + router: 315, +}; + +// ── Custom node component ───────────────────────────────────────────────────── + +interface NodeData { + device: TopologyNode; + onClick: (id: number) => void; + [key: string]: unknown; +} + +function DeviceNode({ data }: { data: NodeData }) { + const { device, onClick } = data; + const borderColour = device.highest_severity + ? SEVERITY_COLOUR[device.highest_severity] + : "var(--color-accent-positive)"; + const isPulsing = + device.highest_severity === "critical" || + device.highest_severity === "high"; + const displayName = device.label ?? device.hostname ?? device.ip_address; + const icon = DEVICE_TYPE_ICON[device.device_type] ?? "❓"; + + return ( + <> + +
onClick(device.id)} + className={`cursor-pointer rounded-xl border-2 bg-[var(--color-surface)] p-3 shadow-md transition-transform hover:scale-105 hover:shadow-lg min-w-[120px] max-w-[160px]${isPulsing ? " animate-pulse" : ""}`} + style={{ borderColor: borderColour }} + title={`${displayName} — ${device.ip_address}`} + > +
+ +
+

+ {displayName} +

+

+ {device.ip_address} +

+
+
+ {device.highest_severity && ( +
+ {device.highest_severity} +
+ )} + {!device.highest_severity && ( +
+ clean +
+ )} +
+ + + ); +} + +const NODE_TYPES = { device: DeviceNode }; + +// ── Layout helpers ──────────────────────────────────────────────────────────── + +const GATEWAY_POS = { x: 400, y: 350 }; +const RING_RADIUS = 280; + +function buildLayout(nodes: TopologyNode[]) { + const gateway = nodes.find((n) => n.is_gateway); + const others = nodes.filter((n) => !n.is_gateway); + + // Group others by device_type + const groups: Record = {}; + for (const n of others) { + const key = n.device_type ?? "unknown"; + (groups[key] ??= []).push(n); + } + + const positions: Record = {}; + if (gateway) { + positions[gateway.id] = GATEWAY_POS; + } + + // Evenly space all non-gateway nodes around the ring + others.forEach((node) => { + // Start offset based on cluster, then spread evenly + const baseAngle = CLUSTER_BASE_ANGLE[node.device_type ?? "unknown"] ?? 270; + const clusterNodes = groups[node.device_type ?? "unknown"] ?? []; + const clusterIdx = clusterNodes.indexOf(node); + const clusterSize = clusterNodes.length; + // Spread cluster ±30° around its base angle + const spread = clusterSize > 1 ? 60 / (clusterSize - 1) : 0; + const angleDeg = baseAngle - 30 + spread * clusterIdx; + const angleRad = ((angleDeg - 90) * Math.PI) / 180; + positions[node.id] = { + x: GATEWAY_POS.x + RING_RADIUS * Math.cos(angleRad) - 60, + y: GATEWAY_POS.y + RING_RADIUS * Math.sin(angleRad) - 40, + }; + }); + + return positions; +} + +// ── Main component ──────────────────────────────────────────────────────────── + +export function TopologyPage() { + const navigate = useNavigate(); + const [topology, setTopology] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetch("/api/topology") + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json() as Promise; + }) + .then((data) => { + setTopology(data); + setLoading(false); + }) + .catch((e: Error) => { + setError(e.message); + setLoading(false); + }); + }, []); + + const handleNodeClick = useCallback( + (id: number) => navigate(`/devices/${id}`), + [navigate], + ); + + const positions = useMemo(() => buildLayout(topology), [topology]); + + const initialNodes = useMemo( + () => + topology.map((device) => ({ + id: String(device.id), + type: "device", + position: positions[device.id] ?? { x: 0, y: 0 }, + data: { device, onClick: handleNodeClick } satisfies NodeData, + })), + [topology, positions, handleNodeClick], + ); + + const gateway = topology.find((n) => n.is_gateway); + const initialEdges = useMemo( + () => + gateway + ? topology + .filter((n) => !n.is_gateway) + .map((n) => ({ + id: `e-${gateway.id}-${n.id}`, + source: String(gateway.id), + target: String(n.id), + style: { + stroke: "var(--color-border)", + strokeWidth: 1.5, + }, + })) + : [], + [topology, gateway], + ); + + const [rfNodes, , onNodesChange] = useNodesState(initialNodes); + const [rfEdges, , onEdgesChange] = useEdgesState(initialEdges); + + // Sync when topology loads + const [synced, setSynced] = useState(false); + useEffect(() => { + if (topology.length > 0 && !synced) { + setSynced(true); + } + }, [topology, synced]); + + if (loading) { + return ( +
+ Loading topology… +
+ ); + } + + if (error) { + return ( +
+ Failed to load topology: {error} +
+ ); + } + + if (topology.length === 0) { + return ( +
+ No devices found. Run a scan first. +
+ ); + } + + return ( +
+
+
+

+ Network Topology +

+

+ {topology.length} device{topology.length !== 1 ? "s" : ""} — click a + node to view details +

+
+
+ {(["critical", "high", "medium", "low"] as const).map((s) => ( + + + {s} + + ))} + + + clean + +
+
+
+ + + + +
+
+ ); +} diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index f27ffde..4a12942 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -9,3 +9,4 @@ export { RecommendationsPage } from "./RecommendationsPage"; export { RecommendationDetailPage } from "./RecommendationDetailPage"; export { SettingsPage } from "./SettingsPage"; export { ChangesPage } from "./ChangesPage"; +export { TopologyPage } from "./TopologyPage"; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 175ca5a..efd5cc6 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -165,3 +165,15 @@ export interface SegmentationInsight { mixed_risk_pairs: MixedRiskPair[]; recommendations: string[]; } + +export interface TopologyNode { + id: number; + ip_address: string; + label: string | null; + hostname: string | null; + device_type: DeviceType; + highest_severity: Severity | null; + port_count: number; + security_score: number; + is_gateway: boolean; +} diff --git a/frontend/tests/pages.test.tsx b/frontend/tests/pages.test.tsx index 48641c6..faa9cb9 100644 --- a/frontend/tests/pages.test.tsx +++ b/frontend/tests/pages.test.tsx @@ -112,6 +112,7 @@ function buildFetch(overrides: Record = {}) { mixed_risk_pairs: [], recommendations: [], }, + "/api/topology": [], ...overrides, }; // match base path for parameterised URLs @@ -412,3 +413,62 @@ describe("RisksPage", () => { ).toBeInTheDocument(); }); }); + +// ── TopologyPage ────────────────────────────────────────────────────────────── + +import { TopologyPage } from "../src/pages/TopologyPage"; + +describe("TopologyPage", () => { + it("shows empty state when no devices", async () => { + vi.stubGlobal("fetch", buildFetch({ "/api/topology": [] })); + render( + + + , + ); + await waitFor(() => + expect( + screen.getByText(/no devices found/i), + ).toBeInTheDocument(), + ); + }); + + it("shows topology canvas when devices are present", async () => { + const nodes = [ + { + id: 1, + ip_address: "192.168.1.1", + label: null, + hostname: "router", + device_type: "router", + highest_severity: null, + port_count: 1, + security_score: 100, + is_gateway: true, + }, + { + id: 2, + ip_address: "192.168.1.10", + label: "my-pc", + hostname: null, + device_type: "workstation", + highest_severity: "high", + port_count: 3, + security_score: 70, + is_gateway: false, + }, + ]; + vi.stubGlobal("fetch", buildFetch({ "/api/topology": nodes })); + render( + + + , + ); + await waitFor(() => + expect( + screen.getByText(/network topology/i), + ).toBeInTheDocument(), + ); + expect(screen.getByText(/2 device/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/setup.ts b/frontend/tests/setup.ts index d0de870..772ffed 100644 --- a/frontend/tests/setup.ts +++ b/frontend/tests/setup.ts @@ -1 +1,8 @@ import "@testing-library/jest-dom"; + +// React Flow requires ResizeObserver — polyfill for jsdom +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +};