diff --git a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx index 91ec10b..d320d77 100644 --- a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx +++ b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx @@ -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' }] }) diff --git a/frontend/src/components/canvas/edges/__tests__/edgeTypes.test.ts b/frontend/src/components/canvas/edges/__tests__/edgeTypes.test.ts new file mode 100644 index 0000000..4d0e354 --- /dev/null +++ b/frontend/src/components/canvas/edges/__tests__/edgeTypes.test.ts @@ -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() + }) +}) diff --git a/frontend/src/components/canvas/edges/edgeTypes.ts b/frontend/src/components/canvas/edges/edgeTypes.ts index da1d29d..ef46d02 100644 --- a/frontend/src/components/canvas/edges/edgeTypes.ts +++ b/frontend/src/components/canvas/edges/edgeTypes.ts @@ -7,4 +7,5 @@ export const edgeTypes = { vlan: HomelableEdge, virtual: HomelableEdge, cluster: HomelableEdge, + fibre: HomelableEdge, } diff --git a/frontend/src/components/canvas/edges/index.tsx b/frontend/src/components/canvas/edges/index.tsx index 2a4fb8b..7393905 100644 --- a/frontend/src/components/canvas/edges/index.tsx +++ b/frontend/src/components/canvas/edges/index.tsx @@ -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 diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index 1c8c398..c65faee 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -254,6 +254,20 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: const targetId = `${sourceId}-t` return ( + {data.show_port_numbers && ( + + {idx + 1} + + )} = { isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers, diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index 274954b..6e96714 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -513,6 +513,27 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' {MIN_BOTTOM_HANDLES} {MAX_BOTTOM_HANDLES} +
+
+ + Label each bottom connection point +
+ +
)} diff --git a/frontend/src/components/modals/__tests__/EdgeModal.test.tsx b/frontend/src/components/modals/__tests__/EdgeModal.test.tsx index 7b97730..c24a0bf 100644 --- a/frontend/src/components/modals/__tests__/EdgeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/EdgeModal.test.tsx @@ -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() + 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', () => { diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx index 7319526..1895e49 100644 --- a/frontend/src/components/modals/__tests__/NodeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/NodeModal.test.tsx @@ -416,20 +416,30 @@ describe('NodeModal', () => { expect((onSubmit.mock.calls[0][0] as Partial).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).bottom_handles).toBe(48) + expect((onSubmit.mock.calls[0][0] as Partial).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).show_port_numbers).toBe(true) }) // ── Zigbee nodes ────────────────────────────────────────────────────── diff --git a/frontend/src/types/__tests__/types.test.ts b/frontend/src/types/__tests__/types.test.ts index 0ccf129..3d1965a 100644 --- a/frontend/src/types/__tests__/types.test.ts +++ b/frontend/src/types/__tests__/types.test.ts @@ -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) }) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 17932b4..a3f7721 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 { */ 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 = { vlan: 'VLAN', virtual: 'Virtual', cluster: 'Cluster', + fibre: 'Fibre', } export interface NodeTypeStyle { diff --git a/frontend/src/utils/__tests__/edgeColors.test.ts b/frontend/src/utils/__tests__/edgeColors.test.ts index 4ca7cb5..577f518 100644 --- a/frontend/src/utils/__tests__/edgeColors.test.ts +++ b/frontend/src/utils/__tests__/edgeColors.test.ts @@ -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') + }) }) diff --git a/frontend/src/utils/__tests__/exportYaml.test.ts b/frontend/src/utils/__tests__/exportYaml.test.ts index 870fe44..7348e7e 100644 --- a/frontend/src/utils/__tests__/exportYaml.test.ts +++ b/frontend/src/utils/__tests__/exportYaml.test.ts @@ -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[] + 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') diff --git a/frontend/src/utils/__tests__/handleUtils.test.ts b/frontend/src/utils/__tests__/handleUtils.test.ts index 64d25ea..fc9ae6b 100644 --- a/frontend/src/utils/__tests__/handleUtils.test.ts +++ b/frontend/src/utils/__tests__/handleUtils.test.ts @@ -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) }) }) diff --git a/frontend/src/utils/__tests__/importYaml.test.ts b/frontend/src/utils/__tests__/importYaml.test.ts index 74a385d..27a4e36 100644 --- a/frontend/src/utils/__tests__/importYaml.test.ts +++ b/frontend/src/utils/__tests__/importYaml.test.ts @@ -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 diff --git a/frontend/src/utils/__tests__/themes.test.ts b/frontend/src/utils/__tests__/themes.test.ts index 74e7937..9563a4d 100644 --- a/frontend/src/utils/__tests__/themes.test.ts +++ b/frontend/src/utils/__tests__/themes.test.ts @@ -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', () => { diff --git a/frontend/src/utils/canvasSerializer.ts b/frontend/src/utils/canvasSerializer.ts index 435e06b..9e8488e 100644 --- a/frontend/src/utils/canvasSerializer.ts +++ b/frontend/src/utils/canvasSerializer.ts @@ -32,6 +32,7 @@ export interface ApiNode extends Record { 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): Record { 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, } diff --git a/frontend/src/utils/edgeColors.ts b/frontend/src/utils/edgeColors.ts index e395c25..bd3b3d3 100644 --- a/frontend/src/utils/edgeColors.ts +++ b/frontend/src/utils/edgeColors.ts @@ -7,4 +7,5 @@ export const EDGE_DEFAULT_COLORS: Record = { vlan: '#00d4ff', virtual: '#8b949e', cluster: '#ff6e00', + fibre: '#22d3ee', } diff --git a/frontend/src/utils/handleUtils.ts b/frontend/src/utils/handleUtils.ts index 0921d0f..7833cb6 100644 --- a/frontend/src/utils/handleUtils.ts +++ b/frontend/src/utils/handleUtils.ts @@ -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 { diff --git a/frontend/src/utils/themes.ts b/frontend/src/utils/themes.ts index bce150a..030a698 100644 --- a/frontend/src/utils/themes.ts +++ b/frontend/src/utils/themes.ts @@ -86,6 +86,7 @@ export const THEMES: Record = { vlan: '#00d4ff', virtual: '#8b949e', cluster: '#ff6e00', + fibre: '#22d3ee', }, edgeSelectedColor: '#00d4ff', edgeLabelBackground:'#161b22', @@ -149,6 +150,7 @@ export const THEMES: Record = { vlan: '#22d3ee', virtual: '#6b7280', cluster: '#fb923c', + fibre: '#06b6d4', }, edgeSelectedColor: '#22d3ee', edgeLabelBackground:'#111111', @@ -212,6 +214,7 @@ export const THEMES: Record = { vlan: '#0284c7', virtual: '#9ca3af', cluster: '#ea580c', + fibre: '#0891b2', }, edgeSelectedColor: '#0284c7', edgeLabelBackground:'#ffffff', @@ -275,6 +278,7 @@ export const THEMES: Record = { vlan: '#00ffff', virtual: '#8888cc', cluster: '#ff8800', + fibre: '#00e5ff', }, edgeSelectedColor: '#00ffff', edgeLabelBackground:'#0a0a1a', @@ -338,6 +342,7 @@ export const THEMES: Record = { vlan: '#00cc33', virtual: '#004400', cluster: '#33ff66', + fibre: '#00ffcc', }, edgeSelectedColor: '#00ff41', edgeLabelBackground:'#001100', @@ -401,6 +406,7 @@ export const THEMES: Record = { vlan: '#00d4ff', virtual: '#8b949e', cluster: '#ff6e00', + fibre: '#22d3ee', }, edgeSelectedColor: '#00d4ff', edgeLabelBackground:'#161b22',