diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py index 7ab4f16..5b8ad98 100644 --- a/backend/app/services/scanner.py +++ b/backend/app/services/scanner.py @@ -28,7 +28,7 @@ def _nmap_scan(target: str) -> list[dict]: nm.scan(hosts=target, arguments="-sV --open -T4 --host-timeout 30s") except Exception as exc: logger.error("nmap scan failed: %s", exc) - return [] + raise RuntimeError(str(exc)) from exc hosts = [] for host in nm.all_hosts(): diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index 45c6150..34e2b00 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react' +import { useState, useCallback, useEffect, useRef } from 'react' import { Network, Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Check, EyeOff as Hide, Trash2, RefreshCw, Loader2 } from 'lucide-react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useCanvasStore } from '@/stores/canvasStore' @@ -53,7 +53,8 @@ export function Sidebar({ onAddNode, onScan, onSave }: SidebarProps) { const handleScan = useCallback(async () => { try { await scanApi.trigger() - toast.success('Network scan started') + toast.success('Network scan started — check Scan History for results') + setActiveView('history') onScan() } catch { toast.error('Failed to trigger scan') @@ -293,12 +294,23 @@ function HiddenDevicesPanel() { function ScanHistoryPanel() { const [runs, setRuns] = useState([]) const [loading, setLoading] = useState(false) + const prevRunsRef = useRef([]) const load = useCallback(async () => { setLoading(true) try { const res = await scanApi.runs() - setRuns(res.data) + const next: ScanRun[] = res.data + + // Toast when a run transitions from running → error + for (const run of next) { + const prev = prevRunsRef.current.find((r) => r.id === run.id) + if (prev?.status === 'running' && run.status === 'error') { + toast.error(`Scan failed: ${run.error ?? 'unknown error'}`) + } + } + prevRunsRef.current = next + setRuns(next) } catch { toast.error('Failed to load scan history') } finally { @@ -306,10 +318,19 @@ function ScanHistoryPanel() { } }, []) - useState(() => { load() }) + // Initial load + useEffect(() => { load() }, [load]) + + // Auto-refresh every 3s while any run is still running + useEffect(() => { + const hasRunning = runs.some((r) => r.status === 'running') + if (!hasRunning) return + const id = setInterval(load, 3000) + return () => clearInterval(id) + }, [runs, load]) const statusColor = (s: string) => - s === 'completed' ? '#39d353' : s === 'running' ? '#e3b341' : s === 'failed' ? '#f85149' : '#8b949e' + s === 'done' ? '#39d353' : s === 'running' ? '#e3b341' : s === 'error' ? '#f85149' : '#8b949e' return (
@@ -319,7 +340,7 @@ function ScanHistoryPanel() {
- {loading && } + {loading && runs.length === 0 && } {!loading && runs.length === 0 && (

No scans yet

)} @@ -328,6 +349,7 @@ function ScanHistoryPanel() {
{r.status} + {r.status === 'running' && } {r.devices_found} found
@@ -336,7 +358,11 @@ function ScanHistoryPanel() { {r.ranges.length > 0 && (
{r.ranges.join(', ')}
)} - {r.error &&
{r.error}
} + {r.error && ( +
+ {r.error} +
+ )}
))} diff --git a/scripts/run_scan.py b/scripts/run_scan.py new file mode 100755 index 0000000..c9f7864 --- /dev/null +++ b/scripts/run_scan.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +""" +Standalone scan script — run with sudo to allow nmap OS detection and SYN scans. + +Usage (from the backend/ directory): + sudo ../scripts/run_scan.py 192.168.1.0/24 + sudo ../scripts/run_scan.py 192.168.1.0/24 10.0.0.0/24 + +Results are written directly to the database and appear as Pending Devices +in the Homelable UI. The backend does not need to be restarted. +""" +import asyncio +import sys +from pathlib import Path + +# Make sure app/ is importable +sys.path.insert(0, str(Path(__file__).parent.parent / "backend")) + +from app.db.database import AsyncSessionLocal, init_db +from app.db.models import ScanRun +from app.services.scanner import run_scan + + +async def main(ranges: list[str]) -> None: + await init_db() + async with AsyncSessionLocal() as db: + run = ScanRun(status="running", ranges=ranges) + db.add(run) + await db.commit() + await db.refresh(run) + print(f"Scan started (id={run.id}) for ranges: {', '.join(ranges)}") + + await run_scan(ranges, db, run.id) + + async with AsyncSessionLocal() as db: + run = await db.get(ScanRun, run.id) + if run: + print(f"Scan {run.status} — {run.devices_found} device(s) found") + if run.error: + print(f"Error: {run.error}", file=sys.stderr) + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: sudo python scripts/run_scan.py [ ...]") + print("Example: sudo python scripts/run_scan.py 192.168.1.0/24") + sys.exit(1) + + ranges = sys.argv[1:] + asyncio.run(main(ranges))