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
+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,
},
}
})
}