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
+38 -5
View File
@@ -1,6 +1,8 @@
import { useEffect, useCallback, useRef, useState } from 'react'
import { ReactFlowProvider, type Connection } from '@xyflow/react'
import { type Node } from '@xyflow/react'
import { applyDagreLayout } from '@/utils/layout'
import { exportToPng } from '@/utils/export'
import { TooltipProvider } from '@/components/ui/tooltip'
import { Toaster } from '@/components/ui/sonner'
import { toast } from 'sonner'
@@ -11,19 +13,25 @@ import { DetailPanel } from '@/components/panels/DetailPanel'
import { LoginPage } from '@/components/LoginPage'
import { NodeModal } from '@/components/modals/NodeModal'
import { EdgeModal } from '@/components/modals/EdgeModal'
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
import { canvasApi } from '@/api/client'
import { demoNodes, demoEdges } from '@/utils/demoData'
import { useStatusPolling } from '@/hooks/useStatusPolling'
import type { NodeData, EdgeData } from '@/types'
export default function App() {
const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, onConnect, nodes } = useCanvasStore()
const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, onConnect, nodes, edges } = useCanvasStore()
const canvasRef = useRef<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore()
useStatusPolling()
const [addNodeOpen, setAddNodeOpen] = useState(false)
const [editNodeId, setEditNodeId] = useState<string | null>(null)
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
const [scanConfigOpen, setScanConfigOpen] = useState(false)
// Declare handleSave before the Ctrl+S effect so it is in scope
const handleSave = useCallback(async () => {
@@ -104,6 +112,23 @@ export default function App() {
setEditNodeId(null)
}, [editNodeId, updateNode])
const handleAutoLayout = useCallback(() => {
const laid = applyDagreLayout(nodes, edges)
loadCanvas(laid, edges)
toast.success('Canvas auto-arranged')
}, [nodes, edges, loadCanvas])
const handleExport = useCallback(async () => {
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
if (!el) { toast.error('Canvas not ready'); return }
try {
await exportToPng(el)
toast.success('Exported as PNG')
} catch {
toast.error('Export failed')
}
}, [])
const handleEdgeConnect = useCallback((connection: Connection) => {
setPendingConnection(connection)
}, [])
@@ -124,17 +149,19 @@ export default function App() {
<div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]">
<Sidebar
onAddNode={() => setAddNodeOpen(true)}
onScan={() => toast.info('Network scan not yet implemented')}
onScan={() => setScanConfigOpen(true)}
onSave={handleSave}
/>
<div className="flex flex-col flex-1 min-w-0">
<Toolbar
onSave={handleSave}
onAutoLayout={() => toast.info('Auto-layout not yet implemented')}
onExport={() => toast.info('Export not yet implemented')}
onAutoLayout={handleAutoLayout}
onExport={handleExport}
/>
<div className="flex flex-1 min-h-0">
<CanvasContainer onConnect={handleEdgeConnect} />
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
<CanvasContainer onConnect={handleEdgeConnect} />
</div>
{selectedNodeId && <DetailPanel onEdit={handleEditNode} />}
</div>
</div>
@@ -163,6 +190,12 @@ export default function App() {
onSubmit={handleEdgeConfirm}
/>
<ScanConfigModal
open={scanConfigOpen}
onClose={() => setScanConfigOpen(false)}
onScanNow={() => toast.success('Scan triggered')}
/>
<Toaster theme="dark" position="bottom-right" />
</ReactFlowProvider>
</TooltipProvider>
+12
View File
@@ -40,3 +40,15 @@ export const edgesApi = {
create: (data: object) => api.post('/edges', data),
delete: (id: string) => api.delete(`/edges/${id}`),
}
export const scanApi = {
trigger: () => api.post('/scan/trigger'),
pending: () => api.get('/scan/pending'),
hidden: () => api.get('/scan/hidden'),
runs: () => api.get('/scan/runs'),
approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData),
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
getConfig: () => api.get<{ ranges: string[]; interval_seconds: number }>('/scan/config'),
saveConfig: (data: { ranges: string[]; interval_seconds: number }) => api.post('/scan/config', data),
}
@@ -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>
)
}
+274 -5
View File
@@ -1,7 +1,9 @@
import { useState } from 'react'
import { Network, Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff } from 'lucide-react'
import { useState, useCallback } 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'
import { scanApi } from '@/api/client'
import { toast } from 'sonner'
type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history'
@@ -12,6 +14,28 @@ const VIEWS = [
{ id: 'history' as SidebarView, icon: Clock, label: 'Scan History' },
]
interface PendingDevice {
id: string
ip: string
mac: string | null
hostname: string | null
os: string | null
services: unknown[]
suggested_type: string | null
status: string
discovered_at: string
}
interface ScanRun {
id: string
status: string
ranges: string[]
devices_found: number
started_at: string
finished_at: string | null
error: string | null
}
interface SidebarProps {
onAddNode: () => void
onScan: () => void
@@ -26,6 +50,16 @@ export function Sidebar({ onAddNode, onScan, onSave }: SidebarProps) {
const onlineCount = nodes.filter((n) => n.data.status === 'online').length
const offlineCount = nodes.filter((n) => n.data.status === 'offline').length
const handleScan = useCallback(async () => {
try {
await scanApi.trigger()
toast.success('Network scan started')
onScan()
} catch {
toast.error('Failed to trigger scan')
}
}, [onScan])
return (
<aside
className="flex flex-col border-r border-border bg-[#161b22] transition-all duration-200 relative shrink-0"
@@ -50,7 +84,7 @@ export function Sidebar({ onAddNode, onScan, onSave }: SidebarProps) {
</div>
{/* Views */}
<nav className="flex flex-col gap-0.5 p-2 flex-1">
<nav className="flex flex-col gap-0.5 p-2">
{VIEWS.map(({ id, icon: Icon, label }) => (
<SidebarItem
key={id}
@@ -63,7 +97,21 @@ export function Sidebar({ onAddNode, onScan, onSave }: SidebarProps) {
))}
</nav>
{/* Stats */}
{/* View content (only when expanded) */}
{!collapsed && activeView !== 'canvas' && (
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
{activeView === 'pending' && <PendingDevicesPanel />}
{activeView === 'hidden' && <HiddenDevicesPanel />}
{activeView === 'history' && <ScanHistoryPanel />}
</div>
)}
{/* Stats (only on canvas view) */}
{!collapsed && activeView === 'canvas' && (
<div className="flex-1" />
)}
{/* Stats footer */}
{!collapsed && (
<div className="px-3 py-2 border-t border-border text-xs text-muted-foreground space-y-0.5">
<div className="flex justify-between">
@@ -84,7 +132,7 @@ export function Sidebar({ onAddNode, onScan, onSave }: SidebarProps) {
{/* Actions */}
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
<SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={onScan} />
<SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />
<SidebarItem
icon={Save}
label="Save Canvas"
@@ -98,6 +146,227 @@ export function Sidebar({ onAddNode, onScan, onSave }: SidebarProps) {
)
}
function PendingDevicesPanel() {
const [devices, setDevices] = useState<PendingDevice[]>([])
const [loading, setLoading] = useState(false)
const { addNode } = useCanvasStore()
const load = useCallback(async () => {
setLoading(true)
try {
const res = await scanApi.pending()
setDevices(res.data)
} catch {
toast.error('Failed to load pending devices')
} finally {
setLoading(false)
}
}, [])
// Load on mount
useState(() => { load() })
const handleApprove = async (device: PendingDevice) => {
try {
const nodeData = {
label: device.hostname ?? device.ip,
type: device.suggested_type ?? 'generic',
ip: device.ip,
hostname: device.hostname ?? undefined,
status: 'unknown',
services: device.services ?? [],
}
const res = await scanApi.approve(device.id, nodeData)
const nodeId = res.data.node_id
addNode({
id: nodeId,
type: nodeData.type,
position: { x: 400, y: 300 },
data: { ...nodeData, status: 'unknown' as const },
})
toast.success(`Approved ${nodeData.label}`)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
} catch {
toast.error('Failed to approve device')
}
}
const handleHide = async (id: string) => {
try {
await scanApi.hide(id)
setDevices((prev) => prev.filter((d) => d.id !== id))
toast.success('Device hidden')
} catch {
toast.error('Failed to hide device')
}
}
const handleIgnore = async (id: string) => {
try {
await scanApi.ignore(id)
setDevices((prev) => prev.filter((d) => d.id !== id))
} catch {
toast.error('Failed to ignore device')
}
}
return (
<div className="p-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pending</span>
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5">
<RefreshCw size={12} />
</button>
</div>
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
{!loading && devices.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
)}
{devices.map((d) => (
<div key={d.id} className="mb-2 p-2 rounded-md bg-[#21262d] text-xs">
<div className="font-mono text-foreground">{d.ip}</div>
{d.hostname && <div className="text-muted-foreground truncate">{d.hostname}</div>}
{d.os && <div className="text-muted-foreground truncate text-[10px]">{d.os}</div>}
<div className="flex gap-1 mt-1.5">
<ActionButton icon={Check} label="Approve" color="green" onClick={() => handleApprove(d)} />
<ActionButton icon={Hide} label="Hide" onClick={() => handleHide(d.id)} />
<ActionButton icon={Trash2} label="Ignore" color="red" onClick={() => handleIgnore(d.id)} />
</div>
</div>
))}
</div>
)
}
function HiddenDevicesPanel() {
const [devices, setDevices] = useState<PendingDevice[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const res = await scanApi.hidden()
setDevices(res.data)
} catch {
toast.error('Failed to load hidden devices')
} finally {
setLoading(false)
}
}, [])
useState(() => { load() })
const handleIgnore = async (id: string) => {
try {
await scanApi.ignore(id)
setDevices((prev) => prev.filter((d) => d.id !== id))
} catch {
toast.error('Failed to remove device')
}
}
return (
<div className="p-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Hidden</span>
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5">
<RefreshCw size={12} />
</button>
</div>
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
{!loading && devices.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">No hidden devices</p>
)}
{devices.map((d) => (
<div key={d.id} className="mb-2 p-2 rounded-md bg-[#21262d] text-xs">
<div className="font-mono text-foreground">{d.ip}</div>
{d.hostname && <div className="text-muted-foreground truncate">{d.hostname}</div>}
<div className="flex gap-1 mt-1.5">
<ActionButton icon={Trash2} label="Remove" color="red" onClick={() => handleIgnore(d.id)} />
</div>
</div>
))}
</div>
)
}
function ScanHistoryPanel() {
const [runs, setRuns] = useState<ScanRun[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const res = await scanApi.runs()
setRuns(res.data)
} catch {
toast.error('Failed to load scan history')
} finally {
setLoading(false)
}
}, [])
useState(() => { load() })
const statusColor = (s: string) =>
s === 'completed' ? '#39d353' : s === 'running' ? '#e3b341' : s === 'failed' ? '#f85149' : '#8b949e'
return (
<div className="p-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">History</span>
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5">
<RefreshCw size={12} />
</button>
</div>
{loading && <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>
)}
{runs.map((r) => (
<div key={r.id} className="mb-2 p-2 rounded-md bg-[#21262d] text-xs">
<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>
<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">
{new Date(r.started_at).toLocaleString()}
</div>
{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>}
</div>
))}
</div>
)
}
interface ActionButtonProps {
icon: React.ElementType
label: string
color?: 'green' | 'red'
onClick: () => void
}
function ActionButton({ icon: Icon, label, color, onClick }: ActionButtonProps) {
const colorClass =
color === 'green' ? 'text-[#39d353] hover:bg-[#39d353]/10' :
color === 'red' ? 'text-[#f85149] hover:bg-[#f85149]/10' :
'text-muted-foreground hover:text-foreground hover:bg-[#30363d]'
return (
<Tooltip>
<TooltipTrigger>
<button onClick={onClick} className={`p-1 rounded ${colorClass} transition-colors`}>
<Icon size={11} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
)
}
interface SidebarItemProps {
icon: React.ElementType
label: string
+49
View File
@@ -0,0 +1,49 @@
import { useEffect, useRef } from 'react'
import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
interface StatusMessage {
node_id: string
status: 'online' | 'offline' | 'pending' | 'unknown'
checked_at: string
response_time_ms: number | null
}
export function useStatusPolling() {
const wsRef = useRef<WebSocket | null>(null)
const { updateNode } = useCanvasStore()
const { isAuthenticated, token } = useAuthStore()
useEffect(() => {
if (!isAuthenticated || !token) return
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
const host = window.location.hostname
const url = `${protocol}://${host}:8000/api/v1/status/ws/status`
const ws = new WebSocket(url)
wsRef.current = ws
ws.onmessage = (event) => {
try {
const msg: StatusMessage = JSON.parse(event.data)
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 {
// ignore malformed messages
}
}
ws.onerror = () => {
// silently ignore — backend may not be running in dev
}
return () => {
ws.close()
wsRef.current = null
}
}, [isAuthenticated, token, updateNode])
}
+12 -6
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface AuthState {
token: string | null
@@ -7,9 +8,14 @@ interface AuthState {
logout: () => void
}
export const useAuthStore = create<AuthState>((set) => ({
token: null,
isAuthenticated: false,
login: (token) => set({ token, isAuthenticated: true }),
logout: () => set({ token: null, isAuthenticated: false }),
}))
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
isAuthenticated: false,
login: (token) => set({ token, isAuthenticated: true }),
logout: () => set({ token: null, isAuthenticated: false }),
}),
{ name: 'homelable-auth' }
)
)
+20
View File
@@ -0,0 +1,20 @@
import { toPng } from 'html-to-image'
/**
* Export the React Flow canvas as a PNG and trigger a browser download.
* Pass the `.react-flow` wrapper element.
*/
export async function exportToPng(element: HTMLElement): Promise<void> {
const dataUrl = await toPng(element, {
backgroundColor: '#0d1117',
style: {
// Exclude controls and minimap from the export
'--xy-controls-display': 'none',
},
})
const link = document.createElement('a')
link.download = 'homelable-canvas.png'
link.href = dataUrl
link.click()
}
+39
View File
@@ -0,0 +1,39 @@
import dagre from '@dagrejs/dagre'
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
const NODE_WIDTH = 180
const NODE_HEIGHT = 52
/**
* Apply Dagre hierarchical (top-to-bottom) layout to nodes and edges.
* Returns new nodes with updated positions.
*/
export function applyDagreLayout(
nodes: Node<NodeData>[],
edges: Edge<EdgeData>[],
): Node<NodeData>[] {
const g = new dagre.graphlib.Graph()
g.setDefaultEdgeLabel(() => ({}))
g.setGraph({ rankdir: 'TB', nodesep: 60, ranksep: 80 })
for (const node of nodes) {
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
}
for (const edge of edges) {
g.setEdge(edge.source, edge.target)
}
dagre.layout(g)
return nodes.map((node) => {
const pos = g.node(node.id)
return {
...node,
position: {
x: pos.x - NODE_WIDTH / 2,
y: pos.y - NODE_HEIGHT / 2,
},
}
})
}