From adb447468720a9e40c6a9f3a61dcf62ce2552774 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 20 Mar 2026 21:09:58 +0100 Subject: [PATCH] feat: add Docker node type, group rect border style, and categorized node type selector - Fix DockerNode default icon: use Anchor instead of Package - Add border_style option to Group Rectangle (solid/dashed/dotted/double/none) - Group node type selector by category (Hardware, Virtualization, IoT, Generic) - Add tests for border style feature --- frontend/src/App.tsx | 3 ++ .../components/canvas/nodes/GroupRectNode.tsx | 3 +- .../src/components/canvas/nodes/index.tsx | 3 +- .../src/components/canvas/nodes/nodeTypes.ts | 3 +- .../src/components/modals/GroupRectModal.tsx | 39 ++++++++++++++ frontend/src/components/modals/NodeModal.tsx | 27 +++++++--- .../modals/__tests__/GroupRectModal.test.tsx | 52 +++++++++++++++++++ frontend/src/types/index.ts | 3 ++ .../src/utils/__tests__/nodeIcons.test.ts | 1 + frontend/src/utils/__tests__/themes.test.ts | 3 +- frontend/src/utils/nodeIcons.ts | 3 +- frontend/src/utils/themes.ts | 5 ++ 12 files changed, 134 insertions(+), 11 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index de9dfd4..f09a725 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -273,6 +273,7 @@ export default function App() { services: [], custom_colors: { border: data.border_color, + border_style: data.border_style, background: data.background_color, text_color: data.text_color, text_position: data.text_position, @@ -295,6 +296,7 @@ export default function App() { custom_colors: { ...existing?.data.custom_colors, border: data.border_color, + border_style: data.border_style, background: data.background_color, text_color: data.text_color, text_position: data.text_position, @@ -525,6 +527,7 @@ export default function App() { text_color: rc.text_color ?? '#e6edf3', text_position: rc.text_position ?? 'top-left', border_color: rc.border ?? '#00d4ff', + border_style: rc.border_style ?? 'solid', background_color: rc.background ?? '#00d4ff0d', z_order: rc.z_order ?? 1, } diff --git a/frontend/src/components/canvas/nodes/GroupRectNode.tsx b/frontend/src/components/canvas/nodes/GroupRectNode.tsx index 4055ef8..bd6bfe9 100644 --- a/frontend/src/components/canvas/nodes/GroupRectNode.tsx +++ b/frontend/src/components/canvas/nodes/GroupRectNode.tsx @@ -31,6 +31,7 @@ export function GroupRectNode({ id, data, selected }: NodeProps>) const rc = data.custom_colors ?? {} const borderColor = rc.border ?? '#00d4ff' + const borderStyle = rc.border_style ?? 'solid' const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)' const textColor = rc.text_color ?? '#e6edf3' const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter @@ -61,7 +62,7 @@ export function GroupRectNode({ id, data, selected }: NodeProps>) justifyContent: posStyle.justifyContent, padding: 12, background: backgroundColor, - border: `${selected ? 2 : 1}px solid ${selected ? '#00d4ff' : borderColor}`, + border: `${selected ? 2 : 1}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`, borderRadius: 10, fontFamily, color: textColor, diff --git a/frontend/src/components/canvas/nodes/index.tsx b/frontend/src/components/canvas/nodes/index.tsx index 4b83423..aec536d 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, + HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, } from 'lucide-react' import { BaseNode } from './BaseNode' import type { NodeData } from '@/types' @@ -22,4 +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 GenericNode = (props: N) => diff --git a/frontend/src/components/canvas/nodes/nodeTypes.ts b/frontend/src/components/canvas/nodes/nodeTypes.ts index 28d8953..ffc94c2 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, GenericNode } from './index' +import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index' import { ProxmoxGroupNode } from './ProxmoxGroupNode' import { GroupRectNode } from './GroupRectNode' @@ -17,6 +17,7 @@ export const nodeTypes = { printer: PrinterNode, computer: ComputerNode, cpl: CplNode, + docker: DockerNode, generic: GenericNode, groupRect: GroupRectNode, } diff --git a/frontend/src/components/modals/GroupRectModal.tsx b/frontend/src/components/modals/GroupRectModal.tsx index 58c5338..ad1b490 100644 --- a/frontend/src/components/modals/GroupRectModal.tsx +++ b/frontend/src/components/modals/GroupRectModal.tsx @@ -6,22 +6,34 @@ import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import type { TextPosition } from '@/types' +export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none' + export interface GroupRectFormData { label: string font: string text_color: string text_position: TextPosition border_color: string + border_style: BorderStyle background_color: string z_order: number } +const BORDER_STYLES: { value: BorderStyle; label: string; preview: string }[] = [ + { value: 'solid', label: 'Solid', preview: '───' }, + { value: 'dashed', label: 'Dashed', preview: '╌╌╌' }, + { value: 'dotted', label: 'Dotted', preview: '···' }, + { value: 'double', label: 'Double', preview: '═══' }, + { value: 'none', label: 'None', preview: ' ' }, +] + const DEFAULT_FORM: GroupRectFormData = { label: '', font: 'inter', text_color: '#e6edf3', text_position: 'top-left', border_color: '#00d4ff', + border_style: 'solid', background_color: '#00d4ff0d', z_order: 1, } @@ -157,6 +169,33 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit + {/* Border style */} +
+ +
+ {BORDER_STYLES.map(({ value, label, preview }) => { + const isSelected = form.border_style === value + return ( + + ) + })} +
+
+ {/* Z-order */}
diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index 746fd01..b5194bf 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -4,12 +4,17 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select' import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types' import { resolveNodeColors } from '@/utils/nodeColors' import { ICON_REGISTRY, ICON_CATEGORIES } from '@/utils/nodeIcons' -const NODE_TYPES = Object.entries(NODE_TYPE_LABELS) as [NodeType, string][] +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'] }, + { label: 'IoT', types: ['iot', 'camera', 'cpl'] }, + { label: 'Generic', types: ['computer', 'generic', 'groupRect'] }, +] const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health'] @@ -76,10 +81,20 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' - {NODE_TYPES.map(([value, label]) => ( - - {label} - + {NODE_TYPE_GROUPS.map((group, i) => ( + <> + {i > 0 && } + + + {group.label} + + {group.types.map((type) => ( + + {NODE_TYPE_LABELS[type]} + + ))} + + ))} diff --git a/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx b/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx index c3a9b73..896b752 100644 --- a/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx +++ b/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx @@ -80,4 +80,56 @@ describe('GroupRectModal', () => { const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData expect(submitted.text_position).toBe('bottom-right') }) + + it('renders Border Style section', () => { + render() + expect(screen.getByText('Border Style')).toBeDefined() + expect(screen.getByTitle('Solid')).toBeDefined() + expect(screen.getByTitle('Dashed')).toBeDefined() + expect(screen.getByTitle('Dotted')).toBeDefined() + expect(screen.getByTitle('Double')).toBeDefined() + expect(screen.getByTitle('None')).toBeDefined() + }) + + it('defaults border_style to solid', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.border_style).toBe('solid') + }) + + it('selects border style on click', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByTitle('Dashed')) + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.border_style).toBe('dashed') + }) + + it('pre-fills border_style from initial prop', () => { + const onSubmit = vi.fn() + render( + + ) + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.border_style).toBe('dotted') + }) + + it('toggles border style — clicking selected style deselects back to solid', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByTitle('Dotted')) + fireEvent.click(screen.getByTitle('Solid')) + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.border_style).toBe('solid') + }) }) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5a44a7e..d2f2c9f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -13,6 +13,7 @@ export type NodeType = | 'printer' | 'computer' | 'cpl' + | 'docker' | 'generic' | 'groupRect' @@ -65,6 +66,7 @@ export interface NodeData extends Record { text_color?: string text_position?: TextPosition font?: string + border_style?: 'solid' | 'dashed' | 'dotted' | 'double' | 'none' z_order?: number width?: number height?: number @@ -99,6 +101,7 @@ export const NODE_TYPE_LABELS: Record = { printer: 'Printer', computer: 'Computer', cpl: 'CPL / Powerline', + docker: 'Docker Host', generic: 'Generic Device', groupRect: 'Group Rectangle', } diff --git a/frontend/src/utils/__tests__/nodeIcons.test.ts b/frontend/src/utils/__tests__/nodeIcons.test.ts index d800dd0..dab21d1 100644 --- a/frontend/src/utils/__tests__/nodeIcons.test.ts +++ b/frontend/src/utils/__tests__/nodeIcons.test.ts @@ -28,6 +28,7 @@ describe('ICON_REGISTRY', () => { expect(keys).toContain('play') // Jellyfin expect(keys).toContain('shield') // Pi-hole expect(keys).toContain('anchor') // Portainer + expect(keys).toContain('package') // Docker Host expect(keys).toContain('key') // Vaultwarden expect(keys).toContain('database') // DB services expect(keys).toContain('cctv') // IP Camera / CCTV diff --git a/frontend/src/utils/__tests__/themes.test.ts b/frontend/src/utils/__tests__/themes.test.ts index e59fcfa..aa06ea5 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', 'generic', 'groupRect', + 'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker', 'generic', 'groupRect', ] const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster'] const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown'] @@ -84,6 +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.nodeCardBackground).toBe('#21262d') expect(d.nodeIconBackground).toBe('#161b22') expect(d.canvasBackground).toBe('#0d1117') diff --git a/frontend/src/utils/nodeIcons.ts b/frontend/src/utils/nodeIcons.ts index a22c227..ee37409 100644 --- a/frontend/src/utils/nodeIcons.ts +++ b/frontend/src/utils/nodeIcons.ts @@ -14,7 +14,7 @@ import { // Transfers & sync Download, Upload, RefreshCw, // Containers & Dev - Anchor, GitBranch, Terminal, Code2, Settings, + Anchor, Package, GitBranch, Terminal, Code2, Settings, // Communications Mail, MessageSquare, Phone, // Misc devices @@ -98,6 +98,7 @@ export const ICON_REGISTRY: IconEntry[] = [ // --- Containers & Dev --- { key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor }, + { key: 'package', label: 'Docker Host', category: 'Dev & Containers', icon: Package }, { key: 'gitbranch', label: 'Gitea / Gitlab', category: 'Dev & Containers', icon: GitBranch }, { key: 'terminal', label: 'SSH / Shell', category: 'Dev & Containers', icon: Terminal }, { key: 'code', label: 'VS Code Server', category: 'Dev & Containers', icon: Code2 }, diff --git a/frontend/src/utils/themes.ts b/frontend/src/utils/themes.ts index 6989957..ea83abc 100644 --- a/frontend/src/utils/themes.ts +++ b/frontend/src/utils/themes.ts @@ -56,6 +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' }, generic: { border: '#8b949e', icon: '#8b949e' }, groupRect:{ border: '#00d4ff', icon: '#00d4ff' }, }, @@ -109,6 +110,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' }, generic: { border: '#94a3b8', icon: '#94a3b8' }, groupRect:{ border: '#22d3ee', icon: '#22d3ee' }, }, @@ -162,6 +164,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' }, generic: { border: '#6b7280', icon: '#6b7280' }, groupRect:{ border: '#0284c7', icon: '#0284c7' }, }, @@ -215,6 +218,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' }, generic: { border: '#8888ff', icon: '#8888ff' }, groupRect:{ border: '#00ffff', icon: '#00ffff' }, }, @@ -268,6 +272,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' }, generic: { border: '#006600', icon: '#006600' }, groupRect:{ border: '#00ff41', icon: '#00ff41' }, },