diff --git a/client/src/App.tsx b/client/src/App.tsx index 4742ead..df6b026 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -5,6 +5,7 @@ import { LoginPage } from './components/auth/LoginPage'; import { ProtectedRoute } from './components/auth/ProtectedRoute'; import { RackPlanner } from './components/rack/RackPlanner'; import { ServiceMapper } from './components/mapper/ServiceMapper'; +import { VlanPage } from './components/vlans/VlanPage'; import { Skeleton } from './components/ui/Skeleton'; export default function App() { @@ -46,6 +47,14 @@ export default function App() { } /> + + + + } + /> } /> diff --git a/client/src/components/mapper/MapToolbar.tsx b/client/src/components/mapper/MapToolbar.tsx index dc59c88..d812979 100644 --- a/client/src/components/mapper/MapToolbar.tsx +++ b/client/src/components/mapper/MapToolbar.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Download, Server, LogOut, ChevronDown } from 'lucide-react'; +import { Download, Server, LogOut, ChevronDown, Tag } from 'lucide-react'; import { toast } from 'sonner'; import { toPng } from 'html-to-image'; import { useReactFlow } from '@xyflow/react'; @@ -119,6 +119,10 @@ export function MapToolbar({ Rack Planner + {activeMapId && ( <> + + + ); +} + +// ---- Inline editable row ---- + +interface VlanRowProps { + vlan: Vlan; + onUpdated: (v: Vlan) => void; + onDeleted: (id: string) => void; +} + +function VlanRow({ vlan, onUpdated, onDeleted }: VlanRowProps) { + const [editing, setEditing] = useState(false); + const [name, setName] = useState(vlan.name); + const [description, setDescription] = useState(vlan.description ?? ''); + const [color, setColor] = useState(vlan.color ?? DEFAULT_COLOR); + const [saving, setSaving] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [deleting, setDeleting] = useState(false); + + function startEdit() { + setName(vlan.name); + setDescription(vlan.description ?? ''); + setColor(vlan.color ?? DEFAULT_COLOR); + setEditing(true); + } + + function cancelEdit() { + setEditing(false); + } + + async function handleSave() { + if (!name.trim()) return; + setSaving(true); + try { + const updated = await apiClient.vlans.update(vlan.id, { + name: name.trim(), + description: description.trim() || undefined, + color, + }); + onUpdated(updated); + setEditing(false); + toast.success('VLAN updated'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Update failed'); + } finally { + setSaving(false); + } + } + + async function handleDelete() { + setDeleting(true); + try { + await apiClient.vlans.delete(vlan.id); + onDeleted(vlan.id); + toast.success(`VLAN ${vlan.vlanId} deleted`); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Delete failed'); + } finally { + setDeleting(false); + setConfirmDelete(false); + } + } + + if (editing) { + return ( + <> + + + {vlan.vlanId} + + + setName(e.target.value)} + autoFocus + className="w-full bg-slate-900 border border-slate-600 rounded px-2 py-1 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + + setDescription(e.target.value)} + placeholder="Description" + className="w-full bg-slate-900 border border-slate-600 rounded px-2 py-1 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + +
+ setColor(e.target.value)} + className="w-7 h-7 rounded cursor-pointer bg-transparent border border-slate-600 p-0.5" + /> + {color} +
+ + +
+ + +
+ + + + ); + } + + return ( + <> + + + {vlan.vlanId} + + {vlan.name} + + {vlan.description ?? —} + + +
+
+ + {vlan.color ?? DEFAULT_COLOR} + +
+ + +
+ + +
+ + + + setConfirmDelete(false)} + onConfirm={handleDelete} + title="Delete VLAN" + message={`Delete VLAN ${vlan.vlanId} "${vlan.name}"? Port assignments using this VLAN will be removed.`} + confirmLabel="Delete" + loading={deleting} + /> + + ); +} + +// ---- Main page ---- + +export function VlanPage() { + const navigate = useNavigate(); + const { logout } = useAuthStore(); + const [vlans, setVlans] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + apiClient.vlans + .list() + .then((v) => setVlans(v.sort((a, b) => a.vlanId - b.vlanId))) + .catch(() => toast.error('Failed to load VLANs')) + .finally(() => setLoading(false)); + }, []); + + function handleCreated(v: Vlan) { + setVlans((prev) => [...prev, v].sort((a, b) => a.vlanId - b.vlanId)); + } + + function handleUpdated(v: Vlan) { + setVlans((prev) => prev.map((x) => (x.id === v.id ? v : x))); + } + + function handleDeleted(id: string) { + setVlans((prev) => prev.filter((x) => x.id !== id)); + } + + async function handleLogout() { + await logout(); + navigate('/login', { replace: true }); + } + + return ( +
+ {/* Toolbar */} +
+
+
+
+ + + + + +
+ RACKMAPPER +
+ VLAN Management +
+ +
+ + + +
+
+ + {/* Content */} +
+
+
+ +
+
+

VLANs

+

+ Define and manage VLAN labels for port configuration +

+
+
+ {vlans.length} VLANs +
+
+ +
+ + + {loading ? ( +
Loading…
+ ) : vlans.length === 0 ? ( +
+ No VLANs defined yet. Add one above. +
+ ) : ( +
+ + + + + + + + + + + {vlans.map((v) => ( + + ))} + +
+ ID + + Name + + Description + + Color + +
+
+ )} +
+
+
+ ); +}