From bec699ba93bd7420a926dd3c6cb4a1518409c04a Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 7 Mar 2026 16:06:44 +0100 Subject: [PATCH] feat: non-blocking scan with progressive device discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - asyncio.to_thread(_nmap_scan) — nmap no longer blocks the event loop - Commit each discovered device immediately (previously one bulk commit at end) - Update ScanRun.devices_found after each device so history panel shows live count - broadcast_scan_update() pushes {type: scan_device_found} WS event per device - Refactor broadcast_status to shared _broadcast() helper, adds type: "status" field Frontend: - useStatusPolling handles both WS message types (status / scan_device_found) - canvasStore: scanEventTs + notifyScanDeviceFound() action - PendingDevicesPanel auto-refreshes when WS scan event is received --- backend/app/api/routes/status.py | 26 +++++++++++++------ backend/app/services/scanner.py | 28 +++++++++++++++++---- frontend/src/components/panels/Sidebar.tsx | 9 +++++-- frontend/src/hooks/useStatusPolling.ts | 29 ++++++++++++++-------- frontend/src/stores/canvasStore.ts | 5 ++++ 5 files changed, 72 insertions(+), 25 deletions(-) diff --git a/backend/app/api/routes/status.py b/backend/app/api/routes/status.py index c7524a3..d2f4894 100644 --- a/backend/app/api/routes/status.py +++ b/backend/app/api/routes/status.py @@ -19,15 +19,27 @@ async def ws_status(websocket: WebSocket) -> None: _connections.remove(websocket) -async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None: - payload = json.dumps({ - "node_id": node_id, - "status": status, - "checked_at": checked_at, - "response_time_ms": response_time_ms, - }) +async def _broadcast(payload: str) -> None: for conn in list(_connections): try: await conn.send_text(payload) except Exception: _connections.remove(conn) + + +async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None: + await _broadcast(json.dumps({ + "type": "status", + "node_id": node_id, + "status": status, + "checked_at": checked_at, + "response_time_ms": response_time_ms, + })) + + +async def broadcast_scan_update(run_id: str, devices_found: int) -> None: + await _broadcast(json.dumps({ + "type": "scan_device_found", + "run_id": run_id, + "devices_found": devices_found, + })) diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py index 17c96e4..00318c0 100644 --- a/backend/app/services/scanner.py +++ b/backend/app/services/scanner.py @@ -1,4 +1,5 @@ """Network scanner: ARP sweep + nmap service detection.""" +import asyncio import logging import socket from datetime import UTC, datetime @@ -89,17 +90,24 @@ def _mock_scan(target: str) -> list[dict[str, Any]]: async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None: """Execute scan for given CIDR ranges and populate pending_devices.""" + # Avoid circular import + from sqlalchemy import select + + from app.api.routes.status import broadcast_scan_update + devices_found = 0 try: for cidr in ranges: - hosts = _nmap_scan(cidr) + # Run nmap in a thread pool — does not block the event loop + hosts = await asyncio.to_thread(_nmap_scan, cidr) + for host in hosts: services = fingerprint_ports(host["open_ports"]) suggested_type = suggest_node_type(host["open_ports"]) - # Skip if already pending or already a node (by IP) + # Skip if already pending (by IP) existing = await db.execute( - __import__("sqlalchemy", fromlist=["select"]).select(PendingDevice).where( + select(PendingDevice).where( PendingDevice.ip == host["ip"], PendingDevice.status == "pending", ) @@ -119,9 +127,19 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None: db.add(device) devices_found += 1 - await db.commit() + # Commit immediately so the device is visible right away + await db.commit() - # Update scan run + # Update running count on the scan run record + run = await db.get(ScanRun, run_id) + if run: + run.devices_found = devices_found + await db.commit() + + # Push WS event so the frontend refreshes pending panel + await broadcast_scan_update(run_id=run_id, devices_found=devices_found) + + # Mark scan as done run = await db.get(ScanRun, run_id) if run: run.status = "done" diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index 34e2b00..77dd074 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -150,7 +150,7 @@ export function Sidebar({ onAddNode, onScan, onSave }: SidebarProps) { function PendingDevicesPanel() { const [devices, setDevices] = useState([]) const [loading, setLoading] = useState(false) - const { addNode } = useCanvasStore() + const { addNode, scanEventTs } = useCanvasStore() const load = useCallback(async () => { setLoading(true) @@ -165,7 +165,12 @@ function PendingDevicesPanel() { }, []) // Load on mount - useState(() => { load() }) + useEffect(() => { load() }, [load]) + + // Auto-refresh when the backend pushes a scan_device_found event + useEffect(() => { + if (scanEventTs > 0) load() + }, [scanEventTs, load]) const handleApprove = async (device: PendingDevice) => { try { diff --git a/frontend/src/hooks/useStatusPolling.ts b/frontend/src/hooks/useStatusPolling.ts index 27e3776..c7405b3 100644 --- a/frontend/src/hooks/useStatusPolling.ts +++ b/frontend/src/hooks/useStatusPolling.ts @@ -3,15 +3,18 @@ import { useCanvasStore } from '@/stores/canvasStore' import { useAuthStore } from '@/stores/authStore' interface StatusMessage { - node_id: string - status: 'online' | 'offline' | 'pending' | 'unknown' - checked_at: string - response_time_ms: number | null + type?: string + node_id?: string + status?: 'online' | 'offline' | 'pending' | 'unknown' + checked_at?: string + response_time_ms?: number | null + run_id?: string + devices_found?: number } export function useStatusPolling() { const wsRef = useRef(null) - const { updateNode } = useCanvasStore() + const { updateNode, notifyScanDeviceFound } = useCanvasStore() const { isAuthenticated, token } = useAuthStore() useEffect(() => { @@ -27,11 +30,15 @@ export function useStatusPolling() { ws.onmessage = (event) => { try { const msg: StatusMessage = JSON.parse(event.data) - updateNode(msg.node_id, { - status: msg.status, - response_time_ms: msg.response_time_ms ?? undefined, - last_seen: msg.status === 'online' ? msg.checked_at : undefined, - }) + if (msg.type === 'scan_device_found') { + notifyScanDeviceFound() + } else if (msg.node_id && msg.status) { + updateNode(msg.node_id, { + status: msg.status, + response_time_ms: msg.response_time_ms ?? undefined, + last_seen: msg.status === 'online' ? msg.checked_at : undefined, + }) + } } catch { // ignore malformed messages } @@ -45,5 +52,5 @@ export function useStatusPolling() { ws.close() wsRef.current = null } - }, [isAuthenticated, token, updateNode]) + }, [isAuthenticated, token, updateNode, notifyScanDeviceFound]) } diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index b54965c..61e229c 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -16,6 +16,7 @@ interface CanvasState { edges: Edge[] hasUnsavedChanges: boolean selectedNodeId: string | null + scanEventTs: number onNodesChange: (changes: NodeChange>[]) => void onEdgesChange: (changes: EdgeChange>[]) => void @@ -29,6 +30,7 @@ interface CanvasState { setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void markSaved: () => void loadCanvas: (nodes: Node[], edges: Edge[]) => void + notifyScanDeviceFound: () => void } export const useCanvasStore = create((set) => ({ @@ -36,6 +38,7 @@ export const useCanvasStore = create((set) => ({ edges: [], hasUnsavedChanges: false, selectedNodeId: null, + scanEventTs: 0, onNodesChange: (changes) => set((state) => ({ @@ -126,6 +129,8 @@ export const useCanvasStore = create((set) => ({ markSaved: () => set({ hasUnsavedChanges: false }), + notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }), + loadCanvas: (nodes, edges) => { // React Flow requires parents before children in the array const parents = nodes.filter((n) => !n.parentId)