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
@@ -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',