fix: propagate nmap errors to UI + sudo scan script

- scanner.py: re-raise nmap exceptions so they reach ScanRun.error
  (previously silently returned [] hiding the root cause)
- Sidebar: after triggering scan, auto-switch to History tab
- ScanHistoryPanel: auto-refresh every 3s while any run is 'running';
  show error toast when a run transitions running→error; show spinner
  on running runs; error message fully visible (no truncation)
- scripts/run_scan.py: standalone scan script to run with sudo for
  nmap OS detection / SYN scans on macOS
This commit is contained in:
Pouzor
2026-03-07 01:28:21 +01:00
parent 8a18ded2bc
commit fb85468cef
3 changed files with 84 additions and 8 deletions
+1 -1
View File
@@ -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():
+33 -7
View File
@@ -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<ScanRun[]>([])
const [loading, setLoading] = useState(false)
const prevRunsRef = useRef<ScanRun[]>([])
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 (
<div className="p-2">
@@ -319,7 +340,7 @@ function ScanHistoryPanel() {
<RefreshCw size={12} />
</button>
</div>
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
{loading && runs.length === 0 && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
{!loading && runs.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">No scans yet</p>
)}
@@ -328,6 +349,7 @@ function ScanHistoryPanel() {
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: statusColor(r.status) }} />
<span className="font-mono text-foreground capitalize">{r.status}</span>
{r.status === 'running' && <Loader2 size={10} className="animate-spin text-[#e3b341]" />}
<span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span>
</div>
<div className="text-muted-foreground text-[10px] mt-0.5">
@@ -336,7 +358,11 @@ function ScanHistoryPanel() {
{r.ranges.length > 0 && (
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
)}
{r.error && <div className="text-[#f85149] text-[10px] mt-0.5 truncate">{r.error}</div>}
{r.error && (
<div className="text-[#f85149] text-[10px] mt-1 leading-tight break-words whitespace-pre-wrap">
{r.error}
</div>
)}
</div>
))}
</div>
+50
View File
@@ -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 <cidr> [<cidr> ...]")
print("Example: sudo python scripts/run_scan.py 192.168.1.0/24")
sys.exit(1)
ranges = sys.argv[1:]
asyncio.run(main(ranges))