feat: Phase 3 & 4 — monitoring, discovery, polish, deployment

Phase 3 — Discovery & Monitoring:
- Network scanner: nmap wrapper + mock fallback, fingerprint service (35 signatures)
- Status checker: ping/http/https/tcp/ssh/prometheus/health per-node checks
- APScheduler: status checks every 60s, WebSocket broadcast
- WebSocket /ws/status: live node status updates to frontend
- Sidebar panels: Pending Devices, Hidden Devices, Scan History
- Auth token persisted to localStorage (survive page refresh)
- 24 new backend tests (scan flow + status_checker)

Phase 4 — Polish & Deployment:
- Auto-layout: Dagre hierarchical TB via Toolbar button
- Export PNG: html-to-image download via Toolbar button
- Scan config modal: CIDR ranges + check interval, GET/POST /api/v1/scan/config
- Dockerfile.backend (Python 3.13 slim + nmap), Dockerfile.frontend (nginx)
- docker-compose.yml with data volume and NET_RAW cap for ping
- scripts/lxc-install.sh: Proxmox VE systemd bootstrap
- README.md: quick-start, config reference, stack overview
This commit is contained in:
Pouzor
2026-03-07 00:45:50 +01:00
parent 44a448e26d
commit 0bd714a68b
26 changed files with 1778 additions and 25 deletions
@@ -0,0 +1,126 @@
import { useState, useEffect } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { scanApi } from '@/api/client'
import { toast } from 'sonner'
interface ScanConfigModalProps {
open: boolean
onClose: () => void
onScanNow: () => void
}
export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalProps) {
const [ranges, setRanges] = useState<string[]>([''])
const [interval, setInterval] = useState(60)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open) return
scanApi.getConfig()
.then((res) => {
setRanges(res.data.ranges.length > 0 ? res.data.ranges : [''])
setInterval(res.data.interval_seconds)
})
.catch(() => {/* use defaults */})
}, [open])
const handleSave = async () => {
const cleaned = ranges.map((r) => r.trim()).filter(Boolean)
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
setSaving(true)
try {
await scanApi.saveConfig({ ranges: cleaned, interval_seconds: interval })
toast.success('Scan config saved')
onClose()
} catch {
toast.error('Failed to save config')
} finally {
setSaving(false)
}
}
const handleScanNow = async () => {
const cleaned = ranges.map((r) => r.trim()).filter(Boolean)
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
await handleSave()
onScanNow()
onClose()
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="bg-[#161b22] border-border max-w-md">
<DialogHeader>
<DialogTitle className="text-foreground">Scan Configuration</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* IP Ranges */}
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">IP Ranges (CIDR)</Label>
{ranges.map((r, i) => (
<div key={i} className="flex gap-2">
<Input
value={r}
onChange={(e) => {
const next = [...ranges]
next[i] = e.target.value
setRanges(next)
}}
placeholder="192.168.1.0/24"
className="font-mono text-sm bg-[#0d1117] border-border"
/>
<Button
size="icon"
variant="ghost"
className="shrink-0 text-muted-foreground hover:text-[#f85149]"
onClick={() => setRanges(ranges.filter((_, j) => j !== i))}
disabled={ranges.length === 1}
>
<Trash2 size={14} />
</Button>
</div>
))}
<Button
size="sm"
variant="ghost"
className="gap-1.5 text-muted-foreground hover:text-foreground"
onClick={() => setRanges([...ranges, ''])}
>
<Plus size={13} /> Add range
</Button>
</div>
{/* Status check interval */}
<div className="space-y-1.5">
<Label className="text-sm text-muted-foreground">Status check interval (seconds)</Label>
<Input
type="number"
min={10}
max={3600}
value={interval}
onChange={(e) => setInterval(Number(e.target.value))}
className="font-mono text-sm bg-[#0d1117] border-border w-32"
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="outline" onClick={handleSave} disabled={saving}>Save</Button>
<Button
onClick={handleScanNow}
disabled={saving}
style={{ background: '#00d4ff', color: '#0d1117' }}
>
Scan Now
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}