diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b0da8db..8d8a776 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -35,7 +35,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE_STORAGE_KEY = 'homelable_canvas' export default function App() { - const { loadCanvas, markSaved, markUnsaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore() + const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore() const canvasRef = useRef(null) const { isAuthenticated } = useAuthStore() const { activeTheme, setTheme } = useThemeStore() @@ -100,8 +100,8 @@ export default function App() { // Build a map of proxmox container mode to know if children should be nested const proxmoxContainerMap = new Map( (apiNodes as ApiNode[]) - .filter((n) => n.type === 'proxmox') - .map((n) => [n.id, n.container_mode !== false]) + .filter((n) => n.type === 'proxmox' || n.type === 'group') + .map((n) => [n.id, n.type === 'group' ? true : n.container_mode !== false]) ) const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)) const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge) @@ -385,7 +385,7 @@ export default function App() {
- {selectedNodeId && } + {(selectedNodeId || selectedNodeIds.length > 1) && } diff --git a/frontend/src/components/LiveView.tsx b/frontend/src/components/LiveView.tsx index 8d428f7..bfcdde4 100644 --- a/frontend/src/components/LiveView.tsx +++ b/frontend/src/components/LiveView.tsx @@ -71,8 +71,8 @@ function LiveViewCanvas() { const { nodes: apiNodes, edges: apiEdges } = res.data const proxmoxMap = new Map( (apiNodes as ApiNode[]) - .filter((n: ApiNode) => n.type === 'proxmox') - .map((n: ApiNode) => [n.id, n.container_mode !== false]) + .filter((n: ApiNode) => n.type === 'proxmox' || n.type === 'group') + .map((n: ApiNode) => [n.id, n.type === 'group' ? true : n.container_mode !== false]) ) loadCanvas( (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)), diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index 9892cac..bb7e493 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -1,14 +1,17 @@ -import { useCallback } from 'react' +import { useCallback, useState } from 'react' import { ReactFlow, Background, Controls, + ControlButton, BackgroundVariant, ConnectionMode, + SelectionMode, type Node, type Edge, type Connection, } from '@xyflow/react' +import { MousePointer2, Hand } from 'lucide-react' import '@xyflow/react/dist/style.css' import { useCanvasStore } from '@/stores/canvasStore' import { useThemeStore } from '@/stores/themeStore' @@ -24,6 +27,7 @@ interface CanvasContainerProps { } export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) { + const [lassoMode, setLassoMode] = useState(true) const { nodes, edges, onNodesChange, onEdgesChange, @@ -33,8 +37,12 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o const activeTheme = useThemeStore((s) => s.activeTheme) const theme = THEMES[activeTheme] - const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => { - setSelectedNode(node.id) + const onNodeClick = useCallback((e: React.MouseEvent, node: Node) => { + if (e.ctrlKey || e.metaKey) { + setSelectedNode(null) + } else { + setSelectedNode(node.id) + } }, [setSelectedNode]) const onPaneClick = useCallback(() => { @@ -61,6 +69,11 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o edgeTypes={edgeTypes} deleteKeyCode={['Backspace', 'Delete']} onBeforeDelete={async () => { snapshotHistory(); return true }} + selectionOnDrag={lassoMode} + panOnDrag={lassoMode ? [1, 2] : true} + panActivationKeyCode="Space" + selectionMode={SelectionMode.Partial} + multiSelectionKeyCode={['Meta', 'Control']} snapToGrid snapGrid={[16, 16]} fitView @@ -75,7 +88,14 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o size={1} color={theme.colors.canvasDotColor} /> - + + setLassoMode((m) => !m)} + title={lassoMode ? 'Switch to pan mode (Space to pan)' : 'Switch to lasso mode'} + > + {lassoMode ? : } + + ) diff --git a/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx b/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx index 1c51f84..afebdad 100644 --- a/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx +++ b/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx @@ -16,8 +16,10 @@ vi.mock('@xyflow/react', () => ({ }, Background: () => null, Controls: () => null, + ControlButton: () => null, BackgroundVariant: { Dots: 'dots' }, ConnectionMode: { Loose: 'loose' }, + SelectionMode: { Partial: 'partial' }, })) vi.mock('@xyflow/react/dist/style.css', () => ({})) @@ -151,6 +153,55 @@ describe('CanvasContainer', () => { expect(rfProps.deleteKeyCode).toEqual(['Backspace', 'Delete']) }) + // ── Lasso / multi-select ────────────────────────────────────────────────── + + it('enables selectionOnDrag for lasso selection', () => { + render() + expect(rfProps.selectionOnDrag).toBe(true) + }) + + it('sets panActivationKeyCode to Space', () => { + render() + expect(rfProps.panActivationKeyCode).toBe('Space') + }) + + it('sets panOnDrag to [1, 2]', () => { + render() + expect(rfProps.panOnDrag).toEqual([1, 2]) + }) + + it('sets selectionMode to Partial', () => { + render() + expect(rfProps.selectionMode).toBe('partial') + }) + + it('sets multiSelectionKeyCode to Meta and Control', () => { + render() + expect(rfProps.multiSelectionKeyCode).toEqual(['Meta', 'Control']) + }) + + it('clears selectedNode (sets null) on Ctrl+click instead of selecting', () => { + const node = makeNode('n1') + useCanvasStore.setState({ nodes: [node], selectedNodeId: 'n1' }) + render() + ;(rfProps.onNodeClick as (...args: unknown[]) => unknown)( + { ctrlKey: true, metaKey: false } as unknown as MouseEvent, + node, + ) + expect(useCanvasStore.getState().selectedNodeId).toBeNull() + }) + + it('clears selectedNode (sets null) on Cmd+click', () => { + const node = makeNode('n1') + useCanvasStore.setState({ nodes: [node], selectedNodeId: 'n1' }) + render() + ;(rfProps.onNodeClick as (...args: unknown[]) => unknown)( + { ctrlKey: false, metaKey: true } as unknown as MouseEvent, + node, + ) + expect(useCanvasStore.getState().selectedNodeId).toBeNull() + }) + // ── onBeforeDelete snapshot ─────────────────────────────────────────────── it('onBeforeDelete calls snapshotHistory and returns true', async () => { diff --git a/frontend/src/components/canvas/__tests__/GroupNode.test.tsx b/frontend/src/components/canvas/__tests__/GroupNode.test.tsx new file mode 100644 index 0000000..217cc68 --- /dev/null +++ b/frontend/src/components/canvas/__tests__/GroupNode.test.tsx @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { GroupNode } from '../nodes/GroupNode' +import * as canvasStore from '@/stores/canvasStore' +import type { Node } from '@xyflow/react' +import type { NodeData } from '@/types' + +vi.mock('@/stores/canvasStore') + +vi.mock('@xyflow/react', () => ({ + NodeResizer: ({ isVisible }: { isVisible: boolean }) => ( +
+ ), + useReactFlow: () => ({}), +})) + +vi.mock('@xyflow/react/dist/style.css', () => ({})) + +function makeGroupNode(overrides: Partial = {}): Node { + return { + id: 'g1', + type: 'group', + position: { x: 0, y: 0 }, + width: 400, + height: 250, + data: { + label: 'My Group', + type: 'group', + status: 'unknown', + services: [], + custom_colors: { show_border: true }, + ...overrides, + }, + } +} + +function renderGroupNode(props: Partial[0]> = {}, storeNodes: unknown[] = []) { + const node = makeGroupNode(props.data) + vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ + nodes: storeNodes, + updateNode: vi.fn(), + snapshotHistory: vi.fn(), + } as unknown as ReturnType) + + return render( + , + ) +} + +describe('GroupNode', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders the group label when show_border is true', () => { + renderGroupNode() + expect(screen.getByText('My Group')).toBeDefined() + }) + + it('hides the header when show_border is false and not selected', () => { + renderGroupNode({ data: makeGroupNode({ custom_colors: { show_border: false } }).data, selected: false }) + expect(screen.queryByText('My Group')).toBeNull() + }) + + it('shows header when show_border is false but node is selected', () => { + renderGroupNode({ data: makeGroupNode({ custom_colors: { show_border: false } }).data, selected: true }) + expect(screen.getByText('My Group')).toBeDefined() + }) + + it('shows NodeResizer only when selected', () => { + const { rerender } = renderGroupNode({ selected: false }) + expect(screen.getByTestId('node-resizer').getAttribute('data-visible')).toBe('false') + + vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ + nodes: [], + updateNode: vi.fn(), + snapshotHistory: vi.fn(), + } as unknown as ReturnType) + + rerender( + , + ) + expect(screen.getByTestId('node-resizer').getAttribute('data-visible')).toBe('true') + }) + + it('shows online/offline status summary from children', () => { + const storeNodes = [ + { id: 'c1', parentId: 'g1', data: { status: 'online' } }, + { id: 'c2', parentId: 'g1', data: { status: 'offline' } }, + { id: 'c3', parentId: 'other', data: { status: 'online' } }, // different group — excluded + ] + + renderGroupNode({}, storeNodes) + // Two status indicators: one online, one offline (c3 excluded — wrong parent) + const statusSpans = screen.getAllByText(/● \d+/) + expect(statusSpans).toHaveLength(2) + }) + + it('does not show status summary when group has no children', () => { + renderGroupNode() + expect(screen.queryByText(/●/)).toBeNull() + }) +}) diff --git a/frontend/src/components/canvas/nodes/GroupNode.tsx b/frontend/src/components/canvas/nodes/GroupNode.tsx new file mode 100644 index 0000000..983a730 --- /dev/null +++ b/frontend/src/components/canvas/nodes/GroupNode.tsx @@ -0,0 +1,125 @@ +import { useState } from 'react' +import { type NodeProps, type Node, NodeResizer } from '@xyflow/react' +import { Layers, Pencil, Check, X } from 'lucide-react' +import { useCanvasStore } from '@/stores/canvasStore' +import { STATUS_COLORS, type NodeData } from '@/types' + +export function GroupNode({ id, data, selected }: NodeProps>) { + const { nodes, updateNode, snapshotHistory } = useCanvasStore() + const showBorder = data.custom_colors?.show_border !== false + const isVisible = showBorder || selected + + const [editing, setEditing] = useState(false) + const [labelDraft, setLabelDraft] = useState(data.label) + + const children = nodes.filter((n) => n.parentId === id) + const onlineCount = children.filter((n) => n.data.status === 'online').length + const offlineCount = children.filter((n) => n.data.status === 'offline').length + const unknownCount = children.length - onlineCount - offlineCount + + const handleRename = () => { + if (labelDraft.trim()) { + snapshotHistory() + updateNode(id, { label: labelDraft.trim() }) + } + setEditing(false) + } + + const borderColor = selected ? '#00d4ff' : '#30363d' + const borderStyle = selected ? 'solid' : 'dashed' + + return ( +
+ + + {/* Header */} + {isVisible && ( +
+ + + {editing ? ( + setLabelDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleRename() + if (e.key === 'Escape') { setLabelDraft(data.label); setEditing(false) } + }} + style={{ + flex: 1, + background: 'transparent', + border: 'none', + outline: 'none', + color: '#e6edf3', + fontSize: 11, + fontWeight: 600, + }} + /> + ) : ( + + {data.label} + + )} + + {editing ? ( + <> + + + + ) : ( + + )} + + {/* Status summary */} + {children.length > 0 && ( +
+ {onlineCount > 0 && ● {onlineCount}} + {offlineCount > 0 && ● {offlineCount}} + {unknownCount > 0 && ● {unknownCount}} +
+ )} +
+ )} +
+ ) +} diff --git a/frontend/src/components/canvas/nodes/nodeTypes.ts b/frontend/src/components/canvas/nodes/nodeTypes.ts index ffc94c2..00ae141 100644 --- a/frontend/src/components/canvas/nodes/nodeTypes.ts +++ b/frontend/src/components/canvas/nodes/nodeTypes.ts @@ -1,6 +1,7 @@ import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index' import { ProxmoxGroupNode } from './ProxmoxGroupNode' import { GroupRectNode } from './GroupRectNode' +import { GroupNode } from './GroupNode' export const nodeTypes = { isp: IspNode, @@ -20,4 +21,5 @@ export const nodeTypes = { docker: DockerNode, generic: GenericNode, groupRect: GroupRectNode, + group: GroupNode, } diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index ca046c3..f232776 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -1,33 +1,75 @@ import { useState } from 'react' -import { X, Edit, Trash2, ExternalLink, Plus, Pencil } from 'lucide-react' +import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { useCanvasStore } from '@/stores/canvasStore' -import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo } from '@/types' +import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData } from '@/types' import { getServiceUrl } from '@/utils/serviceUrl' +import type { Node } from '@xyflow/react' interface DetailPanelProps { onEdit: (id: string) => void } type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string } - const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' } export function DetailPanel({ onEdit }: DetailPanelProps) { - const { nodes, selectedNodeId, setSelectedNode, deleteNode, updateNode, snapshotHistory } = useCanvasStore() - const node = nodes.find((n) => n.id === selectedNodeId) + const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore() const [addingForNode, setAddingForNode] = useState(null) const [newSvc, setNewSvc] = useState(EMPTY_FORM) const [editingFor, setEditingFor] = useState<{ nodeId: string; index: number } | null>(null) const [editSvc, setEditSvc] = useState(EMPTY_FORM) + const [groupName, setGroupName] = useState('') + const [creatingGroup, setCreatingGroup] = useState(false) + // Multi-select panel + const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id)) + + if (multiSelected.length > 1) { + return ( + { createGroup(multiSelected, name); setGroupName(''); setCreatingGroup(false) }} + onClose={() => setSelectedNode(null)} + /> + ) + } + + const node = nodes.find((n) => n.id === selectedNodeId) if (!node || node.data.type === 'groupRect') return null + // Group detail panel + if (node.data.type === 'group') { + return ( + { ungroup(node.id) }} + onToggleBorder={() => { + snapshotHistory() + updateNode(node.id, { + custom_colors: { + ...node.data.custom_colors, + show_border: !(node.data.custom_colors?.show_border !== false), + }, + }) + }} + onClose={() => setSelectedNode(null)} + onSelectChild={(id) => setSelectedNode(id)} + /> + ) + } + + // Normal single-node panel const addingService = addingForNode === node.id const editingIndex = editingFor?.nodeId === node.id ? editingFor.index : null - const { data } = node const services = data.services ?? [] const statusColor = STATUS_COLORS[data.status] @@ -43,11 +85,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { const handleAddService = () => { const port = parseInt(newSvc.port, 10) if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return - const svc: ServiceInfo = { - port, - protocol: newSvc.protocol, - service_name: newSvc.service_name.trim(), - } + const svc: ServiceInfo = { port, protocol: newSvc.protocol, service_name: newSvc.service_name.trim() } updateNode(node.id, { services: [...services, svc] }) setNewSvc(EMPTY_FORM) setAddingForNode(null) @@ -72,9 +110,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { const port = parseInt(editSvc.port, 10) if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return const updated = services.map((svc, i) => - i === editingIndex - ? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } - : svc + i === editingIndex ? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } : svc ) updateNode(node.id, { services: updated }) setEditingFor(null) @@ -82,19 +118,13 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { return ( + ) +} + +// --- Group detail panel --- + +interface GroupDetailPanelProps { + node: Node + nodes: Node[] + onUngroup: () => void + onToggleBorder: () => void + onClose: () => void + onSelectChild: (id: string) => void +} + +function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onSelectChild }: GroupDetailPanelProps) { + const children = nodes.filter((n) => n.parentId === node.id) + const onlineCount = children.filter((n) => n.data.status === 'online').length + const offlineCount = children.filter((n) => n.data.status === 'offline').length + const showBorder = node.data.custom_colors?.show_border !== false + + const handleUngroup = () => { + if (confirm(`Ungroup "${node.data.label}"? Nodes will be released to the canvas.`)) { + onUngroup() + } + } + + return ( + + ) +} + +// --- Helpers --- + function formatStorage(gb: number): string { if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB` return `${gb} GB` @@ -228,24 +371,14 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono? return (
{label} - + {value}
) } -function ServiceForm({ - form, - onChange, - onConfirm, - onCancel, - confirmLabel, - autoFocus, -}: { +function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: { form: { port: string; protocol: 'tcp' | 'udp'; service_name: string } onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string }) => void onConfirm: () => void @@ -255,82 +388,31 @@ function ServiceForm({ }) { return (
- onChange({ ...form, service_name: e.target.value })} - placeholder="Service name" - className="bg-[#21262d] border-[#30363d] text-xs h-7" - autoFocus={autoFocus} - onKeyDown={(e) => e.key === 'Enter' && onConfirm()} - /> + onChange({ ...form, service_name: e.target.value })} placeholder="Service name" className="bg-[#21262d] border-[#30363d] text-xs h-7" autoFocus={autoFocus} onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
- onChange({ ...form, port: e.target.value })} - placeholder="Port" - min={1} - max={65535} - className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0" - onKeyDown={(e) => e.key === 'Enter' && onConfirm()} - /> - onChange({ ...form, port: e.target.value })} placeholder="Port" min={1} max={65535} className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} /> +
- - + +
) } const CATEGORY_COLORS: Record = { - web: '#00d4ff', - database: '#a855f7', - monitoring: '#39d353', - storage: '#e3b341', - security: '#f85149', - remote: '#8b949e', + web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e', } -function ServiceBadge({ - svc, - host, - onEdit, - onRemove, -}: { - svc: ServiceInfo - host?: string - onEdit: () => void - onRemove: () => void -}) { +function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) { const url = getServiceUrl(svc, host) const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e' - const inner = ( -
+
{svc.service_name} @@ -338,30 +420,11 @@ function ServiceBadge({
{svc.port}/{svc.protocol} {url && } - - + +
) - - if (url) { - return ( - - {inner} - - ) - } + if (url) return {inner} return inner } diff --git a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx index ca39877..70d04f5 100644 --- a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx +++ b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx @@ -26,10 +26,13 @@ function setupStore(nodeData: Partial = {}) { vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ nodes: [makeNode(nodeData)], selectedNodeId: 'n1', + selectedNodeIds: [], setSelectedNode: vi.fn(), deleteNode: vi.fn(), updateNode: vi.fn(), snapshotHistory: vi.fn(), + createGroup: vi.fn(), + ungroup: vi.fn(), } as unknown as ReturnType) } @@ -38,10 +41,13 @@ describe('DetailPanel', () => { vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ nodes: [], selectedNodeId: null, + selectedNodeIds: [], setSelectedNode: vi.fn(), deleteNode: vi.fn(), updateNode: vi.fn(), snapshotHistory: vi.fn(), + createGroup: vi.fn(), + ungroup: vi.fn(), } as unknown as ReturnType) }) diff --git a/frontend/src/components/panels/__tests__/GroupPanels.test.tsx b/frontend/src/components/panels/__tests__/GroupPanels.test.tsx new file mode 100644 index 0000000..41ce35c --- /dev/null +++ b/frontend/src/components/panels/__tests__/GroupPanels.test.tsx @@ -0,0 +1,233 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { DetailPanel } from '../DetailPanel' +import * as canvasStore from '@/stores/canvasStore' +import { TooltipProvider } from '@/components/ui/tooltip' + +vi.mock('@/stores/canvasStore') +vi.mock('@/utils/serviceUrl', () => ({ getServiceUrl: () => null })) + +function makeNode(id: string, overrides = {}) { + return { + id, + type: 'server', + position: { x: 0, y: 0 }, + data: { label: id, type: 'server', status: 'online', services: [] }, + ...overrides, + } +} + +function makeGroupNode(id = 'g1', label = 'My Group', showBorder = true) { + return { + id, + type: 'group', + position: { x: 76, y: 52 }, + data: { + label, + type: 'group', + status: 'unknown', + services: [], + custom_colors: { show_border: showBorder }, + }, + } +} + +const mockStore = { + nodes: [], + selectedNodeId: null, + selectedNodeIds: [], + setSelectedNode: vi.fn(), + deleteNode: vi.fn(), + updateNode: vi.fn(), + snapshotHistory: vi.fn(), + createGroup: vi.fn(), + ungroup: vi.fn(), +} + +function setupStore(overrides = {}) { + vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ + ...mockStore, + ...overrides, + } as unknown as ReturnType) +} + +function renderPanel() { + return render( + + + , + ) +} + +describe('MultiSelectPanel', () => { + beforeEach(() => vi.clearAllMocks()) + + it('renders multi-select panel when 2+ nodes selected', () => { + const n1 = makeNode('n1', { data: { label: 'Router', type: 'router', status: 'online', services: [] } }) + const n2 = makeNode('n2', { data: { label: 'Switch', type: 'switch', status: 'offline', services: [] } }) + setupStore({ + nodes: [n1, n2], + selectedNodeId: null, + selectedNodeIds: ['n1', 'n2'], + }) + renderPanel() + expect(screen.getByText('2 nodes selected')).toBeDefined() + }) + + it('lists selected node labels in multi-select panel', () => { + const n1 = makeNode('n1', { data: { label: 'My Router', type: 'router', status: 'online', services: [] } }) + const n2 = makeNode('n2', { data: { label: 'My NAS', type: 'nas', status: 'unknown', services: [] } }) + setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'] }) + renderPanel() + expect(screen.getByText('My Router')).toBeDefined() + expect(screen.getByText('My NAS')).toBeDefined() + }) + + it('shows Create Group button', () => { + const n1 = makeNode('n1') + const n2 = makeNode('n2') + setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'] }) + renderPanel() + expect(screen.getByRole('button', { name: /create group/i })).toBeDefined() + }) + + it('shows name input when Create Group is clicked', async () => { + const n1 = makeNode('n1') + const n2 = makeNode('n2') + setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'] }) + renderPanel() + fireEvent.click(screen.getByRole('button', { name: /create group/i })) + await waitFor(() => { + expect(screen.getByPlaceholderText(/group name/i)).toBeDefined() + }) + }) + + it('calls createGroup with selected ids and entered name', async () => { + const createGroup = vi.fn() + const n1 = makeNode('n1') + const n2 = makeNode('n2') + setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'], createGroup }) + renderPanel() + fireEvent.click(screen.getByRole('button', { name: /create group/i })) + const input = await screen.findByPlaceholderText(/group name/i) + fireEvent.change(input, { target: { value: 'DMZ' } }) + fireEvent.click(screen.getByRole('button', { name: /^create group$/i })) + expect(createGroup).toHaveBeenCalledWith(['n1', 'n2'], 'DMZ') + }) + + it('uses default name "Group" when input is empty', async () => { + const createGroup = vi.fn() + const n1 = makeNode('n1') + const n2 = makeNode('n2') + setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'], createGroup }) + renderPanel() + fireEvent.click(screen.getByRole('button', { name: /create group/i })) + await screen.findByPlaceholderText(/group name/i) + fireEvent.click(screen.getByRole('button', { name: /^create group$/i })) + expect(createGroup).toHaveBeenCalledWith(['n1', 'n2'], 'Group') + }) + + it('includes groupRect (zone) nodes in multi-select count', () => { + const n1 = makeNode('n1') + const gr = makeNode('gr1', { data: { label: 'Zone', type: 'groupRect', status: 'unknown', services: [] } }) + setupStore({ nodes: [n1, gr], selectedNodeId: null, selectedNodeIds: ['n1', 'gr1'] }) + renderPanel() + // groupRect included → 2 nodes selected → multi-select panel shown + expect(screen.getByText('2 nodes selected')).toBeDefined() + }) +}) + +describe('GroupDetailPanel', () => { + beforeEach(() => vi.clearAllMocks()) + + it('renders group name and members heading', () => { + const group = makeGroupNode() + const child = makeNode('c1', { parentId: 'g1', data: { label: 'Router', type: 'router', status: 'online', services: [] } }) + setupStore({ nodes: [group, child], selectedNodeId: 'g1', selectedNodeIds: ['g1'] }) + renderPanel() + expect(screen.getByText('My Group')).toBeDefined() + expect(screen.getByText('Members')).toBeDefined() + }) + + it('lists children with their labels', () => { + const group = makeGroupNode() + const c1 = makeNode('c1', { parentId: 'g1', data: { label: 'My Router', type: 'router', status: 'online', services: [] } }) + const c2 = makeNode('c2', { parentId: 'g1', data: { label: 'My NAS', type: 'nas', status: 'offline', services: [] } }) + setupStore({ nodes: [group, c1, c2], selectedNodeId: 'g1', selectedNodeIds: ['g1'] }) + renderPanel() + expect(screen.getByText('My Router')).toBeDefined() + expect(screen.getByText('My NAS')).toBeDefined() + }) + + it('shows online/offline count in status summary', () => { + const group = makeGroupNode() + const c1 = makeNode('c1', { parentId: 'g1', data: { label: 'A', type: 'server', status: 'online', services: [] } }) + const c2 = makeNode('c2', { parentId: 'g1', data: { label: 'B', type: 'server', status: 'offline', services: [] } }) + setupStore({ nodes: [group, c1, c2], selectedNodeId: 'g1', selectedNodeIds: ['g1'] }) + renderPanel() + expect(screen.getByText(/1 online/)).toBeDefined() + expect(screen.getByText(/1 offline/)).toBeDefined() + }) + + it('shows Ungroup button', () => { + const group = makeGroupNode() + setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] }) + renderPanel() + expect(screen.getByRole('button', { name: /ungroup/i })).toBeDefined() + }) + + it('calls ungroup after confirm', () => { + const ungroup = vi.fn() + vi.spyOn(window, 'confirm').mockReturnValue(true) + const group = makeGroupNode() + setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], ungroup }) + renderPanel() + fireEvent.click(screen.getByRole('button', { name: /ungroup/i })) + expect(ungroup).toHaveBeenCalledWith('g1') + }) + + it('does not call ungroup when confirm is cancelled', () => { + const ungroup = vi.fn() + vi.spyOn(window, 'confirm').mockReturnValue(false) + const group = makeGroupNode() + setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], ungroup }) + renderPanel() + fireEvent.click(screen.getByRole('button', { name: /ungroup/i })) + expect(ungroup).not.toHaveBeenCalled() + }) + + it('shows "Hide border & title" when show_border is true', () => { + const group = makeGroupNode('g1', 'G', true) + setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] }) + renderPanel() + expect(screen.getByText(/hide border/i)).toBeDefined() + }) + + it('shows "Show border & title" when show_border is false', () => { + const group = makeGroupNode('g1', 'G', false) + setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] }) + renderPanel() + expect(screen.getByText(/show border/i)).toBeDefined() + }) + + it('calls updateNode to toggle show_border off', () => { + const updateNode = vi.fn() + const group = makeGroupNode('g1', 'G', true) + setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], updateNode }) + renderPanel() + fireEvent.click(screen.getByText(/hide border/i)) + expect(updateNode).toHaveBeenCalledWith('g1', expect.objectContaining({ + custom_colors: expect.objectContaining({ show_border: false }), + })) + }) + + it('calls setSelectedNode when a child node is clicked', () => { + const setSelectedNode = vi.fn() + const group = makeGroupNode() + const child = makeNode('c1', { parentId: 'g1', data: { label: 'Child Node Alpha', type: 'server', status: 'online', services: [] } }) + setupStore({ nodes: [group, child], selectedNodeId: 'g1', selectedNodeIds: ['g1'], setSelectedNode }) + renderPanel() + fireEvent.click(screen.getByText('Child Node Alpha')) + expect(setSelectedNode).toHaveBeenCalledWith('c1') + }) +}) diff --git a/frontend/src/index.css b/frontend/src/index.css index ac5baf1..f962a64 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -111,6 +111,15 @@ background-color: var(--surface-card) !important; } +/* Transparent wrapper for container node types */ +.react-flow__node-proxmox, +.react-flow__node-group { + background: transparent !important; + border: none !important; + box-shadow: none !important; + padding: 0 !important; +} + /* Mono font utility */ .font-mono { font-family: 'JetBrains Mono', monospace; diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index 7541398..3aa1cab 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -25,6 +25,7 @@ describe('canvasStore', () => { edges: [], hasUnsavedChanges: false, selectedNodeId: null, + selectedNodeIds: [], editingGroupRectId: null, past: [], future: [], @@ -178,6 +179,178 @@ describe('canvasStore', () => { expect(child?.extent).toBe('parent') }) + // ── selectedNodeIds ─────────────────────────────────────────────────────── + + it('selectedNodeIds starts empty', () => { + expect(useCanvasStore.getState().selectedNodeIds).toEqual([]) + }) + + it('onNodesChange syncs selectedNodeIds from select changes', () => { + useCanvasStore.getState().addNode(makeNode('n1')) + useCanvasStore.getState().addNode(makeNode('n2')) + useCanvasStore.getState().onNodesChange([ + { type: 'select', id: 'n1', selected: true }, + { type: 'select', id: 'n2', selected: true }, + ]) + expect(useCanvasStore.getState().selectedNodeIds).toEqual(expect.arrayContaining(['n1', 'n2'])) + expect(useCanvasStore.getState().selectedNodeIds).toHaveLength(2) + }) + + it('setSelectedNode(null) resets selectedNodeIds to empty', () => { + useCanvasStore.setState({ selectedNodeIds: ['n1', 'n2'] }) + useCanvasStore.getState().setSelectedNode(null) + expect(useCanvasStore.getState().selectedNodeIds).toEqual([]) + }) + + it('setSelectedNode(id) preserves existing selectedNodeIds', () => { + useCanvasStore.setState({ selectedNodeIds: ['n1', 'n2'] }) + useCanvasStore.getState().setSelectedNode('n1') + // does NOT wipe selectedNodeIds when setting a specific id + expect(useCanvasStore.getState().selectedNodeIds).toEqual(['n1', 'n2']) + }) + + // ── createGroup ─────────────────────────────────────────────────────────── + + it('createGroup creates a group node at the bounding box of selected nodes', () => { + // n1 at (100,100), n2 at (300,200); both default to 200x80 + const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 }, width: 200, height: 80 } + const n2 = { ...makeNode('n2'), position: { x: 300, y: 200 }, width: 200, height: 80 } + useCanvasStore.setState({ nodes: [n1, n2] }) + + useCanvasStore.getState().createGroup(['n1', 'n2'], 'My Group') + + const { nodes } = useCanvasStore.getState() + const group = nodes.find((n) => n.data.type === 'group') + expect(group).toBeDefined() + expect(group?.data.label).toBe('My Group') + // groupX = 100-24=76, groupY = 100-48=52 + expect(group?.position.x).toBe(76) + expect(group?.position.y).toBe(52) + // groupW = (500-100)+48=448, groupH = (280-100)+48+24=252 + expect(group?.width).toBe(448) + expect(group?.height).toBe(252) + }) + + it('createGroup converts children to relative positions', () => { + const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 }, width: 200, height: 80 } + const n2 = { ...makeNode('n2'), position: { x: 300, y: 200 }, width: 200, height: 80 } + useCanvasStore.setState({ nodes: [n1, n2] }) + + useCanvasStore.getState().createGroup(['n1', 'n2'], 'G') + + const { nodes } = useCanvasStore.getState() + const c1 = nodes.find((n) => n.id === 'n1') + const c2 = nodes.find((n) => n.id === 'n2') + // groupX=76, groupY=52 → relative: n1=(24,48), n2=(224,148) + expect(c1?.position).toEqual({ x: 24, y: 48 }) + expect(c2?.position).toEqual({ x: 224, y: 148 }) + }) + + it('createGroup sets parentId and extent on children', () => { + const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } } + const n2 = { ...makeNode('n2'), position: { x: 200, y: 100 } } + useCanvasStore.setState({ nodes: [n1, n2] }) + + useCanvasStore.getState().createGroup(['n1', 'n2'], 'G') + + const { nodes } = useCanvasStore.getState() + const group = nodes.find((n) => n.data.type === 'group')! + const c1 = nodes.find((n) => n.id === 'n1') + const c2 = nodes.find((n) => n.id === 'n2') + expect(c1?.parentId).toBe(group.id) + expect(c1?.extent).toBe('parent') + expect(c2?.parentId).toBe(group.id) + }) + + it('createGroup places the group node before its children in the array', () => { + const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } } + const n2 = { ...makeNode('n2'), position: { x: 200, y: 100 } } + useCanvasStore.setState({ nodes: [n1, n2] }) + + useCanvasStore.getState().createGroup(['n1', 'n2'], 'G') + + const { nodes } = useCanvasStore.getState() + const groupIdx = nodes.findIndex((n) => n.data.type === 'group') + const c1Idx = nodes.findIndex((n) => n.id === 'n1') + const c2Idx = nodes.findIndex((n) => n.id === 'n2') + expect(groupIdx).toBeLessThan(c1Idx) + expect(groupIdx).toBeLessThan(c2Idx) + }) + + it('createGroup snapshots history and marks unsaved', () => { + const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } } + useCanvasStore.setState({ nodes: [n1] }) + useCanvasStore.getState().markSaved() + + useCanvasStore.getState().createGroup(['n1'], 'G') + + expect(useCanvasStore.getState().past).toHaveLength(1) + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) + }) + + it('createGroup clears selection', () => { + const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } } + useCanvasStore.setState({ nodes: [n1], selectedNodeId: 'n1', selectedNodeIds: ['n1'] }) + + useCanvasStore.getState().createGroup(['n1'], 'G') + + expect(useCanvasStore.getState().selectedNodeId).toBeNull() + expect(useCanvasStore.getState().selectedNodeIds).toEqual([]) + }) + + // ── ungroup ─────────────────────────────────────────────────────────────── + + it('ungroup restores children to absolute positions', () => { + const group = { + ...makeNode('g1', { type: 'group', label: 'G' }), + position: { x: 76, y: 52 }, + } + const c1 = { ...makeNode('n1'), position: { x: 24, y: 48 }, parentId: 'g1', extent: 'parent' as const } + const c2 = { ...makeNode('n2'), position: { x: 224, y: 148 }, parentId: 'g1', extent: 'parent' as const } + useCanvasStore.setState({ nodes: [group, c1, c2] }) + + useCanvasStore.getState().ungroup('g1') + + const { nodes } = useCanvasStore.getState() + const r1 = nodes.find((n) => n.id === 'n1') + const r2 = nodes.find((n) => n.id === 'n2') + expect(r1?.position).toEqual({ x: 100, y: 100 }) + expect(r2?.position).toEqual({ x: 300, y: 200 }) + }) + + it('ungroup removes parentId and extent from children', () => { + const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } } + const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'g1', extent: 'parent' as const } + useCanvasStore.setState({ nodes: [group, child] }) + + useCanvasStore.getState().ungroup('g1') + + const { nodes } = useCanvasStore.getState() + const released = nodes.find((n) => n.id === 'n1') + expect(released?.parentId).toBeUndefined() + expect(released?.extent).toBeUndefined() + }) + + it('ungroup deletes the group node', () => { + const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } } + useCanvasStore.setState({ nodes: [group] }) + + useCanvasStore.getState().ungroup('g1') + + expect(useCanvasStore.getState().nodes.find((n) => n.id === 'g1')).toBeUndefined() + }) + + it('ungroup snapshots history and marks unsaved', () => { + const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } } + useCanvasStore.setState({ nodes: [group] }) + useCanvasStore.getState().markSaved() + + useCanvasStore.getState().ungroup('g1') + + expect(useCanvasStore.getState().past).toHaveLength(1) + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) + }) + it('updateEdge updates edge data and marks unsaved', () => { useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] })) useCanvasStore.getState().markSaved() diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index fec044b..6525495 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -19,6 +19,7 @@ interface CanvasState { edges: Edge[] hasUnsavedChanges: boolean selectedNodeId: string | null + selectedNodeIds: string[] scanEventTs: number // History @@ -46,6 +47,8 @@ interface CanvasState { setNodeZIndex: (id: string, zIndex: number) => void editingGroupRectId: string | null setEditingGroupRectId: (id: string | null) => void + createGroup: (nodeIds: string[], name: string) => void + ungroup: (groupId: string) => void markSaved: () => void markUnsaved: () => void loadCanvas: (nodes: Node[], edges: Edge[]) => void @@ -59,6 +62,7 @@ export const useCanvasStore = create((set) => ({ edges: [], hasUnsavedChanges: false, selectedNodeId: null, + selectedNodeIds: [], editingGroupRectId: null, hideIp: false, scanEventTs: 0, @@ -125,10 +129,15 @@ export const useCanvasStore = create((set) => ({ }), onNodesChange: (changes) => - set((state) => ({ - nodes: applyNodeChanges(changes, state.nodes), - hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'), - })), + set((state) => { + const nodes = applyNodeChanges(changes, state.nodes) + const selectedNodeIds = nodes.filter((n) => n.selected).map((n) => n.id) + return { + nodes, + selectedNodeIds, + hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'), + } + }), onEdgesChange: (changes) => set((state) => ({ @@ -156,7 +165,10 @@ export const useCanvasStore = create((set) => ({ } }), - setSelectedNode: (id) => set({ selectedNodeId: id }), + setSelectedNode: (id) => set((state) => ({ + selectedNodeId: id, + selectedNodeIds: id ? state.selectedNodeIds : [], + })), addNode: (node) => set((state) => { @@ -244,6 +256,112 @@ export const useCanvasStore = create((set) => ({ setEditingGroupRectId: (id) => set({ editingGroupRectId: id }), + createGroup: (nodeIds, name) => + set((state) => { + const PADDING_H = 24 + const PADDING_TOP = 48 + const PADDING_BOTTOM = 24 + const targets = state.nodes.filter((n) => nodeIds.includes(n.id)) + if (targets.length === 0) return state + + // Bounding box in absolute coordinates + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (const n of targets) { + const w = n.width ?? 200 + const h = n.height ?? 80 + minX = Math.min(minX, n.position.x) + minY = Math.min(minY, n.position.y) + maxX = Math.max(maxX, n.position.x + w) + maxY = Math.max(maxY, n.position.y + h) + } + + const groupX = minX - PADDING_H + const groupY = minY - PADDING_TOP + const groupW = maxX - minX + PADDING_H * 2 + const groupH = maxY - minY + PADDING_TOP + PADDING_BOTTOM + + const groupId = generateUUID() + const groupNode: Node = { + id: groupId, + type: 'group', + position: { x: groupX, y: groupY }, + width: groupW, + height: groupH, + data: { + label: name, + type: 'group', + status: 'unknown', + services: [], + custom_colors: { show_border: true }, + }, + selected: false, + } + + // Convert children to relative positions and assign parentId + const updatedNodes = state.nodes.map((n) => { + if (!nodeIds.includes(n.id)) return n + return { + ...n, + parentId: groupId, + extent: 'parent' as const, + position: { + x: n.position.x - groupX, + y: n.position.y - groupY, + }, + selected: false, + data: { ...n.data, parent_id: groupId }, + } + }) + + // Group node must come before its children + const withoutTargets = updatedNodes.filter((n) => !nodeIds.includes(n.id)) + const children = updatedNodes.filter((n) => nodeIds.includes(n.id)) + const nodes = [...withoutTargets, groupNode, ...children] + + return { + nodes, + selectedNodeIds: [], + selectedNodeId: null, + hasUnsavedChanges: true, + past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }], + future: [], + } + }), + + ungroup: (groupId) => + set((state) => { + const group = state.nodes.find((n) => n.id === groupId) + if (!group) return state + + const groupAbsX = group.position.x + const groupAbsY = group.position.y + + const nodes = state.nodes + .filter((n) => n.id !== groupId) + .map((n) => { + if (n.parentId !== groupId) return n + return { + ...n, + parentId: undefined, + extent: undefined, + position: { + x: n.position.x + groupAbsX, + y: n.position.y + groupAbsY, + }, + data: { ...n.data, parent_id: undefined }, + } + }) + + return { + nodes, + selectedNodeId: null, + selectedNodeIds: [], + hasUnsavedChanges: true, + past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }], + future: [], + } + }), + markSaved: () => set({ hasUnsavedChanges: false }), markUnsaved: () => set({ hasUnsavedChanges: true }), diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 59379a8..79dcc6c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -16,6 +16,7 @@ export type NodeType = | 'docker' | 'generic' | 'groupRect' + | 'group' export type TextPosition = | 'top-left' @@ -76,6 +77,7 @@ export interface NodeData extends Record { label_position?: 'inside' | 'outside' text_size?: number z_order?: number + show_border?: boolean width?: number height?: number } @@ -112,6 +114,7 @@ export const NODE_TYPE_LABELS: Record = { docker: 'Docker Host', generic: 'Generic Device', groupRect: 'Group Rectangle', + group: 'Node Group', } export const STATUS_COLORS: Record = { diff --git a/frontend/src/utils/canvasSerializer.ts b/frontend/src/utils/canvasSerializer.ts index a79022c..8b653dd 100644 --- a/frontend/src/utils/canvasSerializer.ts +++ b/frontend/src/utils/canvasSerializer.ts @@ -63,7 +63,7 @@ export function serializeNode(n: Node): Record { check_target: null, services: [], notes: null, - parent_id: null, + parent_id: n.data.parent_id ?? null, container_mode: false, custom_icon: null, pos_x: n.position.x, @@ -142,6 +142,7 @@ export function deserializeApiNode( width: w, height: h, zIndex: z - 10, + ...(n.parent_id ? { parentId: n.parent_id, extent: 'parent' as const } : {}), } } const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false