feat: non-blocking scan with progressive device discovery
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
This commit is contained in:
@@ -19,15 +19,27 @@ async def ws_status(websocket: WebSocket) -> None:
|
|||||||
_connections.remove(websocket)
|
_connections.remove(websocket)
|
||||||
|
|
||||||
|
|
||||||
async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None:
|
async def _broadcast(payload: str) -> None:
|
||||||
payload = json.dumps({
|
|
||||||
"node_id": node_id,
|
|
||||||
"status": status,
|
|
||||||
"checked_at": checked_at,
|
|
||||||
"response_time_ms": response_time_ms,
|
|
||||||
})
|
|
||||||
for conn in list(_connections):
|
for conn in list(_connections):
|
||||||
try:
|
try:
|
||||||
await conn.send_text(payload)
|
await conn.send_text(payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
_connections.remove(conn)
|
_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,
|
||||||
|
}))
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Network scanner: ARP sweep + nmap service detection."""
|
"""Network scanner: ARP sweep + nmap service detection."""
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
from datetime import UTC, datetime
|
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:
|
async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
||||||
"""Execute scan for given CIDR ranges and populate pending_devices."""
|
"""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
|
devices_found = 0
|
||||||
try:
|
try:
|
||||||
for cidr in ranges:
|
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:
|
for host in hosts:
|
||||||
services = fingerprint_ports(host["open_ports"])
|
services = fingerprint_ports(host["open_ports"])
|
||||||
suggested_type = suggest_node_type(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(
|
existing = await db.execute(
|
||||||
__import__("sqlalchemy", fromlist=["select"]).select(PendingDevice).where(
|
select(PendingDevice).where(
|
||||||
PendingDevice.ip == host["ip"],
|
PendingDevice.ip == host["ip"],
|
||||||
PendingDevice.status == "pending",
|
PendingDevice.status == "pending",
|
||||||
)
|
)
|
||||||
@@ -119,9 +127,19 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
|||||||
db.add(device)
|
db.add(device)
|
||||||
devices_found += 1
|
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)
|
run = await db.get(ScanRun, run_id)
|
||||||
if run:
|
if run:
|
||||||
run.status = "done"
|
run.status = "done"
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export function Sidebar({ onAddNode, onScan, onSave }: SidebarProps) {
|
|||||||
function PendingDevicesPanel() {
|
function PendingDevicesPanel() {
|
||||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
const [devices, setDevices] = useState<PendingDevice[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const { addNode } = useCanvasStore()
|
const { addNode, scanEventTs } = useCanvasStore()
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -165,7 +165,12 @@ function PendingDevicesPanel() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load on mount
|
// 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) => {
|
const handleApprove = async (device: PendingDevice) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ import { useCanvasStore } from '@/stores/canvasStore'
|
|||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
interface StatusMessage {
|
interface StatusMessage {
|
||||||
node_id: string
|
type?: string
|
||||||
status: 'online' | 'offline' | 'pending' | 'unknown'
|
node_id?: string
|
||||||
checked_at: string
|
status?: 'online' | 'offline' | 'pending' | 'unknown'
|
||||||
response_time_ms: number | null
|
checked_at?: string
|
||||||
|
response_time_ms?: number | null
|
||||||
|
run_id?: string
|
||||||
|
devices_found?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStatusPolling() {
|
export function useStatusPolling() {
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
const { updateNode } = useCanvasStore()
|
const { updateNode, notifyScanDeviceFound } = useCanvasStore()
|
||||||
const { isAuthenticated, token } = useAuthStore()
|
const { isAuthenticated, token } = useAuthStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -27,11 +30,15 @@ export function useStatusPolling() {
|
|||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const msg: StatusMessage = JSON.parse(event.data)
|
const msg: StatusMessage = JSON.parse(event.data)
|
||||||
updateNode(msg.node_id, {
|
if (msg.type === 'scan_device_found') {
|
||||||
status: msg.status,
|
notifyScanDeviceFound()
|
||||||
response_time_ms: msg.response_time_ms ?? undefined,
|
} else if (msg.node_id && msg.status) {
|
||||||
last_seen: msg.status === 'online' ? msg.checked_at : undefined,
|
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 {
|
} catch {
|
||||||
// ignore malformed messages
|
// ignore malformed messages
|
||||||
}
|
}
|
||||||
@@ -45,5 +52,5 @@ export function useStatusPolling() {
|
|||||||
ws.close()
|
ws.close()
|
||||||
wsRef.current = null
|
wsRef.current = null
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, token, updateNode])
|
}, [isAuthenticated, token, updateNode, notifyScanDeviceFound])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface CanvasState {
|
|||||||
edges: Edge<EdgeData>[]
|
edges: Edge<EdgeData>[]
|
||||||
hasUnsavedChanges: boolean
|
hasUnsavedChanges: boolean
|
||||||
selectedNodeId: string | null
|
selectedNodeId: string | null
|
||||||
|
scanEventTs: number
|
||||||
|
|
||||||
onNodesChange: (changes: NodeChange<Node<NodeData>>[]) => void
|
onNodesChange: (changes: NodeChange<Node<NodeData>>[]) => void
|
||||||
onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void
|
onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void
|
||||||
@@ -29,6 +30,7 @@ interface CanvasState {
|
|||||||
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
|
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
|
||||||
markSaved: () => void
|
markSaved: () => void
|
||||||
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
|
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
|
||||||
|
notifyScanDeviceFound: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCanvasStore = create<CanvasState>((set) => ({
|
export const useCanvasStore = create<CanvasState>((set) => ({
|
||||||
@@ -36,6 +38,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
edges: [],
|
edges: [],
|
||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
|
scanEventTs: 0,
|
||||||
|
|
||||||
onNodesChange: (changes) =>
|
onNodesChange: (changes) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -126,6 +129,8 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
|
|
||||||
markSaved: () => set({ hasUnsavedChanges: false }),
|
markSaved: () => set({ hasUnsavedChanges: false }),
|
||||||
|
|
||||||
|
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
|
||||||
|
|
||||||
loadCanvas: (nodes, edges) => {
|
loadCanvas: (nodes, edges) => {
|
||||||
// React Flow requires parents before children in the array
|
// React Flow requires parents before children in the array
|
||||||
const parents = nodes.filter((n) => !n.parentId)
|
const parents = nodes.filter((n) => !n.parentId)
|
||||||
|
|||||||
Reference in New Issue
Block a user