feat(canvas): raise switch port cap to 64, add port numbers and fibre edge type

Issue #20: bump MAX_BOTTOM_HANDLES 48 -> 64 (covers 48+4 SFP switches) and add
a per-node "Show Port Numbers" toggle that labels each bottom connection point.

Issue #21: add `fibre` as a first-class edge/connection type (bright cyan with a
subtle glow) alongside ethernet/wifi/iot/vlan/virtual/cluster - selectable in the
edge modal, themeable, registered in the React Flow edgeTypes registry, and
round-tripped through YAML import/export.

Backport of homelable-hacs PR #23.

ha-relevant: yes
This commit is contained in:
Pouzor
2026-05-29 17:56:51 +02:00
parent b5628e18fa
commit 1431f5b19e
20 changed files with 161 additions and 17 deletions
@@ -59,7 +59,10 @@ vi.mock('@/utils/propertyIcons', () => ({
vi.mock('@/utils/handleUtils', () => ({
bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`,
bottomHandlePositions: () => [50],
bottomHandlePositions: (count: number) => {
const c = typeof count === 'number' && count > 0 ? Math.floor(count) : 1
return Array.from({ length: c }, (_, i) => ((i + 1) * 100) / (c + 1))
},
clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1,
}))
@@ -171,6 +174,29 @@ describe('BaseNode — properties rendering', () => {
})
})
describe('BaseNode — port numbers (issue #20)', () => {
it('renders a number above each bottom handle when show_port_numbers is on', () => {
renderBaseNode({ bottom_handles: 4, show_port_numbers: true })
expect(screen.getByText('1')).toBeDefined()
expect(screen.getByText('2')).toBeDefined()
expect(screen.getByText('3')).toBeDefined()
expect(screen.getByText('4')).toBeDefined()
})
it('does not render port numbers when show_port_numbers is off', () => {
renderBaseNode({ bottom_handles: 4 })
expect(screen.queryByText('1')).toBeNull()
expect(screen.queryByText('4')).toBeNull()
})
it('numbers match the handle count', () => {
renderBaseNode({ bottom_handles: 2, show_port_numbers: true })
expect(screen.getByText('1')).toBeDefined()
expect(screen.getByText('2')).toBeDefined()
expect(screen.queryByText('3')).toBeNull()
})
})
describe('BaseNode — services visibility toggle', () => {
it('does not render service toggle button on the node', () => {
renderBaseNode({ services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }] })
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest'
import { edgeTypes } from '../edgeTypes'
import { EDGE_TYPE_LABELS, type EdgeType } from '@/types'
describe('edgeTypes registry', () => {
// Regression (issue #21): an EdgeType missing here makes React Flow fall back
// to its built-in default edge — grey, unstyled, ignoring custom_color.
it('registers a component for every EdgeType', () => {
for (const type of Object.keys(EDGE_TYPE_LABELS) as EdgeType[]) {
expect(edgeTypes[type as keyof typeof edgeTypes]).toBeDefined()
}
})
it('registers fibre', () => {
expect(edgeTypes.fibre).toBeDefined()
})
})
@@ -7,4 +7,5 @@ export const edgeTypes = {
vlan: HomelableEdge,
virtual: HomelableEdge,
cluster: HomelableEdge,
fibre: HomelableEdge,
}
@@ -323,6 +323,7 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
vlan: { strokeWidth: 2.5 },
virtual: { stroke: edgeColors.virtual, strokeWidth: 1, strokeDasharray: '4 4' },
cluster: { stroke: edgeColors.cluster, strokeWidth: 2.5, strokeDasharray: '8 3' },
fibre: { stroke: edgeColors.fibre, strokeWidth: 2.5, filter: `drop-shadow(0 0 3px ${edgeColors.fibre}aa)` },
}
const customColor = data?.custom_color as string | undefined
@@ -254,6 +254,20 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
const targetId = `${sourceId}-t`
return (
<span key={sourceId}>
{data.show_port_numbers && (
<span
className="absolute font-mono leading-none pointer-events-none select-none"
style={{
left: `${leftPct}%`,
bottom: 3,
transform: 'translateX(-50%)',
fontSize: 7,
color: theme.colors.nodeSubtextColor,
}}
>
{idx + 1}
</span>
)}
<Handle
type="source"
position={Position.Bottom}
@@ -26,7 +26,7 @@ const EDITABLE_NODE_TYPES: NodeType[] = [
'generic',
]
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre']
const NODE_ICONS: Record<string, LucideIcon> = {
isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers,
@@ -513,6 +513,27 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<span>{MIN_BOTTOM_HANDLES}</span>
<span>{MAX_BOTTOM_HANDLES}</span>
</div>
<div className="flex items-center justify-between pt-1">
<div className="flex flex-col gap-0.5">
<Label className="text-xs text-muted-foreground">Show Port Numbers</Label>
<span className="text-[10px] text-muted-foreground/60">Label each bottom connection point</span>
</div>
<button
type="button"
role="switch"
aria-checked={!!form.show_port_numbers}
onClick={() => set('show_port_numbers', !form.show_port_numbers)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none ${modalStyles['modal-interactive']}`}
tabIndex={0}
aria-label="Toggle port numbers"
style={{ background: form.show_port_numbers ? '#ff6e00' : '#30363d' }}
>
<span
className="pointer-events-none absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-all"
style={{ left: form.show_port_numbers ? 'calc(100% - 18px)' : '2px' }}
/>
</button>
</div>
</div>
)}
@@ -59,6 +59,13 @@ describe('EdgeModal', () => {
expect(onSubmit.mock.calls[0][0].label).toBeUndefined()
})
it('round-trips the fibre type through submit (issue #21)', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ type: 'fibre' }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].type).toBe('fibre')
})
// ── VLAN ID field ─────────────────────────────────────────────────────────
it('does not show VLAN ID field for ethernet type', () => {
@@ -416,20 +416,30 @@ describe('NodeModal', () => {
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(12)
})
it('supports the full 1..48 range', () => {
it('supports the full 1..64 range (issue #20)', () => {
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' } })
expect(slider.max).toBe('64')
fireEvent.change(slider, { target: { value: '52' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(48)
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(52)
})
it('clamps pre-filled out-of-range values into [1,48]', () => {
it('clamps pre-filled out-of-range values into [1,64]', () => {
renderModal({ initial: { ...BASE, bottom_handles: 9999 } })
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
expect(slider.value).toBe('48')
expect(slider.value).toBe('64')
})
it('toggles show_port_numbers and submits it (issue #20)', () => {
const { onSubmit } = renderModal({ initial: BASE })
const toggle = screen.getByLabelText('Toggle port numbers')
expect(toggle.getAttribute('aria-checked')).toBe('false')
fireEvent.click(toggle)
expect(toggle.getAttribute('aria-checked')).toBe('true')
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).show_port_numbers).toBe(true)
})
// ── Zigbee nodes ──────────────────────────────────────────────────────
+1 -1
View File
@@ -28,7 +28,7 @@ describe('STATUS_COLORS', () => {
describe('EDGE_TYPE_LABELS', () => {
it('has an entry for every edge type', () => {
const expectedTypes = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
const expectedTypes = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre']
expectedTypes.forEach((t) => {
expect(EDGE_TYPE_LABELS).toHaveProperty(t)
})
+5 -2
View File
@@ -37,7 +37,7 @@ export type TextPosition =
| 'bottom-center'
| 'bottom-right'
export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster'
export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster' | 'fibre'
export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown'
@@ -106,8 +106,10 @@ export interface NodeData extends Record<string, unknown> {
*/
collapsed?: boolean
custom_icon?: string
/** Number of bottom connection points, 1..48. Default 1 (centered). */
/** Number of bottom connection points, 1..64. Default 1 (centered). */
bottom_handles?: number
/** Show a port number (1..N) above each bottom connection point. */
show_port_numbers?: boolean
/** Text node content (type === 'text') */
text_content?: string
}
@@ -173,6 +175,7 @@ export const EDGE_TYPE_LABELS: Record<EdgeType, string> = {
vlan: 'VLAN',
virtual: 'Virtual',
cluster: 'Cluster',
fibre: 'Fibre',
}
export interface NodeTypeStyle {
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
import { EDGE_DEFAULT_COLORS } from '../edgeColors'
import type { EdgeType } from '@/types'
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre']
describe('EDGE_DEFAULT_COLORS', () => {
it('has an entry for every EdgeType', () => {
@@ -36,4 +36,8 @@ describe('EDGE_DEFAULT_COLORS', () => {
it('cluster default is proxmox orange', () => {
expect(EDGE_DEFAULT_COLORS.cluster).toBe('#ff6e00')
})
it('fibre default is bright cyan', () => {
expect(EDGE_DEFAULT_COLORS.fibre).toBe('#22d3ee')
})
})
@@ -102,6 +102,15 @@ describe('exportCanvasToYaml', () => {
expect(entryA).not.toHaveProperty('clusterR')
})
it('serializes a fibre edge with linkType "fibre" (issue #21)', () => {
const nodeA = makeNode({ label: 'Switch', type: 'switch' }, 'sw')
const nodeB = makeNode({ label: 'Server1', type: 'server' }, 's1')
const edge = makeEdge('e1', 'sw', 's1', { type: 'fibre', label: 'sfp0' })
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
const entryA = result.find((e) => e.label === 'Switch')!
expect(entryA.links).toEqual([{ label: 'Server1', linkType: 'fibre', linkLabel: 'sfp0' }])
})
it('serializes multiple outgoing edges as links array', () => {
const sw = makeNode({ label: 'Switch', type: 'switch' }, 'sw')
const s1 = makeNode({ label: 'Server1', type: 'server' }, 's1')
@@ -29,8 +29,12 @@ describe('clampBottomHandles', () => {
expect(clampBottomHandles(-5)).toBe(MIN_BOTTOM_HANDLES)
})
it('supports at least 52 ports (issue #20 — Cisco 48+4 SFP)', () => {
expect(MAX_BOTTOM_HANDLES).toBeGreaterThanOrEqual(52)
})
it('clamps above MAX to MAX', () => {
expect(clampBottomHandles(49)).toBe(MAX_BOTTOM_HANDLES)
expect(clampBottomHandles(65)).toBe(MAX_BOTTOM_HANDLES)
expect(clampBottomHandles(9999)).toBe(MAX_BOTTOM_HANDLES)
})
@@ -49,6 +53,8 @@ describe('clampBottomHandles', () => {
expect(clampBottomHandles(1)).toBe(1)
expect(clampBottomHandles(24)).toBe(24)
expect(clampBottomHandles(48)).toBe(48)
expect(clampBottomHandles(52)).toBe(52)
expect(clampBottomHandles(64)).toBe(64)
})
})
@@ -89,6 +89,22 @@ describe('parseYamlToCanvas', () => {
expect(edges[0].targetHandle).toBe('top-t')
})
it('imports a fibre link type onto the edge (issue #21)', () => {
const yaml = `
- nodeType: switch
label: "SW"
links:
- label: "SRV"
linkType: fibre
- nodeType: server
label: "SRV"
`
const { edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
expect(edges).toHaveLength(1)
expect(edges[0].type).toBe('fibre')
expect(edges[0].data?.type).toBe('fibre')
})
it('cluster edges have cluster-right→cluster-left handles', () => {
const yaml = `
- nodeType: proxmox
+1 -1
View File
@@ -6,7 +6,7 @@ const NODE_TYPES: NodeType[] = [
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc',
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'laptop', 'mobile', 'cpl', 'docker_host', 'docker_container', 'generic', 'groupRect',
]
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre']
const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
describe('THEME_ORDER', () => {
+2
View File
@@ -32,6 +32,7 @@ export interface ApiNode extends Record<string, unknown> {
width?: number | null
height?: number | null
bottom_handles?: number
show_port_numbers?: boolean
}
export interface ApiEdge {
@@ -114,6 +115,7 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
width: n.measured?.width ?? n.width ?? null,
height: n.measured?.height ?? n.height ?? null,
bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1),
show_port_numbers: n.data.show_port_numbers ?? false,
pos_x: n.position.x,
pos_y: n.position.y,
}
+1
View File
@@ -7,4 +7,5 @@ export const EDGE_DEFAULT_COLORS: Record<EdgeType, string> = {
vlan: '#00d4ff',
virtual: '#8b949e',
cluster: '#ff6e00',
fibre: '#22d3ee',
}
+3 -3
View File
@@ -2,14 +2,14 @@
* Bottom handle configuration for multi-handle nodes.
*
* Handle IDs: index 0 = 'bottom' (always the default, backward-compatible)
* index N≥1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 47 = 'bottom-48')
* index N≥1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 63 = 'bottom-64')
*
* Invisible target handles follow the same pattern with a '-t' suffix:
* 'bottom-t', 'bottom-2-t', ..., 'bottom-48-t'
* 'bottom-t', 'bottom-2-t', ..., 'bottom-64-t'
*/
export const MIN_BOTTOM_HANDLES = 1
export const MAX_BOTTOM_HANDLES = 48
export const MAX_BOTTOM_HANDLES = 64
/** Returns the source handle ID at a given slot index. */
export function bottomHandleId(idx: number): string {
+6
View File
@@ -86,6 +86,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#00d4ff',
virtual: '#8b949e',
cluster: '#ff6e00',
fibre: '#22d3ee',
},
edgeSelectedColor: '#00d4ff',
edgeLabelBackground:'#161b22',
@@ -149,6 +150,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#22d3ee',
virtual: '#6b7280',
cluster: '#fb923c',
fibre: '#06b6d4',
},
edgeSelectedColor: '#22d3ee',
edgeLabelBackground:'#111111',
@@ -212,6 +214,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#0284c7',
virtual: '#9ca3af',
cluster: '#ea580c',
fibre: '#0891b2',
},
edgeSelectedColor: '#0284c7',
edgeLabelBackground:'#ffffff',
@@ -275,6 +278,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#00ffff',
virtual: '#8888cc',
cluster: '#ff8800',
fibre: '#00e5ff',
},
edgeSelectedColor: '#00ffff',
edgeLabelBackground:'#0a0a1a',
@@ -338,6 +342,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#00cc33',
virtual: '#004400',
cluster: '#33ff66',
fibre: '#00ffcc',
},
edgeSelectedColor: '#00ff41',
edgeLabelBackground:'#001100',
@@ -401,6 +406,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#00d4ff',
virtual: '#8b949e',
cluster: '#ff6e00',
fibre: '#22d3ee',
},
edgeSelectedColor: '#00d4ff',
edgeLabelBackground:'#161b22',