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
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
|
||||
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<Node<NodeData>>)
|
||||
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,
|
||||
|
||||
@@ -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) => <BaseNode {...props} icon={Cctv} />
|
||||
export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
|
||||
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
|
||||
export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
|
||||
export const DockerNode = (props: N) => <BaseNode {...props} icon={Anchor} />
|
||||
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Border style */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Border Style</Label>
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{BORDER_STYLES.map(({ value, label, preview }) => {
|
||||
const isSelected = form.border_style === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
title={label}
|
||||
onClick={() => set('border_style', value)}
|
||||
className="flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors"
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-[11px] leading-none">{preview}</span>
|
||||
<span className="text-[9px]">{label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Z-order */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
|
||||
|
||||
@@ -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'
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
{NODE_TYPES.map(([value, label]) => (
|
||||
<SelectItem key={value} value={value} className="text-sm">
|
||||
{label}
|
||||
</SelectItem>
|
||||
{NODE_TYPE_GROUPS.map((group, i) => (
|
||||
<>
|
||||
{i > 0 && <SelectSeparator key={`sep-${group.label}`} className="bg-[#30363d]" />}
|
||||
<SelectGroup key={group.label}>
|
||||
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1">
|
||||
{group.label}
|
||||
</SelectLabel>
|
||||
{group.types.map((type) => (
|
||||
<SelectItem key={type} value={type} className="text-sm pl-4">
|
||||
{NODE_TYPE_LABELS[type]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -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(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
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(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
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(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
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(
|
||||
<GroupRectModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
initial={{ border_style: 'dotted' }}
|
||||
/>
|
||||
)
|
||||
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(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ export type NodeType =
|
||||
| 'printer'
|
||||
| 'computer'
|
||||
| 'cpl'
|
||||
| 'docker'
|
||||
| 'generic'
|
||||
| 'groupRect'
|
||||
|
||||
@@ -65,6 +66,7 @@ export interface NodeData extends Record<string, unknown> {
|
||||
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<NodeType, string> = {
|
||||
printer: 'Printer',
|
||||
computer: 'Computer',
|
||||
cpl: 'CPL / Powerline',
|
||||
docker: 'Docker Host',
|
||||
generic: 'Generic Device',
|
||||
groupRect: 'Group Rectangle',
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -56,6 +56,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
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<ThemeId, ThemePreset> = {
|
||||
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<ThemeId, ThemePreset> = {
|
||||
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<ThemeId, ThemePreset> = {
|
||||
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<ThemeId, ThemePreset> = {
|
||||
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' },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user