diff --git a/backend/src/api/analysis_api.py b/backend/src/api/analysis_api.py index 2f38cdf..e52fc1f 100644 --- a/backend/src/api/analysis_api.py +++ b/backend/src/api/analysis_api.py @@ -20,11 +20,30 @@ class InterfaceHostEvidence(BaseModel): destination_on_egress_count: int = Field(..., description="Packets where this endpoint appeared as the destination on egress.") +class ProtocolEvidence(BaseModel): + protocol: str = Field(..., description="Detected application or fallback transport/network protocol.") + packet_count: int = Field(..., description="Packet observations supporting this interface-host-protocol mapping.") + last_seen: datetime = Field(..., description="Most recent packet timestamp supporting this protocol mapping.") + accept_count: int = Field(0, description="Packets with verdict=accept for this protocol.") + drop_count: int = Field(0, description="Packets with verdict=drop for this protocol.") + reject_count: int = Field(0, description="Packets with verdict=reject for this protocol.") + unknown_count: int = Field(0, description="Packets with verdict pending/unknown or without a verdict.") + + +class InterfaceHostProtocolEvidence(InterfaceHostEvidence): + protocols: List[ProtocolEvidence] = Field(default_factory=list, description="Protocols observed for this host on the interface.") + + class InterfaceAttachment(BaseModel): interface: str = Field(..., description="MITM machine interface name.") hosts: List[InterfaceHostEvidence] = Field(default_factory=list, description="Endpoints inferred to be attached to this interface.") +class InterfaceProtocolAttachment(BaseModel): + interface: str = Field(..., description="MITM machine interface name.") + hosts: List[InterfaceHostProtocolEvidence] = Field(default_factory=list, description="Endpoints inferred to be attached to this interface, with protocol breakdown.") + + class InterfaceHostAnalysisResponse(BaseModel): since: Optional[datetime] = Field(None, description="Only packets at or after this timestamp were analyzed.") interfaces: List[InterfaceAttachment] = Field(default_factory=list) @@ -37,6 +56,18 @@ class InterfaceHostAnalysisResponse(BaseModel): ) +class InterfaceHostProtocolAnalysisResponse(BaseModel): + since: Optional[datetime] = Field(None, description="Only packets at or after this timestamp were analyzed.") + interfaces: List[InterfaceProtocolAttachment] = Field(default_factory=list) + notes: List[str] = Field( + default_factory=lambda: [ + "This is an inference from observed packet direction, not a kernel neighbor-table lookup.", + "Each host can carry multiple protocols; protocols prefer app_protocol and fall back to lower-layer protocol names.", + "Verdict counts are packet counts grouped per interface, host, and protocol.", + ] + ) + + @router.get("/interface-hosts", response_model=InterfaceHostAnalysisResponse) async def analysis_interface_hosts( since_minutes: Optional[int] = Query( @@ -68,3 +99,46 @@ async def analysis_interface_hosts( interfaces = [InterfaceAttachment(**row) for row in rows] return InterfaceHostAnalysisResponse(since=since, interfaces=interfaces) + + +@router.get("/interface-host-protocols", response_model=InterfaceHostProtocolAnalysisResponse) +async def analysis_interface_host_protocols( + since_minutes: Optional[int] = Query( + 60, + ge=1, + le=60 * 24 * 30, + description="Analyze only packets seen within the last N minutes. Set to a large value to cover more history.", + ), + limit_per_interface: int = Query( + 50, + ge=1, + le=1000, + description="Maximum number of inferred hosts returned per interface.", + ), + limit_protocols_per_host: int = Query( + 12, + ge=1, + le=100, + description="Maximum number of top protocols returned per inferred host.", + ), +) -> InterfaceHostProtocolAnalysisResponse: + """Infer interface-host attachment and break observed traffic down by protocol.""" + db = shared.db + if db is None: + raise HTTPException(status_code=503, detail="Database not available") + + since: Optional[datetime] = None + if since_minutes is not None: + since = datetime.now(timezone.utc) - timedelta(minutes=since_minutes) + + try: + rows = await db.infer_interface_host_protocols( + since=since, + limit_per_interface=limit_per_interface, + limit_protocols_per_host=limit_protocols_per_host, + ) + except Exception as exc: + raise HTTPException(status_code=500, detail="Failed to infer interface host protocol mapping") from exc + + interfaces = [InterfaceProtocolAttachment(**row) for row in rows] + return InterfaceHostProtocolAnalysisResponse(since=since, interfaces=interfaces) diff --git a/backend/src/utilities/database.py b/backend/src/utilities/database.py index 23d6e1c..bff624a 100644 --- a/backend/src/utilities/database.py +++ b/backend/src/utilities/database.py @@ -733,6 +733,197 @@ class DatabasePool: for iface, hosts in sorted(grouped.items()) ] + async def infer_interface_host_protocols( + self, + *, + since: Optional[datetime] = None, + limit_per_interface: int = 50, + limit_protocols_per_host: int = 12, + ) -> List[Dict[str, Any]]: + """Infer interface-host attachment and aggregate observed protocols and verdicts.""" + if self._pool is None: + await self.init_pool() + + async with self._pool.acquire() as conn: + rows = await conn.fetch( + """ + WITH observations AS ( + SELECT + ingress_if AS iface, + src_ip::text AS ip_address, + src_mac::text AS mac_address, + COALESCE(NULLIF(app_protocol, ''), NULLIF(app_master_protocol, ''), NULLIF(ip_proto, ''), NULLIF(eth_type, ''), 'UNKNOWN') AS protocol_name, + COALESCE(NULLIF(verdict, ''), 'unknown') AS verdict_name, + timestamp, + 'source_on_ingress' AS evidence + FROM packets + WHERE ingress_if IS NOT NULL + AND (src_ip IS NOT NULL OR src_mac IS NOT NULL) + AND ($1::timestamptz IS NULL OR timestamp >= $1) + + UNION ALL + + SELECT + egress_if AS iface, + dst_ip::text AS ip_address, + dst_mac::text AS mac_address, + COALESCE(NULLIF(app_protocol, ''), NULLIF(app_master_protocol, ''), NULLIF(ip_proto, ''), NULLIF(eth_type, ''), 'UNKNOWN') AS protocol_name, + COALESCE(NULLIF(verdict, ''), 'unknown') AS verdict_name, + timestamp, + 'destination_on_egress' AS evidence + FROM packets + WHERE egress_if IS NOT NULL + AND (dst_ip IS NOT NULL OR dst_mac IS NOT NULL) + AND ($1::timestamptz IS NULL OR timestamp >= $1) + ), + filtered AS ( + SELECT * + FROM observations + WHERE iface IS NOT NULL + AND COALESCE(mac_address, '') <> 'ff:ff:ff:ff:ff:ff' + AND ( + COALESCE(ip_address, '') <> '' + OR COALESCE(mac_address, '') <> '' + ) + ), + host_aggregated AS ( + SELECT + iface, + ip_address, + mac_address, + COUNT(*) AS packet_count, + MAX(timestamp) AS last_seen, + SUM(CASE WHEN evidence = 'source_on_ingress' THEN 1 ELSE 0 END) AS source_on_ingress_count, + SUM(CASE WHEN evidence = 'destination_on_egress' THEN 1 ELSE 0 END) AS destination_on_egress_count + FROM filtered + GROUP BY iface, ip_address, mac_address + ), + selected_hosts AS ( + SELECT * + FROM ( + SELECT + *, + ROW_NUMBER() OVER ( + PARTITION BY iface + ORDER BY packet_count DESC, last_seen DESC, ip_address, mac_address + ) AS row_num + FROM host_aggregated + ) ranked_hosts + WHERE row_num <= $2 + ), + protocol_aggregated AS ( + SELECT + filtered.iface, + filtered.ip_address, + filtered.mac_address, + filtered.protocol_name, + COUNT(*) AS packet_count, + MAX(filtered.timestamp) AS last_seen, + SUM(CASE WHEN filtered.verdict_name = 'accept' THEN 1 ELSE 0 END) AS accept_count, + SUM(CASE WHEN filtered.verdict_name = 'drop' THEN 1 ELSE 0 END) AS drop_count, + SUM(CASE WHEN filtered.verdict_name = 'reject' THEN 1 ELSE 0 END) AS reject_count, + SUM(CASE WHEN filtered.verdict_name NOT IN ('accept', 'drop', 'reject') THEN 1 ELSE 0 END) AS unknown_count + FROM filtered + INNER JOIN selected_hosts + ON selected_hosts.iface = filtered.iface + AND selected_hosts.ip_address IS NOT DISTINCT FROM filtered.ip_address + AND selected_hosts.mac_address IS NOT DISTINCT FROM filtered.mac_address + GROUP BY filtered.iface, filtered.ip_address, filtered.mac_address, filtered.protocol_name + ), + ranked_protocols AS ( + SELECT * + FROM ( + SELECT + *, + ROW_NUMBER() OVER ( + PARTITION BY iface, ip_address, mac_address + ORDER BY packet_count DESC, last_seen DESC, protocol_name + ) AS row_num + FROM protocol_aggregated + ) ranked + WHERE row_num <= $3 + ) + SELECT + selected_hosts.iface, + selected_hosts.ip_address, + selected_hosts.mac_address, + selected_hosts.packet_count AS host_packet_count, + selected_hosts.last_seen AS host_last_seen, + selected_hosts.source_on_ingress_count, + selected_hosts.destination_on_egress_count, + ranked_protocols.protocol_name, + ranked_protocols.packet_count AS protocol_packet_count, + ranked_protocols.last_seen AS protocol_last_seen, + ranked_protocols.accept_count, + ranked_protocols.drop_count, + ranked_protocols.reject_count, + ranked_protocols.unknown_count + FROM selected_hosts + LEFT JOIN ranked_protocols + ON ranked_protocols.iface = selected_hosts.iface + AND ranked_protocols.ip_address IS NOT DISTINCT FROM selected_hosts.ip_address + AND ranked_protocols.mac_address IS NOT DISTINCT FROM selected_hosts.mac_address + ORDER BY + selected_hosts.iface, + selected_hosts.packet_count DESC, + selected_hosts.last_seen DESC, + selected_hosts.ip_address, + selected_hosts.mac_address, + ranked_protocols.packet_count DESC NULLS LAST, + ranked_protocols.last_seen DESC NULLS LAST, + ranked_protocols.protocol_name + """, + since, + limit_per_interface, + limit_protocols_per_host, + ) + + grouped: Dict[str, Dict[str, Dict[str, Any]]] = {} + for row in rows: + record = dict(row) + iface = str(record["iface"]) + host_key = f"{record.get('ip_address') or 'no-ip'}|{record.get('mac_address') or 'no-mac'}" + + iface_hosts = grouped.setdefault(iface, {}) + host_record = iface_hosts.get(host_key) + if host_record is None: + host_record = { + "ip_address": record.get("ip_address"), + "mac_address": record.get("mac_address"), + "packet_count": int(record.get("host_packet_count") or 0), + "last_seen": record["host_last_seen"].isoformat() if hasattr(record.get("host_last_seen"), "isoformat") else record.get("host_last_seen"), + "source_on_ingress_count": int(record.get("source_on_ingress_count") or 0), + "destination_on_egress_count": int(record.get("destination_on_egress_count") or 0), + "protocols": [], + } + iface_hosts[host_key] = host_record + + protocol_name = record.get("protocol_name") + if protocol_name not in (None, ""): + host_record["protocols"].append( + { + "protocol": str(protocol_name), + "packet_count": int(record.get("protocol_packet_count") or 0), + "last_seen": record["protocol_last_seen"].isoformat() + if hasattr(record.get("protocol_last_seen"), "isoformat") + else record.get("protocol_last_seen"), + "accept_count": int(record.get("accept_count") or 0), + "drop_count": int(record.get("drop_count") or 0), + "reject_count": int(record.get("reject_count") or 0), + "unknown_count": int(record.get("unknown_count") or 0), + } + ) + + result: List[Dict[str, Any]] = [] + for iface, hosts in sorted(grouped.items()): + sorted_hosts = sorted( + hosts.values(), + key=lambda item: (-int(item.get("packet_count") or 0), str(item.get("last_seen") or ""), str(item.get("ip_address") or ""), str(item.get("mac_address") or "")), + ) + result.append({"interface": iface, "hosts": sorted_hosts}) + + return result + async def clear_all_packets(self, reset_identity: bool = True) -> bool: """Truncate the packet table and optionally reset identity counters.""" if self._pool is None: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4f6f185..9b4d4f2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,8 +11,12 @@ "@ant-design/icons": "^6.1.0", "@tanstack/react-query": "^5.90.12", "@tanstack/react-query-devtools": "^5.91.1", + "@types/d3": "^7.4.3", + "@types/d3-sankey": "^0.12.5", "antd": "^6.0.0", "axios": "^1.13.2", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", "prismjs": "^1.30.0", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -2280,6 +2284,283 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "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-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "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-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "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-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-sankey": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.12.5.tgz", + "integrity": "sha512-/3RZSew0cLAtzGQ+C89hq/Rp3H20QJuVRSqFy6RKLe7E0B8kd2iOS1oBsodrgds4PcNVpqWhdUEng/SHvBcJ6Q==", + "license": "MIT", + "dependencies": { + "@types/d3-shape": "^1" + } + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "license": "MIT" + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "^1" + } + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "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-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "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", @@ -2287,6 +2568,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/history": { "version": "4.7.11", "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", @@ -2885,6 +3172,15 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/compute-scroll-into-view": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", @@ -2970,6 +3266,448 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "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-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "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-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "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-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "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-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "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", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "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-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3092,6 +3830,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -4077,9 +4824,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -4153,6 +4898,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5409,6 +6163,12 @@ "node": ">=4" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.53.5", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz", @@ -5451,6 +6211,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -5510,9 +6276,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/sax": { "version": "1.4.3", diff --git a/frontend/package.json b/frontend/package.json index 4f2f4fd..1875c1e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,8 +15,12 @@ "@ant-design/icons": "^6.1.0", "@tanstack/react-query": "^5.90.12", "@tanstack/react-query-devtools": "^5.91.1", + "@types/d3": "^7.4.3", + "@types/d3-sankey": "^0.12.5", "antd": "^6.0.0", "axios": "^1.13.2", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", "prismjs": "^1.30.0", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/frontend/src/Layout/Sidebar.tsx b/frontend/src/Layout/Sidebar.tsx index 6d165bf..17949c1 100644 --- a/frontend/src/Layout/Sidebar.tsx +++ b/frontend/src/Layout/Sidebar.tsx @@ -1,6 +1,7 @@ // src/components/Sidebar.tsx import { ApartmentOutlined, + ClusterOutlined, HomeOutlined, InfoCircleOutlined, MonitorOutlined, @@ -19,6 +20,7 @@ const { Sider } = Layout; const menuItems = [ { key: PATHS.HOME, icon: , label: 'Home' }, { key: PATHS.NETWORK, icon: , label: 'Network' }, + { key: PATHS.ANALYSIS, icon: , label: 'Analysis' }, { key: PATHS.SNIFFING, icon: , label: 'Sniffing' }, { key: PATHS.SCRIPTING, diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 82dfe1a..f0ca022 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -1,5 +1,6 @@ import axios from 'axios'; +import { InterfaceHostAnalysisResponse, InterfaceHostProtocolAnalysisResponse } from '../types/analysis'; import { CreateRuleRequest, ExecResult, RulesetModel } from '../types/firewall'; import { BridgeCreateRequest, @@ -172,6 +173,34 @@ export const clearPackets = async (): Promise => { return res.data; }; +export const fetchInterfaceHostAnalysis = async ( + sinceMinutes = 60, + limitPerInterface = 100, +): Promise => { + const res = await api.get('/analysis/interface-hosts', { + params: { + since_minutes: sinceMinutes, + limit_per_interface: limitPerInterface, + }, + }); + return res.data; +}; + +export const fetchInterfaceHostProtocolAnalysis = async ( + sinceMinutes = 60, + limitPerInterface = 50, + limitProtocolsPerHost = 12, +): Promise => { + const res = await api.get('/analysis/interface-host-protocols', { + params: { + since_minutes: sinceMinutes, + limit_per_interface: limitPerInterface, + limit_protocols_per_host: limitProtocolsPerHost, + }, + }); + return res.data; +}; + export const fetchRuleset = async (): Promise<{ ruleset: RulesetModel }> => { const res = await api.get<{ ruleset: RulesetModel }>('/firewall/rules'); return res.data; diff --git a/frontend/src/appRouter.tsx b/frontend/src/appRouter.tsx index 46ef434..c6e41bf 100644 --- a/frontend/src/appRouter.tsx +++ b/frontend/src/appRouter.tsx @@ -1,6 +1,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import App from './App'; +import Analysis from './pages/Analysis'; import { Firewall } from './pages/Firewall'; import Home from './pages/Home'; import Network from './pages/Network'; @@ -24,6 +25,7 @@ export default function AppRouter() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/Analysis.tsx b/frontend/src/pages/Analysis.tsx new file mode 100644 index 0000000..5611342 --- /dev/null +++ b/frontend/src/pages/Analysis.tsx @@ -0,0 +1,725 @@ +import { ReloadOutlined } from '@ant-design/icons'; +import { + Alert, + Button, + Card, + Col, + Empty, + InputNumber, + Row, + Space, + Spin, + Table, + Tabs, + Tag, + Typography, + message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import * as d3 from 'd3'; +import { sankey as d3Sankey, sankeyLinkHorizontal, type SankeyGraph, type SankeyLink, type SankeyNode } from 'd3-sankey'; +import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { fetchInterfaceHostProtocolAnalysis } from '../api/apiClient'; +import type { + InterfaceHostProtocolAnalysisResponse, + InterfaceHostProtocolEvidence, + InterfaceProtocolAttachment, + ProtocolEvidence, +} from '../types/analysis'; + +const { Title, Text, Paragraph } = Typography; + +type GraphNodeKind = 'interface' | 'host' | 'protocol'; + +type TopologyNode = { + id: string; + label: string; + kind: GraphNodeKind; + packetCount: number; + interfaceName?: string; + ipAddress?: string | null; + macAddress?: string | null; + protocol?: string; +}; + +type TopologyLink = { + source: string; + target: string; + value: number; + label: string; +}; + +type HeatmapRow = { + hostId: string; + hostLabel: string; + interfaceName: string; + values: Record; +}; + +type ProtocolTableRow = { + key: string; + interface: string; + ip_address?: string | null; + mac_address?: string | null; + host_packet_count: number; + protocol: string; + protocol_packet_count: number; + accept_count: number; + drop_count: number; + reject_count: number; + unknown_count: number; + last_seen: string; +}; + +type TopologyData = { + nodes: TopologyNode[]; + links: TopologyLink[]; + heatmapRows: HeatmapRow[]; + protocols: string[]; + tableRows: ProtocolTableRow[]; +}; + +type ForceNode = d3.SimulationNodeDatum & TopologyNode; +type ForceLink = d3.SimulationLinkDatum & TopologyLink; + +type SankeyNodeDatum = SankeyNode & TopologyNode; +type SankeyLinkDatum = SankeyLink & TopologyLink; + +function formatTimestamp(value?: string | null) { + if (!value) return '-'; + try { + const date = new Date(value); + return ( + date.toLocaleString('de-DE', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + `.${String(date.getMilliseconds()).padStart(3, '0')}` + ); + } catch { + return value; + } +} + +function hostIdentity(host: InterfaceHostProtocolEvidence) { + return `${host.ip_address ?? 'no-ip'}|${host.mac_address ?? 'no-mac'}`; +} + +function hostLabel(interfaceName: string, host: InterfaceHostProtocolEvidence) { + const ip = host.ip_address ?? 'unknown ip'; + const mac = host.mac_address ?? 'unknown mac'; + return `${interfaceName} • ${ip}\n${mac}`; +} + +function protocolColor(protocol: string) { + const palette = d3.schemeTableau10; + let hash = 0; + for (let index = 0; index < protocol.length; index += 1) { + hash = (hash * 31 + protocol.charCodeAt(index)) >>> 0; + } + return palette[hash % palette.length]; +} + +function buildTopologyData(interfaces: InterfaceProtocolAttachment[]): TopologyData { + const nodes = new Map(); + const links = new Map(); + const heatmapByHost = new Map(); + const protocols = new Set(); + const tableRows: ProtocolTableRow[] = []; + + for (const entry of interfaces) { + const interfaceNodeId = `iface:${entry.interface}`; + nodes.set(interfaceNodeId, { + id: interfaceNodeId, + label: entry.interface, + kind: 'interface', + packetCount: entry.hosts.reduce((sum, host) => sum + host.packet_count, 0), + interfaceName: entry.interface, + }); + + for (const host of entry.hosts) { + const hostId = `host:${entry.interface}:${hostIdentity(host)}`; + nodes.set(hostId, { + id: hostId, + label: hostLabel(entry.interface, host), + kind: 'host', + packetCount: host.packet_count, + interfaceName: entry.interface, + ipAddress: host.ip_address, + macAddress: host.mac_address, + }); + + const interfaceHostLinkId = `${interfaceNodeId}->${hostId}`; + links.set(interfaceHostLinkId, { + source: interfaceNodeId, + target: hostId, + value: host.packet_count, + label: `${entry.interface} -> ${host.ip_address ?? host.mac_address ?? 'host'} (${host.packet_count})`, + }); + + const heatmapRow: HeatmapRow = { + hostId, + hostLabel: `${entry.interface} • ${host.ip_address ?? 'unknown ip'}`, + interfaceName: entry.interface, + values: {}, + }; + + for (const protocol of host.protocols) { + const protocolId = `protocol:${protocol.protocol}`; + protocols.add(protocol.protocol); + if (!nodes.has(protocolId)) { + nodes.set(protocolId, { + id: protocolId, + label: protocol.protocol, + kind: 'protocol', + packetCount: 0, + protocol: protocol.protocol, + }); + } + const existingProtocolNode = nodes.get(protocolId)!; + existingProtocolNode.packetCount += protocol.packet_count; + + const hostProtocolLinkId = `${hostId}->${protocolId}`; + links.set(hostProtocolLinkId, { + source: hostId, + target: protocolId, + value: protocol.packet_count, + label: `${host.ip_address ?? host.mac_address ?? 'host'} -> ${protocol.protocol} (${protocol.packet_count})`, + }); + + heatmapRow.values[protocol.protocol] = protocol.packet_count; + + tableRows.push({ + key: `${entry.interface}-${hostIdentity(host)}-${protocol.protocol}`, + interface: entry.interface, + ip_address: host.ip_address, + mac_address: host.mac_address, + host_packet_count: host.packet_count, + protocol: protocol.protocol, + protocol_packet_count: protocol.packet_count, + accept_count: protocol.accept_count, + drop_count: protocol.drop_count, + reject_count: protocol.reject_count, + unknown_count: protocol.unknown_count, + last_seen: protocol.last_seen, + }); + } + + heatmapByHost.set(hostId, heatmapRow); + } + } + + return { + nodes: Array.from(nodes.values()), + links: Array.from(links.values()), + heatmapRows: Array.from(heatmapByHost.values()).sort((left, right) => left.hostLabel.localeCompare(right.hostLabel)), + protocols: Array.from(protocols).sort(), + tableRows: tableRows.sort((left, right) => right.protocol_packet_count - left.protocol_packet_count || left.interface.localeCompare(right.interface)), + }; +} + +function SankeyTopology({ data }: { data: TopologyData }) { + const svgRef = useRef(null); + + useEffect(() => { + if (!svgRef.current) return; + + const width = 1160; + const height = Math.max(420, data.nodes.length * 26); + const svg = d3.select(svgRef.current); + svg.selectAll('*').remove(); + svg.attr('viewBox', `0 0 ${width} ${height}`); + + if (data.nodes.length === 0 || data.links.length === 0) { + return; + } + + const graph: SankeyGraph = { + nodes: data.nodes.map((node) => ({ ...node })), + links: data.links.map((link) => ({ ...link })), + }; + + const sankeyLayout = d3Sankey() + .nodeId((node) => node.id) + .nodeWidth(18) + .nodePadding(16) + .extent([ + [24, 28], + [width - 24, height - 28], + ]); + + const layout = sankeyLayout(graph); + + const defs = svg.append('defs'); + defs + .append('marker') + .attr('id', 'sankey-arrow') + .attr('markerWidth', 10) + .attr('markerHeight', 10) + .attr('refX', 8) + .attr('refY', 5) + .attr('orient', 'auto') + .append('path') + .attr('d', 'M0,0 L10,5 L0,10 z') + .attr('fill', '#8394a7'); + + svg.append('rect').attr('x', 0).attr('y', 0).attr('width', width).attr('height', height).attr('rx', 18).attr('fill', '#f8fbff'); + + const linkLayer = svg.append('g').attr('fill', 'none').attr('stroke-opacity', 0.35); + linkLayer + .selectAll('path') + .data(layout.links as SankeyLinkDatum[]) + .join('path') + .attr('d', sankeyLinkHorizontal()) + .attr('stroke', (link) => { + const target = link.target as SankeyNodeDatum; + if (target.kind === 'protocol' && target.protocol) { + return protocolColor(target.protocol); + } + return '#7f8ea3'; + }) + .attr('stroke-width', (link) => Math.max(1, link.width || 1)) + .attr('marker-end', 'url(#sankey-arrow)') + .append('title') + .text((link) => `${link.label}\nPackets: ${link.value}`); + + const nodeLayer = svg.append('g'); + const node = nodeLayer + .selectAll('g') + .data(layout.nodes as SankeyNodeDatum[]) + .join('g'); + + node + .append('rect') + .attr('x', (d) => d.x0 ?? 0) + .attr('y', (d) => d.y0 ?? 0) + .attr('width', (d) => (d.x1 ?? 0) - (d.x0 ?? 0)) + .attr('height', (d) => Math.max(8, (d.y1 ?? 0) - (d.y0 ?? 0))) + .attr('rx', 8) + .attr('fill', (d) => { + if (d.kind === 'interface') return '#20405d'; + if (d.kind === 'host') return '#d7e7f5'; + return d.protocol ? protocolColor(d.protocol) : '#d8d8d8'; + }) + .attr('stroke', (d) => (d.kind === 'host' ? '#9bb8d6' : 'none')) + .append('title') + .text((d) => `${d.label}\nPackets: ${d.value ?? d.packetCount}`); + + node + .append('text') + .attr('x', (d) => ((d.x0 ?? 0) < 360 ? (d.x1 ?? 0) + 8 : (d.x0 ?? 0) - 8)) + .attr('y', (d) => ((d.y0 ?? 0) + (d.y1 ?? 0)) / 2) + .attr('dy', '0.35em') + .attr('text-anchor', (d) => ((d.x0 ?? 0) < 360 ? 'start' : 'end')) + .attr('font-size', 12) + .attr('font-weight', (d) => (d.kind === 'interface' ? 700 : 500)) + .attr('fill', '#22374f') + .text((d) => { + if (d.kind === 'host') { + return d.ipAddress ?? d.macAddress ?? d.label; + } + return d.label; + }); + }, [data]); + + if (data.nodes.length === 0 || data.links.length === 0) { + return ; + } + + return ; +} + +function ForceTopology({ data }: { data: TopologyData }) { + const svgRef = useRef(null); + + useEffect(() => { + if (!svgRef.current) return; + const width = 1160; + const height = 720; + const svg = d3.select(svgRef.current); + svg.selectAll('*').remove(); + svg.attr('viewBox', `0 0 ${width} ${height}`); + + if (data.nodes.length === 0 || data.links.length === 0) { + return; + } + + const nodes: ForceNode[] = data.nodes.map((node) => ({ ...node })); + const links: ForceLink[] = data.links.map((link) => ({ ...link })); + + const simulation = d3 + .forceSimulation(nodes) + .force( + 'link', + d3 + .forceLink(links) + .id((node) => node.id) + .distance((link) => { + const source = link.source as ForceNode; + return source.kind === 'interface' ? 140 : 120; + }), + ) + .force('charge', d3.forceManyBody().strength(-280)) + .force('collision', d3.forceCollide().radius((node) => (node.kind === 'host' ? 38 : 28))) + .force( + 'x', + d3.forceX().x((node) => { + if (node.kind === 'interface') return 150; + if (node.kind === 'host') return width / 2; + return width - 180; + }).strength(0.32), + ) + .force('y', d3.forceY(height / 2).strength(0.08)); + + svg.append('rect').attr('x', 0).attr('y', 0).attr('width', width).attr('height', height).attr('rx', 18).attr('fill', '#fbfcfe'); + + const link = svg + .append('g') + .attr('stroke-opacity', 0.45) + .selectAll('line') + .data(links) + .join('line') + .attr('stroke', (d) => { + const target = d.target as ForceNode; + return target.kind === 'protocol' && target.protocol ? protocolColor(target.protocol) : '#92a1b2'; + }) + .attr('stroke-width', (d) => Math.max(1.5, Math.sqrt(d.value))); + + link.append('title').text((d) => `${d.label}\nPackets: ${d.value}`); + + const node = svg + .append('g') + .selectAll('g') + .data(nodes) + .join('g'); + + node + .append('circle') + .attr('r', (d) => { + if (d.kind === 'interface') return 26; + if (d.kind === 'host') return 22; + return 18; + }) + .attr('fill', (d) => { + if (d.kind === 'interface') return '#20405d'; + if (d.kind === 'host') return '#d7e7f5'; + return d.protocol ? protocolColor(d.protocol) : '#cfd7df'; + }) + .attr('stroke', (d) => (d.kind === 'host' ? '#8aa8c6' : '#ffffff')) + .attr('stroke-width', 2); + + node + .append('text') + .attr('text-anchor', 'middle') + .attr('dy', 40) + .attr('font-size', 11) + .attr('font-weight', 600) + .attr('fill', '#29445d') + .text((d) => { + if (d.kind === 'host') return d.ipAddress ?? d.macAddress ?? 'host'; + return d.label; + }); + + node.append('title').text((d) => `${d.label}\nPackets: ${d.packetCount}`); + + simulation.on('tick', () => { + link + .attr('x1', (d) => (d.source as ForceNode).x ?? 0) + .attr('y1', (d) => (d.source as ForceNode).y ?? 0) + .attr('x2', (d) => (d.target as ForceNode).x ?? 0) + .attr('y2', (d) => (d.target as ForceNode).y ?? 0); + + node.attr('transform', (d) => `translate(${d.x ?? 0},${d.y ?? 0})`); + }); + + return () => { + simulation.stop(); + }; + }, [data]); + + if (data.nodes.length === 0 || data.links.length === 0) { + return ; + } + + return ; +} + +function ProtocolHeatmap({ data }: { data: TopologyData }) { + const svgRef = useRef(null); + + useEffect(() => { + if (!svgRef.current) return; + + const margin = { top: 140, right: 30, bottom: 30, left: 240 }; + const cellWidth = 92; + const cellHeight = 34; + const width = Math.max(920, margin.left + margin.right + data.protocols.length * cellWidth); + const height = Math.max(320, margin.top + margin.bottom + data.heatmapRows.length * cellHeight); + + const svg = d3.select(svgRef.current); + svg.selectAll('*').remove(); + svg.attr('viewBox', `0 0 ${width} ${height}`); + + if (data.protocols.length === 0 || data.heatmapRows.length === 0) { + return; + } + + const x = d3.scaleBand().domain(data.protocols).range([margin.left, width - margin.right]).paddingInner(0.08); + const y = d3.scaleBand().domain(data.heatmapRows.map((row) => row.hostId)).range([margin.top, height - margin.bottom]).paddingInner(0.08); + const maxValue = d3.max(data.heatmapRows.flatMap((row) => data.protocols.map((protocol) => row.values[protocol] || 0))) ?? 1; + const color = d3.scaleSequential(d3.interpolateYlGnBu).domain([0, maxValue]); + + svg.append('rect').attr('x', 0).attr('y', 0).attr('width', width).attr('height', height).attr('rx', 18).attr('fill', '#fbfcfe'); + + const cells = svg.append('g'); + for (const row of data.heatmapRows) { + for (const protocol of data.protocols) { + const value = row.values[protocol] || 0; + const cell = cells + .append('g') + .attr('transform', `translate(${x(protocol) ?? 0},${y(row.hostId) ?? 0})`); + + cell + .append('rect') + .attr('width', x.bandwidth()) + .attr('height', y.bandwidth()) + .attr('rx', 8) + .attr('fill', value > 0 ? color(value) : '#eef3f8') + .attr('stroke', '#dce5ef'); + + if (value > 0) { + cell + .append('text') + .attr('x', x.bandwidth() / 2) + .attr('y', y.bandwidth() / 2 + 4) + .attr('text-anchor', 'middle') + .attr('font-size', 11) + .attr('font-weight', 700) + .attr('fill', value > maxValue * 0.45 ? '#ffffff' : '#23415c') + .text(value); + } + + cell.append('title').text(`${row.hostLabel}\n${protocol}: ${value} packets`); + } + } + + svg + .append('g') + .selectAll('text.protocol-label') + .data(data.protocols) + .join('text') + .attr('class', 'protocol-label') + .attr('x', (protocol) => (x(protocol) ?? 0) + x.bandwidth() / 2) + .attr('y', margin.top - 12) + .attr('transform', (protocol) => `rotate(-35, ${(x(protocol) ?? 0) + x.bandwidth() / 2}, ${margin.top - 12})`) + .attr('text-anchor', 'start') + .attr('font-size', 12) + .attr('font-weight', 600) + .attr('fill', '#29445d') + .text((protocol) => protocol); + + svg + .append('g') + .selectAll('text.host-label') + .data(data.heatmapRows) + .join('text') + .attr('class', 'host-label') + .attr('x', margin.left - 12) + .attr('y', (row) => (y(row.hostId) ?? 0) + y.bandwidth() / 2 + 4) + .attr('text-anchor', 'end') + .attr('font-size', 12) + .attr('fill', '#29445d') + .text((row) => row.hostLabel); + }, [data]); + + if (data.protocols.length === 0 || data.heatmapRows.length === 0) { + return ; + } + + return ; +} + +export default function Analysis(): ReactElement { + const [sinceMinutes, setSinceMinutes] = useState(60); + const [limitPerInterface, setLimitPerInterface] = useState(50); + const [limitProtocolsPerHost, setLimitProtocolsPerHost] = useState(12); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const response = await fetchInterfaceHostProtocolAnalysis(sinceMinutes, limitPerInterface, limitProtocolsPerHost); + setData(response); + } catch (error: any) { + message.error(error?.message ?? 'Failed to load analysis data'); + } finally { + setLoading(false); + } + }, [sinceMinutes, limitPerInterface, limitProtocolsPerHost]); + + useEffect(() => { + loadData().catch(() => undefined); + }, [loadData]); + + const topologyData = useMemo(() => buildTopologyData(data?.interfaces ?? []), [data]); + + const columns = useMemo>( + () => [ + { + title: 'Interface', + dataIndex: 'interface', + key: 'interface', + width: 140, + render: (value: string) => {value}, + }, + { + title: 'IP', + dataIndex: 'ip_address', + key: 'ip_address', + render: (value?: string | null) => value ?? '—', + }, + { + title: 'MAC', + dataIndex: 'mac_address', + key: 'mac_address', + render: (value?: string | null) => value ?? '—', + }, + { + title: 'Protocol', + dataIndex: 'protocol', + key: 'protocol', + width: 140, + render: (value: string) => {value}, + }, + { title: 'Host Packets', dataIndex: 'host_packet_count', key: 'host_packet_count', width: 110 }, + { title: 'Protocol Packets', dataIndex: 'protocol_packet_count', key: 'protocol_packet_count', width: 130 }, + { title: 'Accept', dataIndex: 'accept_count', key: 'accept_count', width: 90 }, + { title: 'Drop', dataIndex: 'drop_count', key: 'drop_count', width: 90 }, + { title: 'Reject', dataIndex: 'reject_count', key: 'reject_count', width: 90 }, + { title: 'Unknown', dataIndex: 'unknown_count', key: 'unknown_count', width: 90 }, + { + title: 'Last Seen', + dataIndex: 'last_seen', + key: 'last_seen', + width: 220, + render: (value: string) => formatTimestamp(value), + }, + ], + [], + ); + + return ( +
+ + + + Analysis + + Explore inferred interface, host, and protocol relationships from captured traffic. + + + + + + + Look back + setSinceMinutes(value ?? 60)} /> + minutes + + + Max hosts per interface + setLimitPerInterface(value ?? 50)} /> + + + Max protocols per host + setLimitProtocolsPerHost(value ?? 12)} /> + + + + + + {data?.notes?.length ? ( + + {data.notes.map((note) => ( +
  • {note}
  • + ))} + + } + /> + ) : null} + + Since {formatTimestamp(data.since)} : null} + style={{ marginBottom: 16 }} + > + + + + Best for understanding how traffic flows from MITM interfaces to inferred hosts and then into protocols. + + +
    + ), + }, + { + key: 'force', + label: 'Force Graph', + children: ( +
    + Useful for exploring clusters and protocol neighborhoods across interfaces and hosts. + +
    + ), + }, + { + key: 'heatmap', + label: 'Heatmap', + children: ( +
    + Useful for comparing which hosts are most active in which protocols. + +
    + ), + }, + ]} + /> + + + + + + This is the underlying aggregated evidence used by the visualizations, including verdict counts per interface, host, and protocol. + + + + + ); +} diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 8f10996..7ca63d4 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -2,7 +2,8 @@ export const PATHS = { ROOT: '/', HOME: '/home', NETWORK: '/network', + ANALYSIS: '/analysis', SNIFFING: '/sniffing', SCRIPTING: '/scripting', FIREWALL: '/firewall', -}; \ No newline at end of file +}; diff --git a/frontend/src/types/analysis.ts b/frontend/src/types/analysis.ts new file mode 100644 index 0000000..c29d568 --- /dev/null +++ b/frontend/src/types/analysis.ts @@ -0,0 +1,44 @@ +export interface InterfaceHostEvidence { + ip_address?: string | null; + mac_address?: string | null; + packet_count: number; + last_seen: string; + source_on_ingress_count: number; + destination_on_egress_count: number; +} + +export interface InterfaceAttachment { + interface: string; + hosts: InterfaceHostEvidence[]; +} + +export interface InterfaceHostAnalysisResponse { + since?: string | null; + interfaces: InterfaceAttachment[]; + notes: string[]; +} + +export interface ProtocolEvidence { + protocol: string; + packet_count: number; + last_seen: string; + accept_count: number; + drop_count: number; + reject_count: number; + unknown_count: number; +} + +export interface InterfaceHostProtocolEvidence extends InterfaceHostEvidence { + protocols: ProtocolEvidence[]; +} + +export interface InterfaceProtocolAttachment { + interface: string; + hosts: InterfaceHostProtocolEvidence[]; +} + +export interface InterfaceHostProtocolAnalysisResponse { + since?: string | null; + interfaces: InterfaceProtocolAttachment[]; + notes: string[]; +}