From 150302c3f7b6785901c147c17c2e5fecc3d75fde Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 6 Mar 2026 23:33:55 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202=20=E2=80=94=20auth,=20node/ed?= =?UTF-8?q?ge=20modals,=20API=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Login page: full-screen dark, grid bg, JWT flow - Auth store (Zustand) + API client (axios + interceptors) - NodeModal: add/edit form (type, label, hostname, IP, check method) - EdgeModal: link type picker + VLAN ID + label on connect - App: auth gate, canvas load from API (fallback to demo), Ctrl+S save - CanvasContainer: expose onConnect prop for edge modal flow --- frontend/src/App.tsx | 141 +++++++++--- frontend/src/api/client.ts | 42 ++++ frontend/src/components/LoginPage.tsx | 103 +++++++++ .../src/components/canvas/CanvasContainer.tsx | 11 +- frontend/src/components/modals/EdgeModal.tsx | 92 ++++++++ frontend/src/components/modals/NodeModal.tsx | 162 ++++++++++++++ frontend/src/components/ui/input.tsx | 20 ++ frontend/src/components/ui/label.tsx | 18 ++ frontend/src/components/ui/select.tsx | 201 ++++++++++++++++++ frontend/src/stores/authStore.ts | 15 ++ 10 files changed, 776 insertions(+), 29 deletions(-) create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/components/LoginPage.tsx create mode 100644 frontend/src/components/modals/EdgeModal.tsx create mode 100644 frontend/src/components/modals/NodeModal.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/components/ui/select.tsx create mode 100644 frontend/src/stores/authStore.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4a9c936..6f0e4ac 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ -import { useEffect, useCallback } from 'react' -import { ReactFlowProvider } from '@xyflow/react' +import { useEffect, useCallback, useState } from 'react' +import { ReactFlowProvider, type Connection } from '@xyflow/react' +import { type Node } from '@xyflow/react' import { TooltipProvider } from '@/components/ui/tooltip' import { Toaster } from '@/components/ui/sonner' import { toast } from 'sonner' @@ -7,18 +8,53 @@ import { CanvasContainer } from '@/components/canvas/CanvasContainer' import { Sidebar } from '@/components/panels/Sidebar' import { Toolbar } from '@/components/panels/Toolbar' import { DetailPanel } from '@/components/panels/DetailPanel' +import { LoginPage } from '@/components/LoginPage' +import { NodeModal } from '@/components/modals/NodeModal' +import { EdgeModal } from '@/components/modals/EdgeModal' import { useCanvasStore } from '@/stores/canvasStore' +import { useAuthStore } from '@/stores/authStore' +import { canvasApi } from '@/api/client' import { demoNodes, demoEdges } from '@/utils/demoData' +import type { NodeData, EdgeData } from '@/types' export default function App() { - const { loadCanvas, markSaved, selectedNodeId } = useCanvasStore() + const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, onConnect, nodes } = useCanvasStore() + const { isAuthenticated } = useAuthStore() - // Load demo data on start + const [addNodeOpen, setAddNodeOpen] = useState(false) + const [editNodeId, setEditNodeId] = useState(null) + const [pendingConnection, setPendingConnection] = useState(null) + + // Load canvas on auth useEffect(() => { - loadCanvas(demoNodes, demoEdges) - }, [loadCanvas]) + if (!isAuthenticated) return + canvasApi.load() + .then((res) => { + const { nodes: apiNodes, edges: apiEdges } = res.data + if (apiNodes.length > 0) { + // Map API response to React Flow nodes + const rfNodes = apiNodes.map((n: NodeData & { id: string; pos_x: number; pos_y: number }) => ({ + id: n.id, + type: n.type, + position: { x: n.pos_x, y: n.pos_y }, + data: n, + })) + const rfEdges = apiEdges.map((e: EdgeData & { id: string; source: string; target: string }) => ({ + id: e.id, + source: e.source, + target: e.target, + type: e.type, + data: e, + })) + loadCanvas(rfNodes, rfEdges) + } else { + loadCanvas(demoNodes, demoEdges) + } + }) + .catch(() => loadCanvas(demoNodes, demoEdges)) + }, [isAuthenticated, loadCanvas]) - // Ctrl+S shortcut + // Ctrl+S useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { @@ -30,45 +66,98 @@ export default function App() { return () => window.removeEventListener('keydown', handler) }) - const handleSave = useCallback(() => { - // TODO: POST /api/v1/canvas/save - markSaved() - toast.success('Canvas saved') - }, [markSaved]) + const handleSave = useCallback(async () => { + try { + const nodePositions = nodes.map((n) => ({ id: n.id, x: n.position.x, y: n.position.y })) + await canvasApi.save({ node_positions: nodePositions, viewport: {} }) + markSaved() + toast.success('Canvas saved') + } catch { + // Backend not running — mark saved anyway in dev + markSaved() + toast.success('Canvas saved (local)') + } + }, [nodes, markSaved]) - const handleScan = useCallback(() => { - toast.info('Network scan not yet implemented') + const handleAddNode = useCallback((data: Partial) => { + const id = crypto.randomUUID() + const newNode: Node = { + id, + type: data.type ?? 'generic', + position: { x: 300, y: 300 }, + data: { status: 'unknown', services: [], ...data } as NodeData, + } + addNode(newNode) + toast.success(`Added "${data.label}"`) + }, [addNode]) + + const handleEditNode = useCallback((id: string) => { + setEditNodeId(id) }, []) - const handleAutoLayout = useCallback(() => { - toast.info('Auto-layout not yet implemented') + const handleUpdateNode = useCallback((data: Partial) => { + if (!editNodeId) return + updateNode(editNodeId, data) + setEditNodeId(null) + }, [editNodeId, updateNode]) + + const handleEdgeConnect = useCallback((connection: Connection) => { + setPendingConnection(connection) }, []) - const handleExport = useCallback(() => { - toast.info('Export not yet implemented') - }, []) + const handleEdgeConfirm = useCallback((edgeData: EdgeData) => { + if (!pendingConnection) return + onConnect({ ...pendingConnection, ...edgeData }) + setPendingConnection(null) + }, [pendingConnection, onConnect]) - const handleEditNode = useCallback((_id: string) => { - toast.info('Edit node — not yet implemented') - }, []) + const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null + + if (!isAuthenticated) return return (
toast.info('Add node — not yet implemented')} - onScan={handleScan} + onAddNode={() => setAddNodeOpen(true)} + onScan={() => toast.info('Network scan not yet implemented')} onSave={handleSave} />
- + toast.info('Auto-layout not yet implemented')} + onExport={() => toast.info('Export not yet implemented')} + />
- + {selectedNodeId && }
+ + setAddNodeOpen(false)} + onSubmit={handleAddNode} + title="Add Node" + /> + + setEditNodeId(null)} + onSubmit={handleUpdateNode} + initial={editNode?.data} + title="Edit Node" + /> + + setPendingConnection(null)} + onSubmit={handleEdgeConfirm} + /> +
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..ef5a876 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,42 @@ +import axios from 'axios' +import { useAuthStore } from '@/stores/authStore' + +export const api = axios.create({ + baseURL: '/api/v1', +}) + +api.interceptors.request.use((config) => { + const token = useAuthStore.getState().token + if (token) config.headers.Authorization = `Bearer ${token}` + return config +}) + +api.interceptors.response.use( + (r) => r, + (err) => { + if (err.response?.status === 401) useAuthStore.getState().logout() + return Promise.reject(err) + } +) + +export const authApi = { + login: (username: string, password: string) => + api.post<{ access_token: string }>('/auth/login', { username, password }), +} + +export const canvasApi = { + load: () => api.get('/canvas'), + save: (payload: { node_positions: { id: string; x: number; y: number }[]; viewport: object }) => + api.post('/canvas/save', payload), +} + +export const nodesApi = { + create: (data: object) => api.post('/nodes', data), + update: (id: string, data: object) => api.patch(`/nodes/${id}`, data), + delete: (id: string) => api.delete(`/nodes/${id}`), +} + +export const edgesApi = { + create: (data: object) => api.post('/edges', data), + delete: (id: string) => api.delete(`/edges/${id}`), +} diff --git a/frontend/src/components/LoginPage.tsx b/frontend/src/components/LoginPage.tsx new file mode 100644 index 0000000..7fbbb0e --- /dev/null +++ b/frontend/src/components/LoginPage.tsx @@ -0,0 +1,103 @@ +import { useState } from 'react' +import { Network, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { authApi } from '@/api/client' +import { useAuthStore } from '@/stores/authStore' + +export function LoginPage() { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const login = useAuthStore((s) => s.login) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setLoading(true) + try { + const res = await authApi.login(username, password) + login(res.data.access_token) + } catch { + setError('Invalid username or password') + } finally { + setLoading(false) + } + } + + return ( +
+ {/* Grid background */} +
+ +
+ {/* Logo */} +
+
+ +
+
+

Homelable

+

HomeLab Visualizer

+
+
+ + {/* Form */} +
+
+ + setUsername(e.target.value)} + className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm" + placeholder="admin" + required + /> +
+ +
+ + setPassword(e.target.value)} + className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm" + required + /> +
+ + {error && ( +

{error}

+ )} + + +
+ +

