diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e6b5be2..d83ff2d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { LoginPage } from '@/components/LoginPage' import { NodeModal } from '@/components/modals/NodeModal' import { EdgeModal } from '@/components/modals/EdgeModal' import { ScanConfigModal } from '@/components/modals/ScanConfigModal' +import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal' import { useCanvasStore } from '@/stores/canvasStore' import { useAuthStore } from '@/stores/authStore' import { canvasApi } from '@/api/client' @@ -25,13 +26,14 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE_STORAGE_KEY = 'homelable_canvas' export default function App() { - const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, nodes, edges } = useCanvasStore() + const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges } = useCanvasStore() const canvasRef = useRef(null) const { isAuthenticated } = useAuthStore() useStatusPolling() const [addNodeOpen, setAddNodeOpen] = useState(false) + const [addGroupRectOpen, setAddGroupRectOpen] = useState(false) const [editNodeId, setEditNodeId] = useState(null) const [pendingConnection, setPendingConnection] = useState(null) const [editEdgeId, setEditEdgeId] = useState(null) @@ -46,26 +48,55 @@ export default function App() { toast.success('Canvas saved') return } - const nodesToSave = nodes.map((n) => ({ - id: n.id, - type: n.data.type, - label: n.data.label, - hostname: n.data.hostname ?? null, - ip: n.data.ip ?? null, - mac: n.data.mac ?? null, - os: n.data.os ?? null, - status: n.data.status, - check_method: n.data.check_method ?? null, - check_target: n.data.check_target ?? null, - services: n.data.services ?? [], - notes: n.data.notes ?? null, - parent_id: n.data.parent_id ?? null, - container_mode: n.data.container_mode ?? false, - custom_colors: n.data.custom_colors ?? null, - custom_icon: n.data.custom_icon ?? null, - pos_x: n.position.x, - pos_y: n.position.y, - })) + const nodesToSave = nodes.map((n) => { + if (n.data.type === 'groupRect') { + return { + id: n.id, + type: 'groupRect', + label: n.data.label, + hostname: null, + ip: null, + mac: null, + os: null, + status: 'unknown', + check_method: null, + check_target: null, + services: [], + notes: null, + parent_id: null, + container_mode: false, + custom_icon: null, + pos_x: n.position.x, + pos_y: n.position.y, + // Persist size and all rect config inside custom_colors + custom_colors: { + ...n.data.custom_colors, + width: n.measured?.width ?? n.width ?? 360, + height: n.measured?.height ?? n.height ?? 240, + }, + } + } + return { + id: n.id, + type: n.data.type, + label: n.data.label, + hostname: n.data.hostname ?? null, + ip: n.data.ip ?? null, + mac: n.data.mac ?? null, + os: n.data.os ?? null, + status: n.data.status, + check_method: n.data.check_method ?? null, + check_target: n.data.check_target ?? null, + services: n.data.services ?? [], + notes: n.data.notes ?? null, + parent_id: n.data.parent_id ?? null, + container_mode: n.data.container_mode ?? false, + custom_colors: n.data.custom_colors ?? null, + custom_icon: n.data.custom_icon ?? null, + pos_x: n.position.x, + pos_y: n.position.y, + } + }) const edgesToSave = edges.map((e) => ({ id: e.id, source: e.source, @@ -121,6 +152,20 @@ export default function App() { .map((n: NodeData & { id: string }) => [n.id, n.container_mode !== false]) ) const rfNodes = apiNodes.map((n: NodeData & { id: string; pos_x: number; pos_y: number; parent_id?: string }) => { + if (n.type === 'groupRect') { + const w = n.custom_colors?.width ?? 360 + const h = n.custom_colors?.height ?? 240 + const z = n.custom_colors?.z_order ?? 1 + return { + id: n.id, + type: 'groupRect', + position: { x: n.pos_x, y: n.pos_y }, + data: n, + width: w, + height: h, + zIndex: z - 10, + } + } const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false return { id: n.id, @@ -181,6 +226,58 @@ export default function App() { toast.success(`Added "${data.label}"`) }, [addNode, nodes]) + const handleAddGroupRect = useCallback((data: GroupRectFormData) => { + const id = crypto.randomUUID() + const newNode: Node = { + id, + type: 'groupRect', + position: { x: 200, y: 200 }, + data: { + label: data.label, + type: 'groupRect', + status: 'unknown', + services: [], + custom_colors: { + border: data.border_color, + background: data.background_color, + text_color: data.text_color, + text_position: data.text_position, + font: data.font, + z_order: data.z_order, + }, + }, + width: 360, + height: 240, + zIndex: data.z_order - 10, + } + addNode(newNode) + }, [addNode]) + + const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => { + if (!editingGroupRectId) return + const existing = nodes.find((n) => n.id === editingGroupRectId) + updateNode(editingGroupRectId, { + label: data.label, + custom_colors: { + ...existing?.data.custom_colors, + border: data.border_color, + background: data.background_color, + text_color: data.text_color, + text_position: data.text_position, + font: data.font, + z_order: data.z_order, + }, + }) + setNodeZIndex(editingGroupRectId, data.z_order - 10) + setEditingGroupRectId(null) + }, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId]) + + const handleDeleteGroupRect = useCallback(() => { + if (!editingGroupRectId) return + deleteNode(editingGroupRectId) + setEditingGroupRectId(null) + }, [editingGroupRectId, deleteNode, setEditingGroupRectId]) + const handleEditNode = useCallback((id: string) => { setEditNodeId(id) }, []) @@ -284,6 +381,7 @@ export default function App() {
setAddNodeOpen(true)} + onAddGroupRect={() => setAddGroupRectOpen(true)} onScan={() => setScanConfigOpen(true)} onSave={handleSave} onNodeApproved={setEditNodeId} @@ -352,6 +450,37 @@ export default function App() { /> )} + setAddGroupRectOpen(false)} + onSubmit={handleAddGroupRect} + title="Add Rectangle" + /> + + {/* key forces re-mount when editing a different rect */} + setEditingGroupRectId(null)} + onSubmit={handleUpdateGroupRect} + onDelete={handleDeleteGroupRect} + initial={(() => { + const n = editingGroupRectId ? nodes.find((nd) => nd.id === editingGroupRectId) : null + if (!n) return undefined + const rc = n.data.custom_colors ?? {} + return { + label: n.data.label, + font: rc.font ?? 'inter', + text_color: rc.text_color ?? '#e6edf3', + text_position: rc.text_position ?? 'top-left', + border_color: rc.border ?? '#00d4ff', + background_color: rc.background ?? '#00d4ff0d', + z_order: rc.z_order ?? 1, + } + })()} + title="Edit Rectangle" + /> + diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index ab9aaf0..dddc17c 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -57,6 +57,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }: snapGrid={[16, 16]} fitView colorMode="dark" + elevateNodesOnSelect={false} connectionMode={ConnectionMode.Loose} isValidConnection={(connection) => connection.source !== connection.target} > diff --git a/frontend/src/components/canvas/nodes/GroupRectNode.tsx b/frontend/src/components/canvas/nodes/GroupRectNode.tsx new file mode 100644 index 0000000..4055ef8 --- /dev/null +++ b/frontend/src/components/canvas/nodes/GroupRectNode.tsx @@ -0,0 +1,86 @@ +import { NodeResizer, type NodeProps, type Node } from '@xyflow/react' +import { useCanvasStore } from '@/stores/canvasStore' +import type { NodeData, TextPosition } from '@/types' + +const FONT_FAMILIES: Record = { + inter: 'Inter, sans-serif', + mono: '"JetBrains Mono", monospace', + serif: 'Georgia, serif', +} + +interface AlignStyle { + alignItems: string + justifyContent: string + textAlign: React.CSSProperties['textAlign'] +} + +const POSITION_STYLES: Record = { + 'top-left': { alignItems: 'flex-start', justifyContent: 'flex-start', textAlign: 'left' }, + 'top-center': { alignItems: 'flex-start', justifyContent: 'center', textAlign: 'center' }, + 'top-right': { alignItems: 'flex-start', justifyContent: 'flex-end', textAlign: 'right' }, + 'middle-left': { alignItems: 'center', justifyContent: 'flex-start', textAlign: 'left' }, + 'center': { alignItems: 'center', justifyContent: 'center', textAlign: 'center' }, + 'middle-right': { alignItems: 'center', justifyContent: 'flex-end', textAlign: 'right' }, + 'bottom-left': { alignItems: 'flex-end', justifyContent: 'flex-start', textAlign: 'left' }, + 'bottom-center': { alignItems: 'flex-end', justifyContent: 'center', textAlign: 'center' }, + 'bottom-right': { alignItems: 'flex-end', justifyContent: 'flex-end', textAlign: 'right' }, +} + +export function GroupRectNode({ id, data, selected }: NodeProps>) { + const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId) + + const rc = data.custom_colors ?? {} + const borderColor = rc.border ?? '#00d4ff' + const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)' + const textColor = rc.text_color ?? '#e6edf3' + const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter + const textPos = (rc.text_position ?? 'top-left') as TextPosition + const posStyle = POSITION_STYLES[textPos] + + return ( + <> + +
{ + e.stopPropagation() + setEditingGroupRectId(id) + }} + > + {data.label && ( + + {data.label} + + )} +
+ + ) +} diff --git a/frontend/src/components/canvas/nodes/nodeTypes.ts b/frontend/src/components/canvas/nodes/nodeTypes.ts index ea757b3..28d8953 100644 --- a/frontend/src/components/canvas/nodes/nodeTypes.ts +++ b/frontend/src/components/canvas/nodes/nodeTypes.ts @@ -1,5 +1,6 @@ import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, GenericNode } from './index' import { ProxmoxGroupNode } from './ProxmoxGroupNode' +import { GroupRectNode } from './GroupRectNode' export const nodeTypes = { isp: IspNode, @@ -17,4 +18,5 @@ export const nodeTypes = { computer: ComputerNode, cpl: CplNode, generic: GenericNode, + groupRect: GroupRectNode, } diff --git a/frontend/src/components/modals/GroupRectModal.tsx b/frontend/src/components/modals/GroupRectModal.tsx new file mode 100644 index 0000000..677d68b --- /dev/null +++ b/frontend/src/components/modals/GroupRectModal.tsx @@ -0,0 +1,202 @@ +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 type { TextPosition } from '@/types' + +export interface GroupRectFormData { + label: string + font: string + text_color: string + text_position: TextPosition + border_color: string + background_color: string + z_order: number +} + +const DEFAULT_FORM: GroupRectFormData = { + label: '', + font: 'inter', + text_color: '#e6edf3', + text_position: 'top-left', + border_color: '#00d4ff', + background_color: '#00d4ff0d', + z_order: 1, +} + +const FONTS = [ + { value: 'inter', label: 'Inter (sans-serif)' }, + { value: 'mono', label: 'JetBrains Mono' }, + { value: 'serif', label: 'Serif' }, +] + +const TEXT_POSITIONS: { value: TextPosition; label: string }[] = [ + { value: 'top-left', label: '↖' }, + { value: 'top-center', label: '↑' }, + { value: 'top-right', label: '↗' }, + { value: 'middle-left', label: '←' }, + { value: 'center', label: '·' }, + { value: 'middle-right', label: '→' }, + { value: 'bottom-left', label: '↙' }, + { value: 'bottom-center', label: '↓' }, + { value: 'bottom-right', label: '↘' }, +] + +interface GroupRectModalProps { + open: boolean + onClose: () => void + onSubmit: (data: GroupRectFormData) => void + onDelete?: () => void + initial?: Partial + title?: string +} + +export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Rectangle' }: GroupRectModalProps) { + const [form, setForm] = useState({ ...DEFAULT_FORM, ...initial }) + + const set = (key: K, value: GroupRectFormData[K]) => + setForm((f) => ({ ...f, [key]: value })) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSubmit(form) + onClose() + } + + const colorFields = [ + { key: 'text_color' as const, label: 'Text' }, + { key: 'border_color' as const, label: 'Border' }, + { key: 'background_color' as const, label: 'Background' }, + ] + + return ( + !o && onClose()}> + + + {title} + + +
+ {/* Label */} +
+ + set('label', e.target.value)} + placeholder="Zone name…" + className="bg-[#21262d] border-[#30363d] text-sm h-8" + /> +
+ + {/* Font */} +
+ + +
+ + {/* Text position 3×3 grid */} +
+ +
+ {TEXT_POSITIONS.map(({ value, label }) => { + const isSelected = form.text_position === value + return ( + + ) + })} +
+
+ + {/* Colors */} +
+ +
+ {colorFields.map(({ key, label }) => ( +
+
+
+ + {/* Z-order */} +
+ + +
+ +
+ {onDelete && ( + + )} +
+ + +
+
+ + +
+ ) +} diff --git a/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx b/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx new file mode 100644 index 0000000..c3a9b73 --- /dev/null +++ b/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { GroupRectModal, type GroupRectFormData } from '../GroupRectModal' + +describe('GroupRectModal', () => { + it('renders nothing when closed', () => { + const { container } = render( + + ) + expect(container.querySelector('[role="dialog"]')).toBeNull() + }) + + it('renders form fields when open', () => { + render() + expect(screen.getByPlaceholderText('Zone name…')).toBeDefined() + expect(screen.getByText('Add Rectangle')).toBeDefined() + expect(screen.getByText('Text Position')).toBeDefined() + expect(screen.getByText('Z-Order (1 = furthest back)')).toBeDefined() + }) + + it('renders Edit Rectangle title when provided', () => { + render() + expect(screen.getByText('Edit Rectangle')).toBeDefined() + }) + + it('calls onSubmit with form data on submit', () => { + const onSubmit = vi.fn() + render() + const input = screen.getByPlaceholderText('Zone name…') + fireEvent.change(input, { target: { value: 'DMZ' } }) + fireEvent.click(screen.getByText('Add')) + expect(onSubmit).toHaveBeenCalledOnce() + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.label).toBe('DMZ') + expect(submitted.font).toBe('inter') + expect(submitted.z_order).toBe(1) + }) + + it('calls onClose when Cancel is clicked', () => { + const onClose = vi.fn() + render() + fireEvent.click(screen.getByText('Cancel')) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('shows Delete button when onDelete is provided', () => { + const onDelete = vi.fn() + render() + expect(screen.getByText('Delete')).toBeDefined() + }) + + it('calls onDelete and onClose when Delete is clicked', () => { + const onDelete = vi.fn() + const onClose = vi.fn() + render() + fireEvent.click(screen.getByText('Delete')) + expect(onDelete).toHaveBeenCalledOnce() + expect(onClose).toHaveBeenCalledOnce() + }) + + it('pre-fills form from initial prop', () => { + render( + + ) + const input = screen.getByPlaceholderText('Zone name…') as HTMLInputElement + expect(input.value).toBe('Pre-filled') + }) + + it('selects text position button', () => { + const onSubmit = vi.fn() + render() + // Click bottom-right (↘) + fireEvent.click(screen.getByTitle('bottom-right')) + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.text_position).toBe('bottom-right') + }) +}) diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index a8cf4ee..cc28d41 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect, useRef } from 'react' -import { Network, Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2 } from 'lucide-react' +import { Network, Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square } from 'lucide-react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useCanvasStore } from '@/stores/canvasStore' import { scanApi } from '@/api/client' @@ -30,18 +30,20 @@ interface ScanRun { interface SidebarProps { onAddNode: () => void + onAddGroupRect: () => void onScan: () => void onSave: () => void onNodeApproved: (nodeId: string) => void } -export function Sidebar({ onAddNode, onScan, onSave, onNodeApproved }: SidebarProps) { +export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved }: SidebarProps) { const [collapsed, setCollapsed] = useState(false) const [activeView, setActiveView] = useState('canvas') const { nodes, hasUnsavedChanges } = useCanvasStore() - const onlineCount = nodes.filter((n) => n.data.status === 'online').length - const offlineCount = nodes.filter((n) => n.data.status === 'offline').length + const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect') + const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length + const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length const handleScan = useCallback(async () => { try { @@ -110,7 +112,7 @@ export function Sidebar({ onAddNode, onScan, onSave, onNodeApproved }: SidebarPr
Total - {nodes.length} + {networkNodes.length}
Online @@ -126,6 +128,7 @@ export function Sidebar({ onAddNode, onScan, onSave, onNodeApproved }: SidebarPr {/* Actions */}
+ {!STANDALONE && } { edges: [], hasUnsavedChanges: false, selectedNodeId: null, + editingGroupRectId: null, }) }) @@ -190,6 +191,40 @@ describe('canvasStore', () => { expect(updatedChild?.extent).toBeUndefined() }) + it('setEditingGroupRectId sets and clears the editing id', () => { + useCanvasStore.getState().setEditingGroupRectId('rect-1') + expect(useCanvasStore.getState().editingGroupRectId).toBe('rect-1') + useCanvasStore.getState().setEditingGroupRectId(null) + expect(useCanvasStore.getState().editingGroupRectId).toBeNull() + }) + + it('setNodeZIndex updates the node zIndex and marks unsaved', () => { + useCanvasStore.getState().addNode(makeNode('n1')) + useCanvasStore.getState().markSaved() + useCanvasStore.getState().setNodeZIndex('n1', -5) + const node = useCanvasStore.getState().nodes.find((n) => n.id === 'n1') + expect(node?.zIndex).toBe(-5) + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) + }) + + it('addNode with groupRect type preserves zIndex and dimensions', () => { + const rectNode: Node = { + id: 'rect-1', + type: 'groupRect', + position: { x: 100, y: 100 }, + data: { label: 'Zone A', type: 'groupRect', status: 'unknown', services: [] }, + width: 360, + height: 240, + zIndex: -9, + } + useCanvasStore.getState().addNode(rectNode) + const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'rect-1') + expect(stored?.type).toBe('groupRect') + expect(stored?.zIndex).toBe(-9) + expect(stored?.width).toBe(360) + expect(stored?.height).toBe(240) + }) + it('loadCanvas sorts parents before children', () => { const parent = makeNode('p1') const child: Node = { ...makeNode('c1', { parent_id: 'p1' }), parentId: 'p1', extent: 'parent' } diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index 361a534..f0ed5a6 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -28,6 +28,9 @@ interface CanvasState { updateEdge: (id: string, data: Partial) => void deleteEdge: (id: string) => void setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void + setNodeZIndex: (id: string, zIndex: number) => void + editingGroupRectId: string | null + setEditingGroupRectId: (id: string | null) => void markSaved: () => void loadCanvas: (nodes: Node[], edges: Edge[]) => void notifyScanDeviceFound: () => void @@ -38,6 +41,7 @@ export const useCanvasStore = create((set) => ({ edges: [], hasUnsavedChanges: false, selectedNodeId: null, + editingGroupRectId: null, scanEventTs: 0, onNodesChange: (changes) => @@ -141,6 +145,14 @@ export const useCanvasStore = create((set) => ({ return { nodes, hasUnsavedChanges: true } }), + setNodeZIndex: (id, zIndex) => + set((state) => ({ + nodes: state.nodes.map((n) => n.id === id ? { ...n, zIndex } : n), + hasUnsavedChanges: true, + })), + + setEditingGroupRectId: (id) => set({ editingGroupRectId: id }), + markSaved: () => set({ hasUnsavedChanges: false }), notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }), diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 1311c5b..bdf6001 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -14,6 +14,18 @@ export type NodeType = | 'computer' | 'cpl' | 'generic' + | 'groupRect' + +export type TextPosition = + | 'top-left' + | 'top-center' + | 'top-right' + | 'middle-left' + | 'center' + | 'middle-right' + | 'bottom-left' + | 'bottom-center' + | 'bottom-right' export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster' @@ -45,7 +57,18 @@ export interface NodeData extends Record { notes?: string parent_id?: string container_mode?: boolean - custom_colors?: { border?: string; background?: string; icon?: string } + custom_colors?: { + border?: string + background?: string + icon?: string + // Group rectangle extras (type === 'groupRect') + text_color?: string + text_position?: TextPosition + font?: string + z_order?: number + width?: number + height?: number + } custom_icon?: string } @@ -76,6 +99,7 @@ export const NODE_TYPE_LABELS: Record = { computer: 'Computer', cpl: 'CPL / Powerline', generic: 'Generic Device', + groupRect: 'Group Rectangle', } export const STATUS_COLORS: Record = {