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:
@@ -28,7 +28,7 @@ def _nmap_scan(target: str) -> list[dict]:
|
|||||||
nm.scan(hosts=target, arguments="-sV --open -T4 --host-timeout 30s")
|
nm.scan(hosts=target, arguments="-sV --open -T4 --host-timeout 30s")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("nmap scan failed: %s", exc)
|
logger.error("nmap scan failed: %s", exc)
|
||||||
return []
|
raise RuntimeError(str(exc)) from exc
|
||||||
|
|
||||||
hosts = []
|
hosts = []
|
||||||
for host in nm.all_hosts():
|
for host in nm.all_hosts():
|
||||||
|
|||||||
@@ -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 { 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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
@@ -53,7 +53,8 @@ export function Sidebar({ onAddNode, onScan, onSave }: SidebarProps) {
|
|||||||
const handleScan = useCallback(async () => {
|
const handleScan = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await scanApi.trigger()
|
await scanApi.trigger()
|
||||||
toast.success('Network scan started')
|
toast.success('Network scan started — check Scan History for results')
|
||||||
|
setActiveView('history')
|
||||||
onScan()
|
onScan()
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to trigger scan')
|
toast.error('Failed to trigger scan')
|
||||||
@@ -293,12 +294,23 @@ function HiddenDevicesPanel() {
|
|||||||
function ScanHistoryPanel() {
|
function ScanHistoryPanel() {
|
||||||
const [runs, setRuns] = useState<ScanRun[]>([])
|
const [runs, setRuns] = useState<ScanRun[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const prevRunsRef = useRef<ScanRun[]>([])
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await scanApi.runs()
|
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 {
|
} catch {
|
||||||
toast.error('Failed to load scan history')
|
toast.error('Failed to load scan history')
|
||||||
} finally {
|
} 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) =>
|
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 (
|
return (
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
@@ -319,7 +340,7 @@ function ScanHistoryPanel() {
|
|||||||
<RefreshCw size={12} />
|
<RefreshCw size={12} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 && (
|
{!loading && runs.length === 0 && (
|
||||||
<p className="text-xs text-muted-foreground text-center py-4">No scans yet</p>
|
<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">
|
<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="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>
|
<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>
|
<span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground text-[10px] mt-0.5">
|
<div className="text-muted-foreground text-[10px] mt-0.5">
|
||||||
@@ -336,7 +358,11 @@ function ScanHistoryPanel() {
|
|||||||
{r.ranges.length > 0 && (
|
{r.ranges.length > 0 && (
|
||||||
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Executable
+50
@@ -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))
|
||||||
Reference in New Issue
Block a user