From 785be6a5dd185b60d5dd6b86237ad4c8880a81c6 Mon Sep 17 00:00:00 2001 From: Remy Date: Tue, 28 Apr 2026 14:52:25 +0200 Subject: [PATCH 1/6] Create FUNDING.yml --- .github/FUNDING.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..04580bc --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: pouzor +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] From dfa4a9c849da3cd76ceeac4b95043f3d86e534ba Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 3 May 2026 16:03:30 +0200 Subject: [PATCH 2/6] fix(sidebar): unfreeze view after scan triggers history panel forceView prop was overriding local activeView on every render. Once parent set sidebarForceView='history' after starting a scan, it never cleared, so clicks on other nav items were ignored. Treat forceView as a one-shot trigger via the previous-prop pattern; user clicks afterward control view. --- frontend/src/components/panels/Sidebar.tsx | 16 +++++++++++----- .../components/panels/__tests__/Sidebar.test.tsx | 12 ++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index 62b94ee..4325938 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -43,13 +43,19 @@ interface SidebarProps { } export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) { - const [_collapsed, setCollapsed] = useState(false) - const [_activeView, setActiveView] = useState('canvas') + const [collapsed, setCollapsed] = useState(false) + const [activeView, setActiveView] = useState(forceView ?? 'canvas') + const [prevForceView, setPrevForceView] = useState(forceView) const logout = useAuthStore((s) => s.logout) - // When forceView is set, override local state without useEffect - const collapsed = forceView ? false : _collapsed - const activeView = forceView ?? _activeView + // forceView acts as a one-shot trigger from parent; user clicks afterwards still control view. + if (forceView !== prevForceView) { + setPrevForceView(forceView) + if (forceView) { + setActiveView(forceView) + setCollapsed(false) + } + } const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore() diff --git a/frontend/src/components/panels/__tests__/Sidebar.test.tsx b/frontend/src/components/panels/__tests__/Sidebar.test.tsx index 4853225..5488f1a 100644 --- a/frontend/src/components/panels/__tests__/Sidebar.test.tsx +++ b/frontend/src/components/panels/__tests__/Sidebar.test.tsx @@ -267,6 +267,18 @@ describe('Sidebar', () => { await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument()) }) + // Regression: forceView used to override local state on every render, freezing + // the sidebar on whichever view the parent forced (e.g. 'history' after a scan). + it('allows switching views after forceView is set by parent', async () => { + const { rerender } = render() + await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument()) + // Parent keeps forceView as 'history'; user clicks another nav item. + rerender() + fireEvent.click(screen.getByText('Pending Devices')) + await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument()) + expect(screen.queryByText('No scans yet')).not.toBeInTheDocument() + }) + it('toggles Settings panel on Settings click', async () => { render() fireEvent.click(screen.getByText('Settings')) From e3876e934c89847e813239b886574f14fcb1e753 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 3 May 2026 16:48:15 +0200 Subject: [PATCH 3/6] feat(nodes): add firewall node type New 'firewall' NodeType with Flame icon. Wired through node registry, icon registry, all six themes (red-tinted accent per palette), NodeModal Hardware group, and CustomStyleModal editable types. --- frontend/src/components/canvas/nodes/index.tsx | 3 ++- frontend/src/components/canvas/nodes/nodeTypes.ts | 3 ++- frontend/src/components/modals/CustomStyleModal.tsx | 6 +++--- frontend/src/components/modals/NodeModal.tsx | 2 +- frontend/src/types/__tests__/types.test.ts | 2 +- frontend/src/types/index.ts | 2 ++ frontend/src/utils/__tests__/themes.test.ts | 2 +- frontend/src/utils/nodeIcons.ts | 4 +++- frontend/src/utils/themes.ts | 6 ++++++ 9 files changed, 21 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/canvas/nodes/index.tsx b/frontend/src/components/canvas/nodes/index.tsx index 2dcf080..cfb6604 100644 --- a/frontend/src/components/canvas/nodes/index.tsx +++ b/frontend/src/components/canvas/nodes/index.tsx @@ -1,7 +1,7 @@ import { type NodeProps, type Node } from '@xyflow/react' import { Globe, Router, Network, Server, Layers, Box, Container, - HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package, + HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package, Flame, } from 'lucide-react' import { BaseNode } from './BaseNode' import type { NodeData } from '@/types' @@ -10,6 +10,7 @@ type N = NodeProps> export const IspNode = (props: N) => export const RouterNode = (props: N) => +export const FirewallNode = (props: N) => export const SwitchNode = (props: N) => export const ServerNode = (props: N) => export const ProxmoxNode = (props: N) => diff --git a/frontend/src/components/canvas/nodes/nodeTypes.ts b/frontend/src/components/canvas/nodes/nodeTypes.ts index 6b33ac4..6adba16 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, DockerContainerNode, GenericNode } from './index' +import { IspNode, RouterNode, FirewallNode, 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' @@ -6,6 +6,7 @@ import { GroupNode } from './GroupNode' export const nodeTypes = { isp: IspNode, router: RouterNode, + firewall: FirewallNode, switch: SwitchNode, server: ServerNode, proxmox: ProxmoxGroupNode, diff --git a/frontend/src/components/modals/CustomStyleModal.tsx b/frontend/src/components/modals/CustomStyleModal.tsx index a0e0cb3..0c5b2f2 100644 --- a/frontend/src/components/modals/CustomStyleModal.tsx +++ b/frontend/src/components/modals/CustomStyleModal.tsx @@ -2,7 +2,7 @@ import { useState, useCallback } from 'react' import { toast } from 'sonner' import { Globe, Router, Network, Server, Layers, Box, Container, HardDrive, - Cpu, Wifi, Camera, Printer, Monitor, PlugZap, Anchor, Package, Circle, + Cpu, Wifi, Camera, Printer, Monitor, PlugZap, Anchor, Package, Circle, Flame, type LucideIcon, } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' @@ -19,7 +19,7 @@ import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types' // ── Node types exposed for custom style (skip groupRect/group) ─────────────── const EDITABLE_NODE_TYPES: NodeType[] = [ - 'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', + 'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host', 'docker_container', 'generic', ] @@ -27,7 +27,7 @@ const EDITABLE_NODE_TYPES: NodeType[] = [ const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster'] const NODE_ICONS: Record = { - isp: Globe, router: Router, switch: Network, server: Server, proxmox: Layers, + isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers, vm: Box, lxc: Container, nas: HardDrive, iot: Cpu, ap: Wifi, camera: Camera, printer: Printer, computer: Monitor, cpl: PlugZap, docker_host: Anchor, docker_container: Package, generic: Circle, diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index 622b51b..31626b5 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -11,7 +11,7 @@ import { resolveNodeColors } from '@/utils/nodeColors' import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons' const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [ - { label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] }, + { label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] }, { label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] }, { label: 'IoT', types: ['iot', 'camera', 'cpl'] }, { label: 'Generic', types: ['computer', 'generic', 'groupRect'] }, diff --git a/frontend/src/types/__tests__/types.test.ts b/frontend/src/types/__tests__/types.test.ts index 8191c72..57e6efd 100644 --- a/frontend/src/types/__tests__/types.test.ts +++ b/frontend/src/types/__tests__/types.test.ts @@ -4,7 +4,7 @@ import type { CheckMethod } from '@/types' describe('NODE_TYPE_LABELS', () => { it('has an entry for every node type', () => { - const expectedTypes = ['isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', 'iot', 'ap', 'camera', 'generic'] + const expectedTypes = ['isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', 'iot', 'ap', 'camera', 'generic'] expectedTypes.forEach((t) => { expect(NODE_TYPE_LABELS).toHaveProperty(t) expect(typeof NODE_TYPE_LABELS[t as keyof typeof NODE_TYPE_LABELS]).toBe('string') diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b37ca34..2511a24 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,6 +1,7 @@ export type NodeType = | 'isp' | 'router' + | 'firewall' | 'switch' | 'server' | 'proxmox' @@ -116,6 +117,7 @@ export interface EdgeData extends Record { export const NODE_TYPE_LABELS: Record = { isp: 'ISP / Modem', router: 'Router', + firewall: 'Firewall', switch: 'Switch', server: 'Server', proxmox: 'Proxmox VE', diff --git a/frontend/src/utils/__tests__/themes.test.ts b/frontend/src/utils/__tests__/themes.test.ts index 48e9854..1a4b103 100644 --- a/frontend/src/utils/__tests__/themes.test.ts +++ b/frontend/src/utils/__tests__/themes.test.ts @@ -3,7 +3,7 @@ import { THEMES, THEME_ORDER, type ThemeId } from '../themes' import type { NodeType, EdgeType, NodeStatus } from '@/types' const NODE_TYPES: NodeType[] = [ - 'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc', + 'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host', 'docker_container', 'generic', 'groupRect', ] const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster'] diff --git a/frontend/src/utils/nodeIcons.ts b/frontend/src/utils/nodeIcons.ts index 3523718..5c329c8 100644 --- a/frontend/src/utils/nodeIcons.ts +++ b/frontend/src/utils/nodeIcons.ts @@ -9,7 +9,7 @@ import { // Storage & Databases Database, Archive, Cloud, FolderOpen, // Security & Auth - Shield, ShieldCheck, Lock, Key, Users, UserCheck, + Shield, ShieldCheck, Lock, Key, Users, UserCheck, Flame, // Automation & IoT Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio, // Transfers & sync @@ -34,6 +34,7 @@ export const ICON_REGISTRY: IconEntry[] = [ // --- Infrastructure --- { key: 'globe', label: 'Globe / ISP', category: 'Infrastructure', icon: Globe }, { key: 'router', label: 'Router', category: 'Infrastructure', icon: Router }, + { key: 'flame', label: 'Firewall', category: 'Infrastructure', icon: Flame }, { 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 }, @@ -120,6 +121,7 @@ export const ICON_MAP: Record = Object.fromEntries( export const NODE_TYPE_DEFAULT_ICONS: Record = { isp: Globe, router: Router, + firewall: Flame, switch: Network, server: Server, proxmox: Layers, diff --git a/frontend/src/utils/themes.ts b/frontend/src/utils/themes.ts index 583aa29..202c0dd 100644 --- a/frontend/src/utils/themes.ts +++ b/frontend/src/utils/themes.ts @@ -44,6 +44,7 @@ export const THEMES: Record = { nodeAccents: { isp: { border: '#00d4ff', icon: '#00d4ff' }, router: { border: '#00d4ff', icon: '#00d4ff' }, + firewall: { border: '#f85149', icon: '#f85149' }, switch: { border: '#39d353', icon: '#39d353' }, server: { border: '#a855f7', icon: '#a855f7' }, proxmox: { border: '#ff6e00', icon: '#ff6e00' }, @@ -100,6 +101,7 @@ export const THEMES: Record = { nodeAccents: { isp: { border: '#22d3ee', icon: '#22d3ee' }, router: { border: '#22d3ee', icon: '#22d3ee' }, + firewall: { border: '#ef4444', icon: '#ef4444' }, switch: { border: '#4ade80', icon: '#4ade80' }, server: { border: '#c084fc', icon: '#c084fc' }, proxmox: { border: '#fb923c', icon: '#fb923c' }, @@ -156,6 +158,7 @@ export const THEMES: Record = { nodeAccents: { isp: { border: '#0284c7', icon: '#0284c7' }, router: { border: '#0284c7', icon: '#0284c7' }, + firewall: { border: '#dc2626', icon: '#dc2626' }, switch: { border: '#16a34a', icon: '#16a34a' }, server: { border: '#7c3aed', icon: '#7c3aed' }, proxmox: { border: '#ea580c', icon: '#ea580c' }, @@ -212,6 +215,7 @@ export const THEMES: Record = { nodeAccents: { isp: { border: '#00ffff', icon: '#00ffff' }, router: { border: '#00ffff', icon: '#00ffff' }, + firewall: { border: '#ff0040', icon: '#ff0040' }, switch: { border: '#00ff80', icon: '#00ff80' }, server: { border: '#ff00ff', icon: '#ff00ff' }, proxmox: { border: '#ff8800', icon: '#ff8800' }, @@ -268,6 +272,7 @@ export const THEMES: Record = { nodeAccents: { isp: { border: '#00ff41', icon: '#00ff41' }, router: { border: '#00ff41', icon: '#00ff41' }, + firewall: { border: '#88ff00', icon: '#88ff00' }, switch: { border: '#00cc33', icon: '#00cc33' }, server: { border: '#008822', icon: '#008822' }, proxmox: { border: '#33ff66', icon: '#33ff66' }, @@ -324,6 +329,7 @@ export const THEMES: Record = { nodeAccents: { isp: { border: '#00d4ff', icon: '#00d4ff' }, router: { border: '#00d4ff', icon: '#00d4ff' }, + firewall: { border: '#f85149', icon: '#f85149' }, switch: { border: '#39d353', icon: '#39d353' }, server: { border: '#a855f7', icon: '#a855f7' }, proxmox: { border: '#ff6e00', icon: '#ff6e00' }, From ad958feabd2053a141015717af3112809350ef42 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 3 May 2026 17:16:05 +0200 Subject: [PATCH 4/6] fix(node-modal): keep modal open when delete confirm is cancelled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare if without braces meant onClose() ran unconditionally after the window.confirm — clicking Cancel still closed the modal and dropped unsaved edits. Wrap the confirm body so onClose only fires on accept. --- frontend/src/components/modals/NodeModal.tsx | 7 +++++- .../modals/__tests__/NodeModal.test.tsx | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index 31626b5..69b83c0 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -404,7 +404,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer" - onClick={() => { if (window.confirm('Delete this node?')) onSubmit({ ...form, _delete: true }); onClose(); }} + onClick={() => { + if (window.confirm('Delete this node?')) { + onSubmit({ ...form, _delete: true }) + onClose() + } + }} style={{ minWidth: 64 }} > Delete diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx index 828a8e8..c561495 100644 --- a/frontend/src/components/modals/__tests__/NodeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/NodeModal.test.tsx @@ -83,6 +83,28 @@ describe('NodeModal', () => { expect(onClose).toHaveBeenCalledOnce() }) + // ── Delete confirm ──────────────────────────────────────────────────── + + it('deletes and closes when Delete confirm is accepted', () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + const { onClose, onSubmit } = renderModal({ title: 'Edit Node', initial: BASE }) + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ _delete: true })) + expect(onClose).toHaveBeenCalledOnce() + confirmSpy.mockRestore() + }) + + // Regression: bare-if without braces used to call onClose() unconditionally, + // closing the modal even when the user cancelled the confirm dialog. + it('does not delete or close when Delete confirm is cancelled', () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) + const { onClose, onSubmit } = renderModal({ title: 'Edit Node', initial: BASE }) + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + expect(onSubmit).not.toHaveBeenCalled() + expect(onClose).not.toHaveBeenCalled() + confirmSpy.mockRestore() + }) + // ── Label validation ────────────────────────────────────────────────── it('blocks submit and shows error when label is empty', () => { From 43761c60cb630a1e2a97162dd3b7d07c38547d90 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 3 May 2026 21:09:14 +0200 Subject: [PATCH 5/6] feat(handles): allow 1-48 bottom connection points via slider Replaces the 4-option dropdown with a 1..48 slider. Existing handle IDs ('bottom', 'bottom-2'..'bottom-4') are preserved and the hand-tuned pixel positions for counts 1..4 are locked in (regression-tested) so saved canvases render identically. Counts >=5 use uniform spacing; node card auto-grows in width so handles stay clickable. Existing edge-remap on shrink scales unchanged thanks to its index-based loop. ha-relevant: yes --- .../canvas/__tests__/BaseNode.test.tsx | 5 +- .../src/components/canvas/nodes/BaseNode.tsx | 11 +- frontend/src/components/modals/NodeModal.tsx | 34 +++-- .../modals/__tests__/NodeModal.test.tsx | 27 +++- .../src/stores/__tests__/canvasStore.test.ts | 18 +++ frontend/src/types/index.ts | 1 + .../src/utils/__tests__/handleUtils.test.ts | 137 +++++++++++++----- frontend/src/utils/canvasSerializer.ts | 6 +- frontend/src/utils/handleUtils.ts | 44 ++++-- 9 files changed, 206 insertions(+), 77 deletions(-) diff --git a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx index 3ffb576..ae5591f 100644 --- a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx +++ b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx @@ -56,8 +56,9 @@ vi.mock('@/utils/propertyIcons', () => ({ })) vi.mock('@/utils/handleUtils', () => ({ - BOTTOM_HANDLE_IDS: ['bottom'], - BOTTOM_HANDLE_POSITIONS: { 1: [50] }, + bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`, + bottomHandlePositions: () => [50], + clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1, })) beforeEach(() => { mockZoom = 1 }) diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index d63d16d..c239452 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -9,7 +9,7 @@ import { useThemeStore } from '@/stores/themeStore' import { THEMES } from '@/utils/themes' import { useCanvasStore } from '@/stores/canvasStore' import { maskIp, splitIps } from '@/utils/maskIp' -import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils' +import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils' interface BaseNodeProps extends NodeProps> { icon: LucideIcon @@ -56,7 +56,8 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: ? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44` : 'none', opacity: data.status === 'offline' ? 0.55 : 1, - minWidth: 140, + // Grow node width when many bottom handles so each stays clickable (~14px slot). + minWidth: Math.max(140, clampBottomHandles(data.bottom_handles ?? 1) * 14), width: width ? '100%' : undefined, height: height ? '100%' : undefined, }} @@ -173,9 +174,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: )} - {(BOTTOM_HANDLE_POSITIONS[data.bottom_handles ?? 1] ?? BOTTOM_HANDLE_POSITIONS[1]).map((leftPct, idx) => { - const sourceId = BOTTOM_HANDLE_IDS[idx] - const targetId = idx === 0 ? 'bottom-t' : `bottom-${idx + 1}-t` + {bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => { + const sourceId = bottomHandleId(idx) + const targetId = `${sourceId}-t` return ( - - +
+ + {clampBottomHandles(form.bottom_handles ?? 1)} +
+ set('bottom_handles', clampBottomHandles(Number(e.target.value)))} + aria-label="Bottom connection points slider" + className="w-full accent-[#00d4ff] cursor-pointer" + /> +
+ {MIN_BOTTOM_HANDLES} + {MAX_BOTTOM_HANDLES} +
)} diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx index c561495..147f14d 100644 --- a/frontend/src/components/modals/__tests__/NodeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/NodeModal.test.tsx @@ -367,18 +367,37 @@ describe('NodeModal', () => { it('defaults bottom_handles to 1', () => { renderModal({ initial: BASE }) - expect(selects()[2].value).toBe('1') + const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement + expect(slider.value).toBe('1') }) it('pre-fills bottom_handles from initial', () => { renderModal({ initial: { ...BASE, bottom_handles: 3 } }) - expect(selects()[2].value).toBe('3') + const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement + expect(slider.value).toBe('3') }) it('submits updated bottom_handles', () => { const { onSubmit } = renderModal({ initial: BASE }) - fireEvent.change(selects()[2], { target: { value: '4' } }) + const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement + fireEvent.change(slider, { target: { value: '12' } }) fireEvent.click(screen.getByRole('button', { name: 'Add' })) - expect((onSubmit.mock.calls[0][0] as Partial).bottom_handles).toBe(4) + expect((onSubmit.mock.calls[0][0] as Partial).bottom_handles).toBe(12) + }) + + it('supports the full 1..48 range', () => { + const { onSubmit } = renderModal({ initial: BASE }) + const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement + expect(slider.min).toBe('1') + expect(slider.max).toBe('48') + fireEvent.change(slider, { target: { value: '48' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add' })) + expect((onSubmit.mock.calls[0][0] as Partial).bottom_handles).toBe(48) + }) + + it('clamps pre-filled out-of-range values into [1,48]', () => { + renderModal({ initial: { ...BASE, bottom_handles: 9999 } }) + const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement + expect(slider.value).toBe('48') }) }) diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index 893ccc9..2db5673 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -721,6 +721,24 @@ describe('canvasStore', () => { const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1') expect(updated?.sourceHandle).toBe('bottom') }) + + // Regression: handle cap raised from 4 to 48 — remap must scale. + it('remaps high-count handles when shrinking from 12 to 2', () => { + const node = makeNode('n1', { bottom_handles: 12 }) + const edges = [ + { ...makeEdge('e2', 'n1', 'n2'), sourceHandle: 'bottom-2' }, + { ...makeEdge('e3', 'n1', 'n2'), sourceHandle: 'bottom-5' }, + { ...makeEdge('e12', 'n1', 'n2'), sourceHandle: 'bottom-12' }, + ] + useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges }) + + useCanvasStore.getState().updateNode('n1', { bottom_handles: 2 }) + + const after = useCanvasStore.getState().edges + expect(after.find((e) => e.id === 'e2')?.sourceHandle).toBe('bottom-2') + expect(after.find((e) => e.id === 'e3')?.sourceHandle).toBe('bottom') + expect(after.find((e) => e.id === 'e12')?.sourceHandle).toBe('bottom') + }) }) describe('canvasStore — custom style apply', () => { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 2511a24..7b326fd 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -93,6 +93,7 @@ export interface NodeData extends Record { height?: number } custom_icon?: string + /** Number of bottom connection points, 1..48. Default 1 (centered). */ bottom_handles?: number } diff --git a/frontend/src/utils/__tests__/handleUtils.test.ts b/frontend/src/utils/__tests__/handleUtils.test.ts index f5a9886..64d25ea 100644 --- a/frontend/src/utils/__tests__/handleUtils.test.ts +++ b/frontend/src/utils/__tests__/handleUtils.test.ts @@ -1,51 +1,105 @@ import { describe, it, expect } from 'vitest' import { - BOTTOM_HANDLE_IDS, - BOTTOM_HANDLE_POSITIONS, + MIN_BOTTOM_HANDLES, + MAX_BOTTOM_HANDLES, + bottomHandleId, + bottomHandlePositions, + clampBottomHandles, normalizeHandle, removedBottomHandleIds, } from '../handleUtils' -describe('BOTTOM_HANDLE_IDS', () => { +describe('bottomHandleId', () => { it('first id is always "bottom" for backward compatibility', () => { - expect(BOTTOM_HANDLE_IDS[0]).toBe('bottom') + expect(bottomHandleId(0)).toBe('bottom') }) - it('has ids for 1–4 handles', () => { - expect(BOTTOM_HANDLE_IDS).toHaveLength(4) - expect(BOTTOM_HANDLE_IDS).toEqual(['bottom', 'bottom-2', 'bottom-3', 'bottom-4']) + it('subsequent ids follow bottom-N pattern (1-indexed shift)', () => { + expect(bottomHandleId(1)).toBe('bottom-2') + expect(bottomHandleId(2)).toBe('bottom-3') + expect(bottomHandleId(3)).toBe('bottom-4') + expect(bottomHandleId(11)).toBe('bottom-12') + expect(bottomHandleId(47)).toBe('bottom-48') }) }) -describe('BOTTOM_HANDLE_POSITIONS', () => { +describe('clampBottomHandles', () => { + it('clamps below MIN to MIN', () => { + expect(clampBottomHandles(0)).toBe(MIN_BOTTOM_HANDLES) + expect(clampBottomHandles(-5)).toBe(MIN_BOTTOM_HANDLES) + }) + + it('clamps above MAX to MAX', () => { + expect(clampBottomHandles(49)).toBe(MAX_BOTTOM_HANDLES) + expect(clampBottomHandles(9999)).toBe(MAX_BOTTOM_HANDLES) + }) + + it('returns MIN for non-finite or non-number', () => { + expect(clampBottomHandles(NaN)).toBe(MIN_BOTTOM_HANDLES) + expect(clampBottomHandles(Infinity)).toBe(MIN_BOTTOM_HANDLES) + expect(clampBottomHandles('4' as unknown)).toBe(MIN_BOTTOM_HANDLES) + expect(clampBottomHandles(undefined)).toBe(MIN_BOTTOM_HANDLES) + }) + + it('floors fractional values', () => { + expect(clampBottomHandles(3.9)).toBe(3) + }) + + it('passes valid integers through', () => { + expect(clampBottomHandles(1)).toBe(1) + expect(clampBottomHandles(24)).toBe(24) + expect(clampBottomHandles(48)).toBe(48) + }) +}) + +describe('bottomHandlePositions — backward-compat lock for 1..4', () => { + // These exact arrays are the pre-multi-handle hand-tuned positions. + // Existing user canvases depend on them — do NOT change. it('1 handle is centered at 50%', () => { - expect(BOTTOM_HANDLE_POSITIONS[1]).toEqual([50]) + expect(bottomHandlePositions(1)).toEqual([50]) }) - it('2 handles are symmetric', () => { - const [a, b] = BOTTOM_HANDLE_POSITIONS[2] - expect(a).toBeLessThan(50) - expect(b).toBeGreaterThan(50) - expect(a + b).toBe(100) + it('2 handles use exact prior positions', () => { + expect(bottomHandlePositions(2)).toEqual([25, 75]) }) - it('3 handles include a center at 50%', () => { - expect(BOTTOM_HANDLE_POSITIONS[3]).toContain(50) - expect(BOTTOM_HANDLE_POSITIONS[3]).toHaveLength(3) + it('3 handles use exact prior positions', () => { + expect(bottomHandlePositions(3)).toEqual([20, 50, 80]) }) - it('4 handles are evenly spaced', () => { - const pos = BOTTOM_HANDLE_POSITIONS[4] - expect(pos).toHaveLength(4) - // All values should be between 0 and 100 exclusive + it('4 handles use exact prior positions', () => { + expect(bottomHandlePositions(4)).toEqual([15, 38, 62, 85]) + }) +}) + +describe('bottomHandlePositions — uniform spacing for ≥5', () => { + it('returns count entries', () => { + expect(bottomHandlePositions(5)).toHaveLength(5) + expect(bottomHandlePositions(12)).toHaveLength(12) + expect(bottomHandlePositions(48)).toHaveLength(48) + }) + + it('all positions strictly between 0 and 100', () => { + const pos = bottomHandlePositions(48) pos.forEach((p) => { expect(p).toBeGreaterThan(0) expect(p).toBeLessThan(100) }) - // Positions should be strictly increasing + }) + + it('positions are strictly increasing and uniform', () => { + const pos = bottomHandlePositions(12) for (let i = 1; i < pos.length; i++) { expect(pos[i]).toBeGreaterThan(pos[i - 1]) } + const step = 100 / 13 + expect(pos[0]).toBeCloseTo(step, 5) + expect(pos[11]).toBeCloseTo(step * 12, 5) + }) + + it('clamps out-of-range counts before computing', () => { + expect(bottomHandlePositions(0)).toEqual([50]) + expect(bottomHandlePositions(99)).toHaveLength(MAX_BOTTOM_HANDLES) }) }) @@ -63,16 +117,10 @@ describe('normalizeHandle', () => { expect(normalizeHandle('bottom-t')).toBe('bottom') }) - it('maps bottom-2-t → bottom-2', () => { + it('maps bottom-N-t → bottom-N for any N', () => { expect(normalizeHandle('bottom-2-t')).toBe('bottom-2') - }) - - it('maps bottom-3-t → bottom-3', () => { - expect(normalizeHandle('bottom-3-t')).toBe('bottom-3') - }) - - it('maps bottom-4-t → bottom-4', () => { - expect(normalizeHandle('bottom-4-t')).toBe('bottom-4') + expect(normalizeHandle('bottom-12-t')).toBe('bottom-12') + expect(normalizeHandle('bottom-48-t')).toBe('bottom-48') }) it('passes through non-stub handles unchanged', () => { @@ -90,22 +138,35 @@ describe('removedBottomHandleIds', () => { }) it('4 → 1 removes bottom-2, bottom-3, bottom-4', () => { - const removed = removedBottomHandleIds(4, 1) - expect(removed).toEqual(new Set(['bottom-2', 'bottom-3', 'bottom-4'])) + expect(removedBottomHandleIds(4, 1)).toEqual(new Set(['bottom-2', 'bottom-3', 'bottom-4'])) }) it('4 → 2 removes bottom-3, bottom-4', () => { - const removed = removedBottomHandleIds(4, 2) - expect(removed).toEqual(new Set(['bottom-3', 'bottom-4'])) + expect(removedBottomHandleIds(4, 2)).toEqual(new Set(['bottom-3', 'bottom-4'])) }) it('3 → 2 removes only bottom-3', () => { - const removed = removedBottomHandleIds(3, 2) - expect(removed).toEqual(new Set(['bottom-3'])) + expect(removedBottomHandleIds(3, 2)).toEqual(new Set(['bottom-3'])) }) it('never removes "bottom" (index 0)', () => { - const removed = removedBottomHandleIds(4, 1) + expect(removedBottomHandleIds(4, 1).has('bottom')).toBe(false) + }) + + // Regression: scaling the cap from 4 → 48 must not break the remap loop. + it('scales to high counts (48 → 1 removes 47 ids)', () => { + const removed = removedBottomHandleIds(48, 1) + expect(removed.size).toBe(47) + expect(removed.has('bottom-2')).toBe(true) + expect(removed.has('bottom-48')).toBe(true) expect(removed.has('bottom')).toBe(false) }) + + it('10 → 2 leaves bottom + bottom-2, removes bottom-3..bottom-10', () => { + const removed = removedBottomHandleIds(10, 2) + expect(removed.size).toBe(8) + expect(removed.has('bottom-2')).toBe(false) + expect(removed.has('bottom-3')).toBe(true) + expect(removed.has('bottom-10')).toBe(true) + }) }) diff --git a/frontend/src/utils/canvasSerializer.ts b/frontend/src/utils/canvasSerializer.ts index 562c400..ccd85ae 100644 --- a/frontend/src/utils/canvasSerializer.ts +++ b/frontend/src/utils/canvasSerializer.ts @@ -1,6 +1,6 @@ import type { Node, Edge } from '@xyflow/react' import type { NodeData, EdgeData, Waypoint } from '@/types' -import { normalizeHandle } from '@/utils/handleUtils' +import { normalizeHandle, clampBottomHandles } from '@/utils/handleUtils' // ── Types ──────────────────────────────────────────────────────────────────── @@ -104,7 +104,7 @@ export function serializeNode(n: Node): Record { properties: n.data.properties ?? [], width: n.measured?.width ?? n.width ?? null, height: n.measured?.height ?? n.height ?? null, - bottom_handles: n.data.bottom_handles ?? 1, + bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1), pos_x: n.position.x, pos_y: n.position.y, } @@ -155,7 +155,7 @@ export function deserializeApiNode( id: n.id, type: normalizedType, position: { x: n.pos_x, y: n.pos_y }, - data: { ...n, type: normalizedType } as unknown as NodeData, + data: { ...n, type: normalizedType, bottom_handles: clampBottomHandles(n.bottom_handles ?? 1) } as unknown as NodeData, ...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}), ...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false ? { width: n.width ?? 300, height: n.height ?? 200 } diff --git a/frontend/src/utils/handleUtils.ts b/frontend/src/utils/handleUtils.ts index e82df5c..0921d0f 100644 --- a/frontend/src/utils/handleUtils.ts +++ b/frontend/src/utils/handleUtils.ts @@ -2,20 +2,44 @@ * Bottom handle configuration for multi-handle nodes. * * Handle IDs: index 0 = 'bottom' (always the default, backward-compatible) - * index 1 = 'bottom-2', index 2 = 'bottom-3', index 3 = 'bottom-4' + * index N≥1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 47 = 'bottom-48') * * Invisible target handles follow the same pattern with a '-t' suffix: - * 'bottom-t', 'bottom-2-t', 'bottom-3-t', 'bottom-4-t' + * 'bottom-t', 'bottom-2-t', ..., 'bottom-48-t' */ -export const BOTTOM_HANDLE_IDS = ['bottom', 'bottom-2', 'bottom-3', 'bottom-4'] as const +export const MIN_BOTTOM_HANDLES = 1 +export const MAX_BOTTOM_HANDLES = 48 -/** Left % position for each handle slot, per count. */ -export const BOTTOM_HANDLE_POSITIONS: Record = { - 1: [50], - 2: [25, 75], - 3: [20, 50, 80], - 4: [15, 38, 62, 85], +/** Returns the source handle ID at a given slot index. */ +export function bottomHandleId(idx: number): string { + return idx === 0 ? 'bottom' : `bottom-${idx + 1}` +} + +/** Clamp a raw count into the supported range. Non-finite or non-int → MIN. */ +export function clampBottomHandles(n: unknown): number { + if (typeof n !== 'number' || !Number.isFinite(n)) return MIN_BOTTOM_HANDLES + const i = Math.floor(n) + if (i < MIN_BOTTOM_HANDLES) return MIN_BOTTOM_HANDLES + if (i > MAX_BOTTOM_HANDLES) return MAX_BOTTOM_HANDLES + return i +} + +/** + * Left % positions for each handle slot. + * Counts 1..4 keep their original hand-tuned values to preserve exact pixel + * positions on canvases saved before the multi-handle expansion. + * Counts ≥ 5 use uniform spacing. + */ +export function bottomHandlePositions(count: number): number[] { + const c = clampBottomHandles(count) + switch (c) { + case 1: return [50] + case 2: return [25, 75] + case 3: return [20, 50, 80] + case 4: return [15, 38, 62, 85] + default: return Array.from({ length: c }, (_, i) => ((i + 1) * 100) / (c + 1)) + } } /** @@ -39,7 +63,7 @@ export function normalizeHandle(h: string | null | undefined): string | null { export function removedBottomHandleIds(oldCount: number, newCount: number): Set { const removed = new Set() for (let i = newCount; i < oldCount; i++) { - removed.add(BOTTOM_HANDLE_IDS[i]) + removed.add(bottomHandleId(i)) } return removed } From 56cfbd1e7683f77c6e440fce914aba076c653bb5 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 3 May 2026 22:44:38 +0200 Subject: [PATCH 6/6] bump version 1.13.0 --- VERSION | 2 +- frontend/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 32bd932..f88cf52 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.12.0 \ No newline at end of file +1.13.0 \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index f179461..fd6d973 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.12.0", + "version": "1.13.0", "type": "module", "scripts": { "dev": "vite",