From 1a3cde3a02c63eb0dbb5313a7a1ccd174bf90af8 Mon Sep 17 00:00:00 2001 From: Brett Ferrante <83841899+findthelorax@users.noreply.github.com> Date: Mon, 20 Apr 2026 09:35:53 -0400 Subject: [PATCH 01/24] Update Docker image references to use repository owner --- .github/workflows/docker-publish.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 600ae67..fe0a4e8 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -15,13 +15,13 @@ jobs: strategy: matrix: include: - - image: ghcr.io/pouzor/homelable-backend + - image: ghcr.io/${{ github.repository_owner }}/homelable-backend dockerfile: Dockerfile.backend build_args: "" - - image: ghcr.io/pouzor/homelable-frontend + - image: ghcr.io/${{ github.repository_owner }}/homelable-frontend dockerfile: Dockerfile.frontend build_args: "" - - image: ghcr.io/pouzor/homelable-frontend-standalone + - image: ghcr.io/${{ github.repository_owner }}/homelable-frontend-standalone dockerfile: Dockerfile.frontend build_args: "VITE_STANDALONE=true" From 585df726e7a3404955d30628129b028b2564b6ad Mon Sep 17 00:00:00 2001 From: findthelorax Date: Thu, 16 Apr 2026 12:58:40 -0400 Subject: [PATCH 02/24] feature: added a Container Mode toggle to the Virtualization group of node types --- frontend/src/App.tsx | 17 ++++--- .../src/components/canvas/nodes/nodeTypes.ts | 2 +- frontend/src/components/modals/NodeModal.tsx | 50 +++++++++++-------- .../modals/__tests__/NodeModal.test.tsx | 43 ++++++++-------- .../src/stores/__tests__/canvasStore.test.ts | 15 ++++++ frontend/src/stores/canvasStore.ts | 40 +++++++++++++-- frontend/src/types/index.ts | 4 +- frontend/src/utils/__tests__/themes.test.ts | 4 +- frontend/src/utils/canvasSerializer.ts | 12 +++-- frontend/src/utils/nodeIcons.ts | 2 +- frontend/src/utils/themes.ts | 10 ++-- 11 files changed, 130 insertions(+), 69 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1c83913..1a26531 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -33,6 +33,7 @@ import type { NodeData, EdgeData } from '@/types' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE_STORAGE_KEY = 'homelable_canvas' +const CONTAINER_MODE_TYPES = new Set(['proxmox', 'vm', 'lxc', 'docker_host']) export default function App() { const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore() @@ -103,8 +104,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' || n.type === 'group') - .map((n) => [n.id, n.type === 'group' ? true : n.container_mode !== false]) + .filter((n) => n.type === 'group' || n.container_mode === true) + .map((n) => [n.id, true]) ) const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)) const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge) @@ -242,8 +243,8 @@ export default function App() { snapshotHistory() const existingNode = nodes.find((n) => n.id === editNodeId) updateNode(editNodeId, data) - // If proxmox container_mode changed, apply structural changes (children parentId, node dimensions) - if (data.type === 'proxmox' && typeof data.container_mode === 'boolean') { + // If container_mode changed, apply structural changes (children parentId, node dimensions) + if (typeof data.container_mode === 'boolean') { setProxmoxContainerMode(editNodeId, data.container_mode) } // Sync virtual edge when parent_id changes on an LXC/VM node @@ -423,7 +424,9 @@ export default function App() { onClose={() => setAddNodeOpen(false)} onSubmit={handleAddNode} title="Add Node" - proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))} + parentContainerNodes={nodes + .filter((n) => CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode) + .map((n) => ({ id: n.id, label: n.data.label }))} /> {/* key forces re-mount when editing a different node, resetting form state */} @@ -434,7 +437,9 @@ export default function App() { onSubmit={handleUpdateNode} initial={editNode?.data} title="Edit Node" - proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))} + parentContainerNodes={nodes + .filter((n) => n.id !== editNodeId && CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode) + .map((n) => ({ id: n.id, label: n.data.label }))} /> = { none: 'None', @@ -37,7 +38,7 @@ const DEFAULT_DATA: Partial = { status: 'unknown', check_method: 'ping', services: [], - container_mode: true, + container_mode: false, custom_colors: undefined, custom_icon: undefined, } @@ -48,12 +49,12 @@ interface NodeModalProps { onSubmit: (data: Partial) => void initial?: Partial title?: string - proxmoxNodes?: { id: string; label: string }[] + parentContainerNodes?: { id: string; label: string }[] } -const CHILD_TYPES: NodeType[] = ['vm', 'lxc'] - -export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', proxmoxNodes = [] }: NodeModalProps) { +// NodeModal is always mounted with a key that changes on open/edit, so useState +// initial value is enough - no need for a reset effect. +export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentContainerNodes = [] }: NodeModalProps) { const [form, setForm] = useState>({ ...DEFAULT_DATA, ...initial }) const [iconSearch, setIconSearch] = useState('') const [iconPickerOpen, setIconPickerOpen] = useState(false) @@ -69,7 +70,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' return } setLabelError(false) - onSubmit(form) + const selectedType = (form.type ?? 'generic') as NodeType + const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType) + onSubmit({ + ...form, + container_mode: canUseContainerMode ? !!form.container_mode : false, + }) onClose() } @@ -92,7 +98,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' {NODE_TYPE_GROUPS.map((group, i) => ( - {i > 0 && } + {i > 0 && } {group.label} @@ -143,7 +149,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' - {/* Inline icon picker — full width, shown below the type+icon row */} + {/* Inline icon picker - full width, shown below the type+icon row */} {iconPickerOpen && (
- {/* Parent Proxmox (VM / LXC only) */} - {CHILD_TYPES.includes(form.type as NodeType) && proxmoxNodes.length > 0 && ( + {/* Parent container */} + {form.type !== 'groupRect' && form.type !== 'group' && parentContainerNodes.length > 0 && (
- +
@@ -390,4 +396,4 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' ) -} +} \ No newline at end of file diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx index 7a8eee7..a466733 100644 --- a/frontend/src/components/modals/__tests__/NodeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/NodeModal.test.tsx @@ -234,15 +234,20 @@ describe('NodeModal', () => { expect(screen.queryByTitle('Router')).toBeNull() }) - // ── Container mode (proxmox only) ───────────────────────────────────── + // ── Container mode ───────────────────────────────────────────────────── it('shows Container Mode toggle for proxmox type', () => { renderModal({ initial: { ...BASE, type: 'proxmox' } }) expect(screen.getByText('Container Mode')).toBeDefined() }) - it('hides Container Mode for non-proxmox types', () => { - renderModal({ initial: BASE }) + it('hides Container Mode for server type', () => { + renderModal({ initial: { ...BASE, type: 'server' } }) + expect(screen.queryByText('Container Mode')).toBeNull() + }) + + it('hides Container Mode for groupRect type', () => { + renderModal({ initial: { ...BASE, type: 'groupRect' } }) expect(screen.queryByText('Container Mode')).toBeNull() }) @@ -253,33 +258,25 @@ describe('NodeModal', () => { expect((onSubmit.mock.calls[0][0] as Partial).container_mode).toBe(false) }) - // ── Parent Proxmox (vm / lxc only) ─────────────────────────────────── + // ── Parent container ────────────────────────────────────────────────── - it('shows Parent Proxmox for vm with proxmoxNodes', () => { + it('shows Parent Container when options are provided', () => { renderModal({ - initial: { ...BASE, type: 'vm' }, - proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }], + initial: { ...BASE, type: 'server' }, + parentContainerNodes: [{ id: 'c1', label: 'Container 01' }], }) - expect(screen.getByText('Parent Proxmox')).toBeDefined() - expect(screen.getByText('PVE-01')).toBeDefined() + expect(screen.getByText('Parent Container')).toBeDefined() + expect(screen.getByText('Container 01')).toBeDefined() }) - it('shows Parent Proxmox for lxc with proxmoxNodes', () => { - renderModal({ - initial: { ...BASE, type: 'lxc' }, - proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }], - }) - expect(screen.getByText('Parent Proxmox')).toBeDefined() + it('hides Parent Container for groupRect type', () => { + renderModal({ initial: { ...BASE, type: 'groupRect' }, parentContainerNodes: [{ id: 'c1', label: 'Container 01' }] }) + expect(screen.queryByText('Parent Container')).toBeNull() }) - it('hides Parent Proxmox for server type', () => { - renderModal({ initial: BASE, proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }] }) - expect(screen.queryByText('Parent Proxmox')).toBeNull() - }) - - it('hides Parent Proxmox for vm when no proxmoxNodes', () => { - renderModal({ initial: { ...BASE, type: 'vm' } }) - expect(screen.queryByText('Parent Proxmox')).toBeNull() + it('hides Parent Container when no container options are available', () => { + renderModal({ initial: { ...BASE, type: 'server' } }) + expect(screen.queryByText('Parent Container')).toBeNull() }) // ── Appearance ──────────────────────────────────────────────────────── diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index f436f69..65af38e 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -49,6 +49,21 @@ describe('canvasStore', () => { expect(hasUnsavedChanges).toBe(true) }) + it('addNode nests under parent only when parent is in container mode', () => { + const parent = { ...makeNode('p1', { container_mode: false }), position: { x: 100, y: 100 } } + const child = { ...makeNode('c1', { parent_id: 'p1' }), position: { x: 150, y: 180 } } + useCanvasStore.getState().addNode(parent) + useCanvasStore.getState().addNode(child) + const childNode = useCanvasStore.getState().nodes.find((n) => n.id === 'c1') + expect(childNode?.parentId).toBeUndefined() + + useCanvasStore.getState().updateNode('p1', { container_mode: true }) + useCanvasStore.getState().setProxmoxContainerMode('p1', true) + const nested = useCanvasStore.getState().nodes.find((n) => n.id === 'c1') + expect(nested?.parentId).toBe('p1') + expect(nested?.extent).toBe('parent') + }) + it('updateNode updates data fields', () => { useCanvasStore.getState().addNode(makeNode('n1', { label: 'old' })) useCanvasStore.getState().updateNode('n1', { label: 'new', ip: '10.0.0.1' }) diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index 9df76ad..0804236 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -172,8 +172,18 @@ export const useCanvasStore = create((set) => ({ addNode: (node) => set((state) => { - const enriched = node.data.parent_id - ? { ...node, parentId: node.data.parent_id, extent: 'parent' as const } + const parent = node.data.parent_id ? state.nodes.find((n) => n.id === node.data.parent_id) : null + const shouldNestInParent = !!(parent?.data.container_mode) + const enriched = node.data.parent_id && shouldNestInParent + ? { + ...node, + parentId: node.data.parent_id, + extent: 'parent' as const, + position: { + x: Math.max(10, node.position.x - parent.position.x), + y: Math.max(10, node.position.y - parent.position.y), + }, + } : node // Parents must come before children in the array (React Flow requirement) const withoutNew = state.nodes.filter((n) => n.id !== node.id) @@ -283,14 +293,38 @@ export const useCanvasStore = create((set) => ({ setProxmoxContainerMode: (proxmoxId, enabled) => set((state) => { + const parentNode = state.nodes.find((n) => n.id === proxmoxId) let nodes = state.nodes.map((n) => { if (n.id === proxmoxId) { const withMode = { ...n, data: { ...n.data, container_mode: enabled } } + if (n.data.type !== 'proxmox') return withMode return enabled - ? { ...withMode, width: 300, height: 200 } + ? { ...withMode, width: n.width ?? 300, height: n.height ?? 200 } : { ...withMode, width: undefined, height: undefined } } if (n.data.parent_id === proxmoxId) { + if (enabled && parentNode) { + return { + ...n, + parentId: proxmoxId, + extent: 'parent' as const, + position: { + x: Math.max(10, n.position.x - parentNode.position.x), + y: Math.max(10, n.position.y - parentNode.position.y), + }, + } + } + if (!enabled && parentNode) { + return { + ...n, + parentId: undefined, + extent: undefined, + position: { + x: parentNode.position.x + n.position.x, + y: parentNode.position.y + n.position.y, + }, + } + } return enabled ? { ...n, parentId: proxmoxId, extent: 'parent' as const } : { ...n, parentId: undefined, extent: undefined } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a9bdaee..2d38308 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -13,7 +13,7 @@ export type NodeType = | 'printer' | 'computer' | 'cpl' - | 'docker' + | 'docker_host' | 'generic' | 'groupRect' | 'group' @@ -127,7 +127,7 @@ export const NODE_TYPE_LABELS: Record = { printer: 'Printer', computer: 'Computer', cpl: 'CPL / Powerline', - docker: 'Docker Host', + docker_host: 'Docker Host', generic: 'Generic Device', groupRect: 'Group Rectangle', group: 'Node Group', diff --git a/frontend/src/utils/__tests__/themes.test.ts b/frontend/src/utils/__tests__/themes.test.ts index aa06ea5..e186106 100644 --- a/frontend/src/utils/__tests__/themes.test.ts +++ b/frontend/src/utils/__tests__/themes.test.ts @@ -4,7 +4,7 @@ import type { NodeType, EdgeType, NodeStatus } from '@/types' const NODE_TYPES: NodeType[] = [ 'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc', - 'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker', 'generic', 'groupRect', + 'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host', 'generic', 'groupRect', ] const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster'] const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown'] @@ -84,7 +84,7 @@ describe('THEMES', () => { expect(d.nodeAccents.server.border).toBe('#a855f7') expect(d.nodeAccents.isp.border).toBe('#00d4ff') expect(d.nodeAccents.proxmox.border).toBe('#ff6e00') - expect(d.nodeAccents.docker.border).toBe('#2496ED') + expect(d.nodeAccents.docker_host.border).toBe('#2496ED') expect(d.nodeCardBackground).toBe('#21262d') expect(d.nodeIconBackground).toBe('#161b22') expect(d.canvasBackground).toBe('#0d1117') diff --git a/frontend/src/utils/canvasSerializer.ts b/frontend/src/utils/canvasSerializer.ts index a0b0a43..02d4fcf 100644 --- a/frontend/src/utils/canvasSerializer.ts +++ b/frontend/src/utils/canvasSerializer.ts @@ -134,6 +134,7 @@ export function deserializeApiNode( n: ApiNode, proxmoxContainerMap: Map, ): Node { + const normalizedType = n.type === 'docker' ? 'docker_host' : n.type if (n.type === 'groupRect') { const w = (n.custom_colors?.width as number | undefined) ?? 360 const h = (n.custom_colors?.height as number | undefined) ?? 240 @@ -152,12 +153,15 @@ export function deserializeApiNode( const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false return { id: n.id, - type: n.type, + type: normalizedType, position: { x: n.pos_x, y: n.pos_y }, - data: n as unknown as NodeData, + data: { ...n, type: normalizedType } as unknown as NodeData, ...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}), - ...(n.width ? { width: n.width } : n.type === 'proxmox' && n.container_mode !== false ? { width: 300 } : {}), - ...(n.height ? { height: n.height } : n.type === 'proxmox' && n.container_mode !== false ? { height: 200 } : {}), + ...(normalizedType === 'proxmox' && n.container_mode !== false + ? { width: n.width ?? 300, height: n.height ?? 200 } + : {}), + ...(n.width && normalizedType !== 'proxmox' ? { width: n.width } : {}), + ...(n.height && normalizedType !== 'proxmox' ? { height: n.height } : {}), } } diff --git a/frontend/src/utils/nodeIcons.ts b/frontend/src/utils/nodeIcons.ts index 6dbf5f5..74504c2 100644 --- a/frontend/src/utils/nodeIcons.ts +++ b/frontend/src/utils/nodeIcons.ts @@ -132,7 +132,7 @@ export const NODE_TYPE_DEFAULT_ICONS: Record = { printer: Printer, computer: Monitor, cpl: PlugZap, - docker: Anchor, + docker_host: Anchor, generic: Circle, group: Circle, groupRect: Circle, diff --git a/frontend/src/utils/themes.ts b/frontend/src/utils/themes.ts index 23d0be0..7f86382 100644 --- a/frontend/src/utils/themes.ts +++ b/frontend/src/utils/themes.ts @@ -56,7 +56,7 @@ export const THEMES: Record = { printer: { border: '#8b949e', icon: '#8b949e' }, computer: { border: '#a855f7', icon: '#a855f7' }, cpl: { border: '#e3b341', icon: '#e3b341' }, - docker: { border: '#2496ED', icon: '#2496ED' }, + docker_host: { border: '#2496ED', icon: '#2496ED' }, generic: { border: '#8b949e', icon: '#8b949e' }, groupRect:{ border: '#00d4ff', icon: '#00d4ff' }, group: { border: '#00d4ff', icon: '#00d4ff' }, @@ -111,7 +111,7 @@ export const THEMES: Record = { printer: { border: '#94a3b8', icon: '#94a3b8' }, computer: { border: '#c084fc', icon: '#c084fc' }, cpl: { border: '#fbbf24', icon: '#fbbf24' }, - docker: { border: '#2496ED', icon: '#2496ED' }, + docker_host: { border: '#2496ED', icon: '#2496ED' }, generic: { border: '#94a3b8', icon: '#94a3b8' }, groupRect:{ border: '#22d3ee', icon: '#22d3ee' }, group: { border: '#22d3ee', icon: '#22d3ee' }, @@ -166,7 +166,7 @@ export const THEMES: Record = { printer: { border: '#6b7280', icon: '#6b7280' }, computer: { border: '#7c3aed', icon: '#7c3aed' }, cpl: { border: '#b45309', icon: '#b45309' }, - docker: { border: '#2496ED', icon: '#2496ED' }, + docker_host: { border: '#2496ED', icon: '#2496ED' }, generic: { border: '#6b7280', icon: '#6b7280' }, groupRect:{ border: '#0284c7', icon: '#0284c7' }, group: { border: '#0284c7', icon: '#0284c7' }, @@ -221,7 +221,7 @@ export const THEMES: Record = { printer: { border: '#8888ff', icon: '#8888ff' }, computer: { border: '#ff00ff', icon: '#ff00ff' }, cpl: { border: '#ffff00', icon: '#ffff00' }, - docker: { border: '#00aaff', icon: '#00aaff' }, + docker_host: { border: '#00aaff', icon: '#00aaff' }, generic: { border: '#8888ff', icon: '#8888ff' }, groupRect:{ border: '#00ffff', icon: '#00ffff' }, group: { border: '#00ffff', icon: '#00ffff' }, @@ -276,7 +276,7 @@ export const THEMES: Record = { printer: { border: '#005500', icon: '#005500' }, computer: { border: '#008822', icon: '#008822' }, cpl: { border: '#66ff33', icon: '#66ff33' }, - docker: { border: '#00cc88', icon: '#00cc88' }, + docker_host: { border: '#00cc88', icon: '#00cc88' }, generic: { border: '#006600', icon: '#006600' }, groupRect:{ border: '#00ff41', icon: '#00ff41' }, group: { border: '#00ff41', icon: '#00ff41' }, From 9f880395da3834f906a5f1178e3a8910182cb0db Mon Sep 17 00:00:00 2001 From: findthelorax Date: Sun, 19 Apr 2026 23:52:36 -0400 Subject: [PATCH 03/24] fix: adjusted deserialize for all virtualization node types --- frontend/src/utils/canvasSerializer.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/utils/canvasSerializer.ts b/frontend/src/utils/canvasSerializer.ts index 02d4fcf..562c400 100644 --- a/frontend/src/utils/canvasSerializer.ts +++ b/frontend/src/utils/canvasSerializer.ts @@ -157,11 +157,11 @@ export function deserializeApiNode( position: { x: n.pos_x, y: n.pos_y }, data: { ...n, type: normalizedType } as unknown as NodeData, ...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}), - ...(normalizedType === 'proxmox' && n.container_mode !== false + ...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false ? { width: n.width ?? 300, height: n.height ?? 200 } : {}), - ...(n.width && normalizedType !== 'proxmox' ? { width: n.width } : {}), - ...(n.height && normalizedType !== 'proxmox' ? { height: n.height } : {}), + ...(n.width && !['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) ? { width: n.width } : {}), + ...(n.height && !['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) ? { height: n.height } : {}), } } From c9a402fa0a28adce4423b0371b1191923718bb89 Mon Sep 17 00:00:00 2001 From: findthelorax Date: Sun, 19 Apr 2026 23:58:09 -0400 Subject: [PATCH 04/24] fix: shows the proper parent node names once selected in the edit node modal --- frontend/src/components/modals/NodeModal.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index cc3dd15..f820691 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -268,7 +268,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)} > - + + {form.parent_id + ? (parentContainerNodes.find((n) => n.id === form.parent_id)?.label ?? 'None (standalone)') + : 'None (standalone)'} + None (standalone) From cb5b1bd2e2f5455a41f394ce5f0c43e7c8085693 Mon Sep 17 00:00:00 2001 From: findthelorax Date: Mon, 20 Apr 2026 12:02:44 -0400 Subject: [PATCH 05/24] adjusting wording by replacing proxmox with generics since multiple node types can be a parent node --- frontend/src/App.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1a26531..8ec3d6b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -101,7 +101,7 @@ export default function App() { .then((res) => { const { nodes: apiNodes, edges: apiEdges } = res.data if (apiNodes.length > 0) { - // Build a map of proxmox container mode to know if children should be nested + // Build a map of container mode nodes to know if children should be nested const proxmoxContainerMap = new Map( (apiNodes as ApiNode[]) .filter((n) => n.type === 'group' || n.container_mode === true) @@ -152,7 +152,7 @@ export default function App() { const handleAddNode = useCallback((data: Partial) => { snapshotHistory() const id = generateUUID() - const isProxmox = data.type === 'proxmox' + const isContainerNode = CONTAINER_MODE_TYPES.has(data.type as NodeData['type']) const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null // Children position is relative to parent; place near top-left with padding const position = parentNode @@ -165,7 +165,7 @@ export default function App() { position, data: { status: 'unknown', services: [], ...data } as NodeData, ...(data.parent_id ? { parentId: data.parent_id, extent: 'parent' as const } : {}), - ...(isProxmox ? { width: 300, height: 200 } : {}), + ...(isContainerNode ? { width: 300, height: 200 } : {}), } addNode(newNode) toast.success(`Added "${data.label}"`) @@ -321,15 +321,15 @@ export default function App() { if (!pendingConnection) return snapshotHistory() onConnect({ ...pendingConnection, ...edgeData } as unknown as Connection) - // When a virtual edge is drawn between LXC/VM (top) and Proxmox (bottom), sync parent_id + // When a virtual edge is drawn between a child node and a container node, sync parent_id if (edgeData.type === 'virtual') { const src = nodes.find((n) => n.id === pendingConnection.source) const tgt = nodes.find((n) => n.id === pendingConnection.target) - const srcType = src?.data.type - const tgtType = tgt?.data.type - if ((srcType === 'lxc' || srcType === 'vm') && tgtType === 'proxmox') { + const srcType = src?.data.type as NodeData['type'] + const tgtType = tgt?.data.type as NodeData['type'] + if ((srcType === 'lxc' || srcType === 'vm') && CONTAINER_MODE_TYPES.has(tgtType)) { updateNode(pendingConnection.source, { parent_id: pendingConnection.target }) - } else if (srcType === 'proxmox' && (tgtType === 'lxc' || tgtType === 'vm')) { + } else if (CONTAINER_MODE_TYPES.has(srcType) && (tgtType === 'lxc' || tgtType === 'vm')) { updateNode(pendingConnection.target, { parent_id: pendingConnection.source }) } } From 7312132767cca14c48baec2c0016c581eede2031 Mon Sep 17 00:00:00 2001 From: findthelorax Date: Mon, 20 Apr 2026 12:18:46 -0400 Subject: [PATCH 06/24] adjusted test to be more exhaustive of all container and parent node types --- .../modals/__tests__/NodeModal.test.tsx | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx index a466733..78a7cec 100644 --- a/frontend/src/components/modals/__tests__/NodeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/NodeModal.test.tsx @@ -236,18 +236,16 @@ describe('NodeModal', () => { // ── Container mode ───────────────────────────────────────────────────── - it('shows Container Mode toggle for proxmox type', () => { - renderModal({ initial: { ...BASE, type: 'proxmox' } }) + const containerModeTypes = ['proxmox', 'vm', 'lxc', 'docker_host'] as const + const nonContainerModeTypes = ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic', 'groupRect', 'group'] as const + + it.each(containerModeTypes)('shows Container Mode toggle for %s type', (type) => { + renderModal({ initial: { ...BASE, type } }) expect(screen.getByText('Container Mode')).toBeDefined() }) - it('hides Container Mode for server type', () => { - renderModal({ initial: { ...BASE, type: 'server' } }) - expect(screen.queryByText('Container Mode')).toBeNull() - }) - - it('hides Container Mode for groupRect type', () => { - renderModal({ initial: { ...BASE, type: 'groupRect' } }) + it.each(nonContainerModeTypes)('hides Container Mode for %s type', (type) => { + renderModal({ initial: { ...BASE, type } }) expect(screen.queryByText('Container Mode')).toBeNull() }) @@ -260,22 +258,25 @@ describe('NodeModal', () => { // ── Parent container ────────────────────────────────────────────────── - it('shows Parent Container when options are provided', () => { + const parentContainerVisibleTypes = ['proxmox', 'vm', 'lxc', 'docker_host', 'isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic'] as const + const parentContainerHiddenTypes = ['groupRect', 'group'] as const + + it.each(parentContainerVisibleTypes)('shows Parent Container for %s type when options are provided', (type) => { renderModal({ - initial: { ...BASE, type: 'server' }, + initial: { ...BASE, type }, parentContainerNodes: [{ id: 'c1', label: 'Container 01' }], }) expect(screen.getByText('Parent Container')).toBeDefined() expect(screen.getByText('Container 01')).toBeDefined() }) - it('hides Parent Container for groupRect type', () => { - renderModal({ initial: { ...BASE, type: 'groupRect' }, parentContainerNodes: [{ id: 'c1', label: 'Container 01' }] }) + it.each(parentContainerHiddenTypes)('hides Parent Container for %s type even when options are provided', (type) => { + renderModal({ initial: { ...BASE, type }, parentContainerNodes: [{ id: 'c1', label: 'Container 01' }] }) expect(screen.queryByText('Parent Container')).toBeNull() }) - it('hides Parent Container when no container options are available', () => { - renderModal({ initial: { ...BASE, type: 'server' } }) + it.each(parentContainerVisibleTypes)('hides Parent Container for %s type when no container options are available', (type) => { + renderModal({ initial: { ...BASE, type } }) expect(screen.queryByText('Parent Container')).toBeNull() }) From 0e70b45e8a557500cf3a600ef5c1bc9e6aa06383 Mon Sep 17 00:00:00 2001 From: findthelorax Date: Mon, 20 Apr 2026 12:33:20 -0400 Subject: [PATCH 07/24] code aligned cleanup --- frontend/src/utils/nodeIcons.ts | 112 ++++++++++---------- frontend/src/utils/themes.ts | 176 ++++++++++++++++---------------- 2 files changed, 144 insertions(+), 144 deletions(-) diff --git a/frontend/src/utils/nodeIcons.ts b/frontend/src/utils/nodeIcons.ts index 74504c2..8287d1d 100644 --- a/frontend/src/utils/nodeIcons.ts +++ b/frontend/src/utils/nodeIcons.ts @@ -32,21 +32,21 @@ export interface IconEntry { export const ICON_REGISTRY: IconEntry[] = [ // --- Infrastructure --- - { key: 'globe', label: 'Globe / ISP', category: 'Infrastructure', icon: Globe }, - { key: 'router', label: 'Router', category: 'Infrastructure', icon: Router }, - { key: 'network', label: 'Switch / Network', category: 'Infrastructure', icon: Network }, - { key: 'server', label: 'Server', category: 'Infrastructure', icon: Server }, + { key: 'globe', label: 'Globe / ISP', category: 'Infrastructure', icon: Globe }, + { key: 'router', label: 'Router', category: 'Infrastructure', icon: Router }, + { key: 'network', label: 'Switch / Network', category: 'Infrastructure', icon: Network }, + { key: 'server', label: 'Server', category: 'Infrastructure', icon: Server }, { key: 'layers', label: 'Proxmox / Hypervisor', category: 'Infrastructure', icon: Layers }, - { key: 'box', label: 'VM', category: 'Infrastructure', icon: Box }, - { key: 'container', label: 'Container / LXC', category: 'Infrastructure', icon: Container }, - { key: 'harddrive', label: 'NAS / Storage', category: 'Infrastructure', icon: HardDrive }, - { key: 'cpu', label: 'IoT / Embedded', category: 'Infrastructure', icon: Cpu }, - { key: 'wifi', label: 'Access Point', category: 'Infrastructure', icon: Wifi }, - { key: 'circle', label: 'Generic', category: 'Infrastructure', icon: Circle }, - { key: 'monitor', label: 'Workstation', category: 'Infrastructure', icon: Monitor }, - { key: 'smartphone', label: 'Phone / Mobile', category: 'Infrastructure', icon: Smartphone }, - { key: 'printer', label: 'Printer', category: 'Infrastructure', icon: Printer }, - { key: 'plugzap', label: 'CPL / Powerline', category: 'Infrastructure', icon: PlugZap }, + { key: 'box', label: 'VM', category: 'Infrastructure', icon: Box }, + { key: 'container', label: 'Container / LXC', category: 'Infrastructure', icon: Container }, + { key: 'harddrive', label: 'NAS / Storage', category: 'Infrastructure', icon: HardDrive }, + { key: 'cpu', label: 'IoT / Embedded', category: 'Infrastructure', icon: Cpu }, + { key: 'wifi', label: 'Access Point', category: 'Infrastructure', icon: Wifi }, + { key: 'circle', label: 'Generic', category: 'Infrastructure', icon: Circle }, + { key: 'monitor', label: 'Workstation', category: 'Infrastructure', icon: Monitor }, + { key: 'smartphone', label: 'Phone / Mobile', category: 'Infrastructure', icon: Smartphone }, + { key: 'printer', label: 'Printer', category: 'Infrastructure', icon: Printer }, + { key: 'plugzap', label: 'CPL / Powerline', category: 'Infrastructure', icon: PlugZap }, // --- Media --- { key: 'play', label: 'Jellyfin / Emby', category: 'Media', icon: Play }, @@ -62,40 +62,40 @@ export const ICON_REGISTRY: IconEntry[] = [ { key: 'cctv', label: 'CCTV / IP Camera', category: 'Media', icon: Cctv }, // --- Monitoring --- - { key: 'activity', label: 'Prometheus / Uptime', category: 'Monitoring', icon: Activity }, - { key: 'barchart', label: 'Grafana / Kibana', category: 'Monitoring', icon: BarChart2 }, - { key: 'linechart', label: 'InfluxDB / Metrics', category: 'Monitoring', icon: LineChart }, + { key: 'activity', label: 'Prometheus / Uptime', category: 'Monitoring', icon: Activity }, + { key: 'barchart', label: 'Grafana / Kibana', category: 'Monitoring', icon: BarChart2 }, + { key: 'linechart', label: 'InfluxDB / Metrics', category: 'Monitoring', icon: LineChart }, { key: 'eye', label: 'Overseerr / Watchlist', category: 'Monitoring', icon: Eye }, - { key: 'bell', label: 'Alerts / Notifiarr', category: 'Monitoring', icon: Bell }, - { key: 'gauge', label: 'Dashboard / Status', category: 'Monitoring', icon: Gauge }, + { key: 'bell', label: 'Alerts / Notifiarr', category: 'Monitoring', icon: Bell }, + { key: 'gauge', label: 'Dashboard / Status', category: 'Monitoring', icon: Gauge }, // --- Storage & Databases --- - { key: 'database', label: 'Database (SQL/NoSQL)', category: 'Storage', icon: Database }, - { key: 'archive', label: 'Backup / Archive', category: 'Storage', icon: Archive }, - { key: 'cloud', label: 'Nextcloud / S3', category: 'Storage', icon: Cloud }, - { key: 'folder', label: 'Files / Filebrowser', category: 'Storage', icon: FolderOpen }, + { key: 'database', label: 'Database (SQL/NoSQL)', category: 'Storage', icon: Database }, + { key: 'archive', label: 'Backup / Archive', category: 'Storage', icon: Archive }, + { key: 'cloud', label: 'Nextcloud / S3', category: 'Storage', icon: Cloud }, + { key: 'folder', label: 'Files / Filebrowser', category: 'Storage', icon: FolderOpen }, { key: 'download', label: 'Downloader (Torrent/NZB)', category: 'Storage', icon: Download }, - { key: 'upload', label: 'Upload / Sync', category: 'Storage', icon: Upload }, - { key: 'refresh', label: 'Sync / Resilio', category: 'Storage', icon: RefreshCw }, + { key: 'upload', label: 'Upload / Sync', category: 'Storage', icon: Upload }, + { key: 'refresh', label: 'Sync / Resilio', category: 'Storage', icon: RefreshCw }, // --- Security & Auth --- - { key: 'shield', label: 'Pi-hole / DNS Block', category: 'Security', icon: Shield }, - { key: 'shieldcheck', label: 'AdGuard Home', category: 'Security', icon: ShieldCheck }, + { key: 'shield', label: 'Pi-hole / DNS Block', category: 'Security', icon: Shield }, + { key: 'shieldcheck', label: 'AdGuard Home', category: 'Security', icon: ShieldCheck }, { key: 'lock', label: 'Authelia / Authentik', category: 'Security', icon: Lock }, - { key: 'key', label: 'Vaultwarden / Vault', category: 'Security', icon: Key }, - { key: 'users', label: 'LDAP / SSO', category: 'Security', icon: Users }, - { key: 'usercheck', label: 'Keycloak', category: 'Security', icon: UserCheck }, - { key: 'filter', label: 'Prowlarr / Jackett', category: 'Security', icon: Filter }, - { key: 'search', label: 'Search / Indexer', category: 'Security', icon: Search }, + { key: 'key', label: 'Vaultwarden / Vault', category: 'Security', icon: Key }, + { key: 'users', label: 'LDAP / SSO', category: 'Security', icon: Users }, + { key: 'usercheck', label: 'Keycloak', category: 'Security', icon: UserCheck }, + { key: 'filter', label: 'Prowlarr / Jackett', category: 'Security', icon: Filter }, + { key: 'search', label: 'Search / Indexer', category: 'Security', icon: Search }, // --- Automation & Smart Home --- - { key: 'home', label: 'Home Assistant', category: 'Automation', icon: Home }, - { key: 'zap', label: 'ESPHome / Node-RED', category: 'Automation', icon: Zap }, - { key: 'workflow', label: 'n8n / Node-RED', category: 'Automation', icon: Workflow }, - { key: 'bot', label: 'Bot / Automation', category: 'Automation', icon: Bot }, + { key: 'home', label: 'Home Assistant', category: 'Automation', icon: Home }, + { key: 'zap', label: 'ESPHome / Node-RED', category: 'Automation', icon: Zap }, + { key: 'workflow', label: 'n8n / Node-RED', category: 'Automation', icon: Workflow }, + { key: 'bot', label: 'Bot / Automation', category: 'Automation', icon: Bot }, { key: 'thermometer', label: 'Sensor / Temperature', category: 'Automation', icon: Thermometer }, - { key: 'lightbulb', label: 'Smart Light / Zigbee', category: 'Automation', icon: Lightbulb }, - { key: 'radio', label: 'MQTT / RTL-SDR', category: 'Automation', icon: Radio }, + { key: 'lightbulb', label: 'Smart Light / Zigbee', category: 'Automation', icon: Lightbulb }, + { key: 'radio', label: 'MQTT / RTL-SDR', category: 'Automation', icon: Radio }, // --- Containers & Dev --- { key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor }, @@ -118,24 +118,24 @@ export const ICON_MAP: Record = Object.fromEntries( ) export const NODE_TYPE_DEFAULT_ICONS: Record = { - isp: Globe, - router: Router, - switch: Network, - server: Server, - proxmox: Layers, - vm: Box, - lxc: Container, - nas: HardDrive, - iot: Cpu, - ap: Wifi, - camera: Cctv, - printer: Printer, - computer: Monitor, - cpl: PlugZap, - docker_host: Anchor, - generic: Circle, - group: Circle, - groupRect: Circle, + isp: Globe, + router: Router, + switch: Network, + server: Server, + proxmox: Layers, + vm: Box, + lxc: Container, + nas: HardDrive, + iot: Cpu, + ap: Wifi, + camera: Cctv, + printer: Printer, + computer: Monitor, + cpl: PlugZap, + docker_host: Anchor, + generic: Circle, + group: Circle, + groupRect: Circle, } /** Resolve the display icon for a node — custom_icon takes priority over type default. */ diff --git a/frontend/src/utils/themes.ts b/frontend/src/utils/themes.ts index 7f86382..2880b66 100644 --- a/frontend/src/utils/themes.ts +++ b/frontend/src/utils/themes.ts @@ -42,24 +42,24 @@ export const THEMES: Record = { description: 'Dark futuristic — the original Homelable look', colors: { nodeAccents: { - isp: { border: '#00d4ff', icon: '#00d4ff' }, - router: { border: '#00d4ff', icon: '#00d4ff' }, - switch: { border: '#39d353', icon: '#39d353' }, - server: { border: '#a855f7', icon: '#a855f7' }, - proxmox: { border: '#ff6e00', icon: '#ff6e00' }, - vm: { border: '#a855f7', icon: '#a855f7' }, - lxc: { border: '#00d4ff', icon: '#00d4ff' }, - nas: { border: '#39d353', icon: '#39d353' }, - iot: { border: '#e3b341', icon: '#e3b341' }, - ap: { border: '#00d4ff', icon: '#00d4ff' }, - camera: { border: '#8b949e', icon: '#8b949e' }, - printer: { border: '#8b949e', icon: '#8b949e' }, - computer: { border: '#a855f7', icon: '#a855f7' }, - cpl: { border: '#e3b341', icon: '#e3b341' }, - docker_host: { border: '#2496ED', icon: '#2496ED' }, - generic: { border: '#8b949e', icon: '#8b949e' }, - groupRect:{ border: '#00d4ff', icon: '#00d4ff' }, - group: { border: '#00d4ff', icon: '#00d4ff' }, + isp: { border: '#00d4ff', icon: '#00d4ff' }, + router: { border: '#00d4ff', icon: '#00d4ff' }, + switch: { border: '#39d353', icon: '#39d353' }, + server: { border: '#a855f7', icon: '#a855f7' }, + proxmox: { border: '#ff6e00', icon: '#ff6e00' }, + vm: { border: '#a855f7', icon: '#a855f7' }, + lxc: { border: '#00d4ff', icon: '#00d4ff' }, + nas: { border: '#39d353', icon: '#39d353' }, + iot: { border: '#e3b341', icon: '#e3b341' }, + ap: { border: '#00d4ff', icon: '#00d4ff' }, + camera: { border: '#8b949e', icon: '#8b949e' }, + printer: { border: '#8b949e', icon: '#8b949e' }, + computer: { border: '#a855f7', icon: '#a855f7' }, + cpl: { border: '#e3b341', icon: '#e3b341' }, + docker_host: { border: '#2496ED', icon: '#2496ED' }, + generic: { border: '#8b949e', icon: '#8b949e' }, + groupRect: { border: '#00d4ff', icon: '#00d4ff' }, + group: { border: '#00d4ff', icon: '#00d4ff' }, }, nodeCardBackground: '#21262d', nodeIconBackground: '#161b22', @@ -97,24 +97,24 @@ export const THEMES: Record = { description: 'Pure black with maximum contrast', colors: { nodeAccents: { - isp: { border: '#22d3ee', icon: '#22d3ee' }, - router: { border: '#22d3ee', icon: '#22d3ee' }, - switch: { border: '#4ade80', icon: '#4ade80' }, - server: { border: '#c084fc', icon: '#c084fc' }, - proxmox: { border: '#fb923c', icon: '#fb923c' }, - vm: { border: '#c084fc', icon: '#c084fc' }, - lxc: { border: '#22d3ee', icon: '#22d3ee' }, - nas: { border: '#4ade80', icon: '#4ade80' }, - iot: { border: '#fbbf24', icon: '#fbbf24' }, - ap: { border: '#22d3ee', icon: '#22d3ee' }, - camera: { border: '#94a3b8', icon: '#94a3b8' }, - printer: { border: '#94a3b8', icon: '#94a3b8' }, - computer: { border: '#c084fc', icon: '#c084fc' }, - cpl: { border: '#fbbf24', icon: '#fbbf24' }, + isp: { border: '#22d3ee', icon: '#22d3ee' }, + router: { border: '#22d3ee', icon: '#22d3ee' }, + switch: { border: '#4ade80', icon: '#4ade80' }, + server: { border: '#c084fc', icon: '#c084fc' }, + proxmox: { border: '#fb923c', icon: '#fb923c' }, + vm: { border: '#c084fc', icon: '#c084fc' }, + lxc: { border: '#22d3ee', icon: '#22d3ee' }, + nas: { border: '#4ade80', icon: '#4ade80' }, + iot: { border: '#fbbf24', icon: '#fbbf24' }, + ap: { border: '#22d3ee', icon: '#22d3ee' }, + camera: { border: '#94a3b8', icon: '#94a3b8' }, + printer: { border: '#94a3b8', icon: '#94a3b8' }, + computer: { border: '#c084fc', icon: '#c084fc' }, + cpl: { border: '#fbbf24', icon: '#fbbf24' }, docker_host: { border: '#2496ED', icon: '#2496ED' }, - generic: { border: '#94a3b8', icon: '#94a3b8' }, - groupRect:{ border: '#22d3ee', icon: '#22d3ee' }, - group: { border: '#22d3ee', icon: '#22d3ee' }, + generic: { border: '#94a3b8', icon: '#94a3b8' }, + groupRect: { border: '#22d3ee', icon: '#22d3ee' }, + group: { border: '#22d3ee', icon: '#22d3ee' }, }, nodeCardBackground: '#0a0a0a', nodeIconBackground: '#111111', @@ -152,24 +152,24 @@ export const THEMES: Record = { description: 'Clean light theme with dark text', colors: { nodeAccents: { - isp: { border: '#0284c7', icon: '#0284c7' }, - router: { border: '#0284c7', icon: '#0284c7' }, - switch: { border: '#16a34a', icon: '#16a34a' }, - server: { border: '#7c3aed', icon: '#7c3aed' }, - proxmox: { border: '#ea580c', icon: '#ea580c' }, - vm: { border: '#7c3aed', icon: '#7c3aed' }, - lxc: { border: '#0284c7', icon: '#0284c7' }, - nas: { border: '#16a34a', icon: '#16a34a' }, - iot: { border: '#b45309', icon: '#b45309' }, - ap: { border: '#0284c7', icon: '#0284c7' }, - camera: { border: '#6b7280', icon: '#6b7280' }, - printer: { border: '#6b7280', icon: '#6b7280' }, - computer: { border: '#7c3aed', icon: '#7c3aed' }, - cpl: { border: '#b45309', icon: '#b45309' }, + isp: { border: '#0284c7', icon: '#0284c7' }, + router: { border: '#0284c7', icon: '#0284c7' }, + switch: { border: '#16a34a', icon: '#16a34a' }, + server: { border: '#7c3aed', icon: '#7c3aed' }, + proxmox: { border: '#ea580c', icon: '#ea580c' }, + vm: { border: '#7c3aed', icon: '#7c3aed' }, + lxc: { border: '#0284c7', icon: '#0284c7' }, + nas: { border: '#16a34a', icon: '#16a34a' }, + iot: { border: '#b45309', icon: '#b45309' }, + ap: { border: '#0284c7', icon: '#0284c7' }, + camera: { border: '#6b7280', icon: '#6b7280' }, + printer: { border: '#6b7280', icon: '#6b7280' }, + computer: { border: '#7c3aed', icon: '#7c3aed' }, + cpl: { border: '#b45309', icon: '#b45309' }, docker_host: { border: '#2496ED', icon: '#2496ED' }, - generic: { border: '#6b7280', icon: '#6b7280' }, - groupRect:{ border: '#0284c7', icon: '#0284c7' }, - group: { border: '#0284c7', icon: '#0284c7' }, + generic: { border: '#6b7280', icon: '#6b7280' }, + groupRect: { border: '#0284c7', icon: '#0284c7' }, + group: { border: '#0284c7', icon: '#0284c7' }, }, nodeCardBackground: '#ffffff', nodeIconBackground: '#f0f6ff', @@ -207,24 +207,24 @@ export const THEMES: Record = { description: 'Cyberpunk vibes with vivid glowing accents', colors: { nodeAccents: { - isp: { border: '#00ffff', icon: '#00ffff' }, - router: { border: '#00ffff', icon: '#00ffff' }, - switch: { border: '#00ff80', icon: '#00ff80' }, - server: { border: '#ff00ff', icon: '#ff00ff' }, - proxmox: { border: '#ff8800', icon: '#ff8800' }, - vm: { border: '#ff00ff', icon: '#ff00ff' }, - lxc: { border: '#00ffff', icon: '#00ffff' }, - nas: { border: '#00ff80', icon: '#00ff80' }, - iot: { border: '#ffff00', icon: '#ffff00' }, - ap: { border: '#00ffff', icon: '#00ffff' }, - camera: { border: '#8888ff', icon: '#8888ff' }, - printer: { border: '#8888ff', icon: '#8888ff' }, - computer: { border: '#ff00ff', icon: '#ff00ff' }, - cpl: { border: '#ffff00', icon: '#ffff00' }, - docker_host: { border: '#00aaff', icon: '#00aaff' }, - generic: { border: '#8888ff', icon: '#8888ff' }, - groupRect:{ border: '#00ffff', icon: '#00ffff' }, - group: { border: '#00ffff', icon: '#00ffff' }, + isp: { border: '#00ffff', icon: '#00ffff' }, + router: { border: '#00ffff', icon: '#00ffff' }, + switch: { border: '#00ff80', icon: '#00ff80' }, + server: { border: '#ff00ff', icon: '#ff00ff' }, + proxmox: { border: '#ff8800', icon: '#ff8800' }, + vm: { border: '#ff00ff', icon: '#ff00ff' }, + lxc: { border: '#00ffff', icon: '#00ffff' }, + nas: { border: '#00ff80', icon: '#00ff80' }, + iot: { border: '#ffff00', icon: '#ffff00' }, + ap: { border: '#00ffff', icon: '#00ffff' }, + camera: { border: '#8888ff', icon: '#8888ff' }, + printer: { border: '#8888ff', icon: '#8888ff' }, + computer: { border: '#ff00ff', icon: '#ff00ff' }, + cpl: { border: '#ffff00', icon: '#ffff00' }, + docker_host: { border: '#00aaff', icon: '#00aaff' }, + generic: { border: '#8888ff', icon: '#8888ff' }, + groupRect: { border: '#00ffff', icon: '#00ffff' }, + group: { border: '#00ffff', icon: '#00ffff' }, }, nodeCardBackground: '#0f0f2a', nodeIconBackground: '#0a0a1a', @@ -262,24 +262,24 @@ export const THEMES: Record = { description: 'Everything in terminal green', colors: { nodeAccents: { - isp: { border: '#00ff41', icon: '#00ff41' }, - router: { border: '#00ff41', icon: '#00ff41' }, - switch: { border: '#00cc33', icon: '#00cc33' }, - server: { border: '#008822', icon: '#008822' }, - proxmox: { border: '#33ff66', icon: '#33ff66' }, - vm: { border: '#008822', icon: '#008822' }, - lxc: { border: '#00ff41', icon: '#00ff41' }, - nas: { border: '#00cc33', icon: '#00cc33' }, - iot: { border: '#66ff33', icon: '#66ff33' }, - ap: { border: '#00ff41', icon: '#00ff41' }, - camera: { border: '#005500', icon: '#005500' }, - printer: { border: '#005500', icon: '#005500' }, - computer: { border: '#008822', icon: '#008822' }, - cpl: { border: '#66ff33', icon: '#66ff33' }, - docker_host: { border: '#00cc88', icon: '#00cc88' }, - generic: { border: '#006600', icon: '#006600' }, - groupRect:{ border: '#00ff41', icon: '#00ff41' }, - group: { border: '#00ff41', icon: '#00ff41' }, + isp: { border: '#00ff41', icon: '#00ff41' }, + router: { border: '#00ff41', icon: '#00ff41' }, + switch: { border: '#00cc33', icon: '#00cc33' }, + server: { border: '#008822', icon: '#008822' }, + proxmox: { border: '#33ff66', icon: '#33ff66' }, + vm: { border: '#008822', icon: '#008822' }, + lxc: { border: '#00ff41', icon: '#00ff41' }, + nas: { border: '#00cc33', icon: '#00cc33' }, + iot: { border: '#66ff33', icon: '#66ff33' }, + ap: { border: '#00ff41', icon: '#00ff41' }, + camera: { border: '#005500', icon: '#005500' }, + printer: { border: '#005500', icon: '#005500' }, + computer: { border: '#008822', icon: '#008822' }, + cpl: { border: '#66ff33', icon: '#66ff33' }, + docker_host: { border: '#00cc88', icon: '#00cc88' }, + generic: { border: '#006600', icon: '#006600' }, + groupRect: { border: '#00ff41', icon: '#00ff41' }, + group: { border: '#00ff41', icon: '#00ff41' }, }, nodeCardBackground: '#001100', nodeIconBackground: '#002200', From 38e6604f500423cff212f2da48fa41afb2f7aaa8 Mon Sep 17 00:00:00 2001 From: findthelorax Date: Mon, 20 Apr 2026 12:50:43 -0400 Subject: [PATCH 08/24] align canvasStore expectations with container-mode behavior --- frontend/src/stores/__tests__/canvasStore.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index 65af38e..860dd67 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -99,7 +99,7 @@ describe('canvasStore', () => { it('updateNode clearing parent_id converts position to absolute and clears parentId', () => { const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 100, y: 100 } } - const lxc = { ...makeNode('lxc1', { type: 'lxc', parent_id: 'px1' }), position: { x: 30, y: 40 }, parentId: 'px1', extent: 'parent' as const } + const lxc = { ...makeNode('lxc1', { type: 'lxc', parent_id: 'px1' }), position: { x: 130, y: 140 }, parentId: 'px1', extent: 'parent' as const } useCanvasStore.getState().addNode(proxmox) useCanvasStore.getState().addNode(lxc) useCanvasStore.getState().updateNode('lxc1', { parent_id: undefined }) @@ -229,7 +229,7 @@ describe('canvasStore', () => { }) it('deleteNode also removes children with matching parentId', () => { - useCanvasStore.getState().addNode(makeNode('parent')) + useCanvasStore.getState().addNode(makeNode('parent', { container_mode: true })) useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' })) useCanvasStore.getState().deleteNode('parent') const { nodes } = useCanvasStore.getState() @@ -237,8 +237,8 @@ describe('canvasStore', () => { expect(nodes.find((n) => n.id === 'child')).toBeUndefined() }) - it('addNode with parent_id sets parentId and extent', () => { - useCanvasStore.getState().addNode(makeNode('parent')) + it('addNode with parent_id sets parentId and extent when parent is in container mode', () => { + useCanvasStore.getState().addNode(makeNode('parent', { container_mode: true })) useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' })) const child = useCanvasStore.getState().nodes.find((n) => n.id === 'child') expect(child?.parentId).toBe('parent') From 78b47384daaa8ac448fcb3cb0e2d5269b5d0f209 Mon Sep 17 00:00:00 2001 From: findthelorax Date: Tue, 21 Apr 2026 11:51:29 -0400 Subject: [PATCH 09/24] fixed the container mode toggle styling for backward compatability and proper padding to remain centered in it's parent element --- frontend/src/components/canvas/nodes/index.tsx | 2 +- frontend/src/components/canvas/nodes/nodeTypes.ts | 4 ++-- frontend/src/components/modals/NodeModal.tsx | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/canvas/nodes/index.tsx b/frontend/src/components/canvas/nodes/index.tsx index aec536d..1120316 100644 --- a/frontend/src/components/canvas/nodes/index.tsx +++ b/frontend/src/components/canvas/nodes/index.tsx @@ -22,5 +22,5 @@ export const CameraNode = (props: N) => export const PrinterNode = (props: N) => export const ComputerNode = (props: N) => export const CplNode = (props: N) => -export const DockerNode = (props: N) => +export const DockerHostNode = (props: N) => export const GenericNode = (props: N) => diff --git a/frontend/src/components/canvas/nodes/nodeTypes.ts b/frontend/src/components/canvas/nodes/nodeTypes.ts index c30acb7..7f06bbd 100644 --- a/frontend/src/components/canvas/nodes/nodeTypes.ts +++ b/frontend/src/components/canvas/nodes/nodeTypes.ts @@ -1,4 +1,4 @@ -import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index' +import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerHostNode, GenericNode } from './index' import { ProxmoxGroupNode } from './ProxmoxGroupNode' import { GroupRectNode } from './GroupRectNode' import { GroupNode } from './GroupNode' @@ -18,7 +18,7 @@ export const nodeTypes = { printer: PrinterNode, computer: ComputerNode, cpl: CplNode, - docker_host: DockerNode, + docker_host: DockerHostNode, generic: GenericNode, groupRect: GroupRectNode, group: GroupNode, diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index f820691..b27b089 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -296,12 +296,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' role="switch" aria-checked={!!form.container_mode} onClick={() => set('container_mode', !form.container_mode)} - className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none" + className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none" style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }} > From fe5e3c98583c7b1a11e8b3b5bf9263cb6a6b35bf Mon Sep 17 00:00:00 2001 From: findthelorax Date: Tue, 21 Apr 2026 11:58:24 -0400 Subject: [PATCH 10/24] revert: restore workflow to upstream version --- .github/workflows/docker-publish.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index fe0a4e8..600ae67 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -15,13 +15,13 @@ jobs: strategy: matrix: include: - - image: ghcr.io/${{ github.repository_owner }}/homelable-backend + - image: ghcr.io/pouzor/homelable-backend dockerfile: Dockerfile.backend build_args: "" - - image: ghcr.io/${{ github.repository_owner }}/homelable-frontend + - image: ghcr.io/pouzor/homelable-frontend dockerfile: Dockerfile.frontend build_args: "" - - image: ghcr.io/${{ github.repository_owner }}/homelable-frontend-standalone + - image: ghcr.io/pouzor/homelable-frontend-standalone dockerfile: Dockerfile.frontend build_args: "VITE_STANDALONE=true" From ebc1d41d5ce4cb133a831f50ab6624451746c3fc Mon Sep 17 00:00:00 2001 From: Pouzor Date: Wed, 22 Apr 2026 22:32:24 +0200 Subject: [PATCH 11/24] feat: add docker_container node type and fix container mode for non-proxmox types - Add docker_container node type (Package icon, sky-blue accent) as child of docker_host - Parent selector for docker_container filters to docker_host only via nodeType field - Virtual edge drag-connect syncs parent_id for docker_container <-> docker_host - Fix setProxmoxContainerMode: remove proxmox-only guard so width/height are properly set/cleared for all container-capable types (docker_host, vm, lxc) - Fix handleAddNode: only give group size when container_mode=true, making create and reload behavior consistent (was giving size unconditionally for CONTAINER_MODE_TYPES) - Add regression tests for docker_host container mode toggle and docker_container nesting --- frontend/src/App.tsx | 12 +++-- .../src/components/canvas/nodes/index.tsx | 5 ++- .../src/components/canvas/nodes/nodeTypes.ts | 3 +- frontend/src/components/modals/NodeModal.tsx | 14 +++--- .../modals/__tests__/NodeModal.test.tsx | 22 ++++++++- .../src/stores/__tests__/canvasStore.test.ts | 31 +++++++++++++ frontend/src/stores/canvasStore.ts | 1 - frontend/src/types/index.ts | 2 + frontend/src/utils/__tests__/themes.test.ts | 2 +- frontend/src/utils/nodeIcons.ts | 9 ++-- frontend/src/utils/themes.ts | 45 ++++++++++--------- 11 files changed, 107 insertions(+), 39 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8ec3d6b..6b10007 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -152,7 +152,7 @@ export default function App() { const handleAddNode = useCallback((data: Partial) => { snapshotHistory() const id = generateUUID() - const isContainerNode = CONTAINER_MODE_TYPES.has(data.type as NodeData['type']) + const isContainerNode = data.container_mode === true const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null // Children position is relative to parent; place near top-left with padding const position = parentNode @@ -249,7 +249,7 @@ export default function App() { } // Sync virtual edge when parent_id changes on an LXC/VM node const nodeType = data.type ?? existingNode?.data.type - if ((nodeType === 'lxc' || nodeType === 'vm') && 'parent_id' in data) { + if ((nodeType === 'lxc' || nodeType === 'vm' || nodeType === 'docker_container') && 'parent_id' in data) { const oldParentId = existingNode?.data.parent_id ?? null const newParentId = data.parent_id ?? null if (oldParentId !== newParentId) { @@ -331,6 +331,10 @@ export default function App() { updateNode(pendingConnection.source, { parent_id: pendingConnection.target }) } else if (CONTAINER_MODE_TYPES.has(srcType) && (tgtType === 'lxc' || tgtType === 'vm')) { updateNode(pendingConnection.target, { parent_id: pendingConnection.source }) + } else if (srcType === 'docker_container' && tgtType === 'docker_host') { + updateNode(pendingConnection.source, { parent_id: pendingConnection.target }) + } else if (tgtType === 'docker_container' && srcType === 'docker_host') { + updateNode(pendingConnection.target, { parent_id: pendingConnection.source }) } } setPendingConnection(null) @@ -426,7 +430,7 @@ export default function App() { title="Add Node" parentContainerNodes={nodes .filter((n) => CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode) - .map((n) => ({ id: n.id, label: n.data.label }))} + .map((n) => ({ id: n.id, label: n.data.label, nodeType: n.data.type }))} /> {/* key forces re-mount when editing a different node, resetting form state */} @@ -439,7 +443,7 @@ export default function App() { title="Edit Node" parentContainerNodes={nodes .filter((n) => n.id !== editNodeId && CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode) - .map((n) => ({ id: n.id, label: n.data.label }))} + .map((n) => ({ id: n.id, label: n.data.label, nodeType: n.data.type }))} /> export const PrinterNode = (props: N) => export const ComputerNode = (props: N) => export const CplNode = (props: N) => -export const DockerHostNode = (props: N) => +export const DockerHostNode = (props: N) => +export const DockerContainerNode = (props: N) => export const GenericNode = (props: N) => diff --git a/frontend/src/components/canvas/nodes/nodeTypes.ts b/frontend/src/components/canvas/nodes/nodeTypes.ts index 7f06bbd..6b33ac4 100644 --- a/frontend/src/components/canvas/nodes/nodeTypes.ts +++ b/frontend/src/components/canvas/nodes/nodeTypes.ts @@ -1,4 +1,4 @@ -import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerHostNode, GenericNode } from './index' +import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode } from './index' import { ProxmoxGroupNode } from './ProxmoxGroupNode' import { GroupRectNode } from './GroupRectNode' import { GroupNode } from './GroupNode' @@ -19,6 +19,7 @@ export const nodeTypes = { computer: ComputerNode, cpl: CplNode, docker_host: DockerHostNode, + docker_container: DockerContainerNode, generic: GenericNode, groupRect: GroupRectNode, group: GroupNode, diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index b27b089..f8cf3f1 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -11,7 +11,7 @@ import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [ { label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] }, - { label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host'] }, + { label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] }, { label: 'IoT', types: ['iot', 'camera', 'cpl'] }, { label: 'Generic', types: ['computer', 'generic', 'groupRect'] }, ] @@ -49,7 +49,7 @@ interface NodeModalProps { onSubmit: (data: Partial) => void initial?: Partial title?: string - parentContainerNodes?: { id: string; label: string }[] + parentContainerNodes?: { id: string; label: string; nodeType?: NodeType }[] } // NodeModal is always mounted with a key that changes on open/edit, so useState @@ -79,6 +79,10 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' onClose() } + const filteredParentNodes = form.type === 'docker_container' + ? parentContainerNodes.filter((n) => n.nodeType === 'docker_host') + : parentContainerNodes + return ( !o && onClose()}> @@ -260,7 +264,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' {/* Parent container */} - {form.type !== 'groupRect' && form.type !== 'group' && parentContainerNodes.length > 0 && ( + {form.type !== 'groupRect' && form.type !== 'group' && filteredParentNodes.length > 0 && (