+ Credentials configured in config.yml +

+
+
+ ) +} diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index 0d81f90..f7814c6 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -7,6 +7,7 @@ import { BackgroundVariant, type Node, type Edge, + type Connection, } from '@xyflow/react' import '@xyflow/react/dist/style.css' import { useCanvasStore } from '@/stores/canvasStore' @@ -14,10 +15,14 @@ import { nodeTypes } from './nodes' import { edgeTypes } from './edges' import type { NodeData, EdgeData } from '@/types' -export function CanvasContainer() { +interface CanvasContainerProps { + onConnect?: (connection: Connection) => void +} + +export function CanvasContainer({ onConnect: onConnectProp }: CanvasContainerProps) { const { nodes, edges, - onNodesChange, onEdgesChange, onConnect, + onNodesChange, onEdgesChange, setSelectedNode, } = useCanvasStore() @@ -36,7 +41,7 @@ export function CanvasContainer() { edges={edges as Edge[]} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} - onConnect={onConnect} + onConnect={onConnectProp} onNodeClick={onNodeClick} onPaneClick={onPaneClick} nodeTypes={nodeTypes} diff --git a/frontend/src/components/modals/EdgeModal.tsx b/frontend/src/components/modals/EdgeModal.tsx new file mode 100644 index 0000000..4295426 --- /dev/null +++ b/frontend/src/components/modals/EdgeModal.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { EDGE_TYPE_LABELS, type EdgeData, type EdgeType } from '@/types' + +const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][] + +interface EdgeModalProps { + open: boolean + onClose: () => void + onSubmit: (data: EdgeData) => void +} + +export function EdgeModal({ open, onClose, onSubmit }: EdgeModalProps) { + const [type, setType] = useState('ethernet') + const [label, setLabel] = useState('') + const [vlanId, setVlanId] = useState('') + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSubmit({ + type, + label: label || undefined, + vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined, + }) + onClose() + setType('ethernet') + setLabel('') + setVlanId('') + } + + return ( + !o && onClose()}> + + + Connect Nodes + + +
+
+ + +
+ + {type === 'vlan' && ( +
+ + setVlanId(e.target.value)} + placeholder="e.g. 20" + className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8" + /> +
+ )} + +
+ + setLabel(e.target.value)} + placeholder="e.g. 1G, trunk..." + className="bg-[#21262d] border-[#30363d] text-sm h-8" + /> +
+ +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx new file mode 100644 index 0000000..595498e --- /dev/null +++ b/frontend/src/components/modals/NodeModal.tsx @@ -0,0 +1,162 @@ +import { useState, useEffect } from 'react' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types' + +const NODE_TYPES = Object.entries(NODE_TYPE_LABELS) as [NodeType, string][] + +const CHECK_METHODS: CheckMethod[] = ['ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health'] + +const DEFAULT_DATA: Partial = { + type: 'server', + label: '', + hostname: '', + ip: '', + status: 'unknown', + check_method: 'ping', + services: [], +} + +interface NodeModalProps { + open: boolean + onClose: () => void + onSubmit: (data: Partial) => void + initial?: Partial + title?: string +} + +export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' }: NodeModalProps) { + const [form, setForm] = useState>({ ...DEFAULT_DATA, ...initial }) + + useEffect(() => { + setForm({ ...DEFAULT_DATA, ...initial }) + }, [initial, open]) + + const set = (key: keyof NodeData, value: unknown) => + setForm((f) => ({ ...f, [key]: value })) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!form.label?.trim()) return + onSubmit(form) + onClose() + } + + return ( + !o && onClose()}> + + + {title} + + +
+
+ {/* Type */} +
+ + +
+ + {/* Label */} +
+ + set('label', e.target.value)} + placeholder="My Server" + className="bg-[#21262d] border-[#30363d] text-sm h-8" + required + /> +
+ + {/* Hostname */} +
+ + set('hostname', e.target.value)} + placeholder="server.lan" + className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8" + /> +
+ + {/* IP */} +
+ + set('ip', e.target.value)} + placeholder="192.168.1.x" + className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8" + /> +
+ + {/* Check method */} +
+ + +
+ + {/* Check target */} +
+ + set('check_target', e.target.value)} + placeholder="http://..." + className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8" + /> +
+ + {/* Notes */} +
+ + set('notes', e.target.value)} + placeholder="Optional notes" + className="bg-[#21262d] border-[#30363d] text-sm h-8" + /> +
+
+ +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..7d21bab --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import { Input as InputPrimitive } from "@base-ui/react/input" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..f162996 --- /dev/null +++ b/frontend/src/components/ui/label.tsx @@ -0,0 +1,18 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( +