From 1cf525844b28cbcb07015ffd1b78fa71baacd99e Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 5 Jul 2026 01:16:26 +0200 Subject: [PATCH] feat: arrowhead endpoints for edges + fix parallel edges not rendering Add optional filled-triangle arrowheads at either end of an edge, independently toggleable per edge (EdgeModal) and as per-edge-type defaults (CustomStyleModal). Arrowheads are custom inline defs filled with the live stroke colour so they recolour reactively with custom_color / vlan / selected state. Persisted frontend (serializer) and backend (edge columns + schemas + runtime migration). Also fix two dedupe layers that silently dropped legitimate parallel links between the same two devices: - store: React Flow addEdge() connectionExists dropped a second edge with matching source+target when handles were null/equal. Build the edge with a unique id and append directly. - render: rewireEdgesForCollapse deduped ALL edges by src->tgt key even when nothing was collapsed, filtering real parallel edges out of the visible set. Restrict the anti-mesh dedupe to rewired collapse stubs. Tests: marker render, per-edge/per-type UI, store apply, serializer round-trip, backend edge/canvas persistence, parallel-edge regressions. ha-relevant: yes --- backend/app/db/database.py | 4 ++ backend/app/db/models.py | 2 + backend/app/schemas/canvas.py | 2 + backend/app/schemas/edges.py | 4 ++ backend/tests/test_canvas.py | 22 ++++++ backend/tests/test_edges.py | 25 +++++++ .../__tests__/HomelableEdge.markers.test.tsx | 72 +++++++++++++++++++ .../src/components/canvas/edges/index.tsx | 41 ++++++++++- .../components/modals/CustomStyleModal.tsx | 24 +++++++ frontend/src/components/modals/EdgeModal.tsx | 28 ++++++++ .../__tests__/CustomStyleModal.test.tsx | 22 ++++++ .../modals/__tests__/EdgeModal.test.tsx | 35 +++++++++ .../src/stores/__tests__/canvasStore.test.ts | 28 +++++++- .../src/stores/__tests__/themeStore.test.ts | 2 +- frontend/src/stores/canvasStore.ts | 27 ++++--- frontend/src/types/index.ts | 8 +++ .../utils/__tests__/canvasSerializer.test.ts | 13 ++++ .../utils/__tests__/collapseFilter.test.ts | 11 +++ frontend/src/utils/canvasSerializer.ts | 4 ++ frontend/src/utils/collapseFilter.ts | 11 ++- 20 files changed, 371 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/canvas/edges/__tests__/HomelableEdge.markers.test.tsx diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 6504004..ca7b9f6 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -81,6 +81,10 @@ async def init_db() -> None: await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT") with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0") + with suppress(OperationalError): + await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN marker_start BOOLEAN NOT NULL DEFAULT 0") + with suppress(OperationalError): + await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN marker_end BOOLEAN NOT NULL DEFAULT 0") with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER") with suppress(OperationalError): diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 875ea5d..a47a974 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -86,6 +86,8 @@ class Edge(Base): custom_color: Mapped[str | None] = mapped_column(String) path_style: Mapped[str | None] = mapped_column(String) animated: Mapped[str] = mapped_column(String, nullable=False, default='none') + marker_start: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + marker_end: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) source_handle: Mapped[str | None] = mapped_column(String) target_handle: Mapped[str | None] = mapped_column(String) waypoints: Mapped[list[dict[str, float]] | None] = mapped_column(JSON, nullable=True) diff --git a/backend/app/schemas/canvas.py b/backend/app/schemas/canvas.py index 8e23860..86892f3 100644 --- a/backend/app/schemas/canvas.py +++ b/backend/app/schemas/canvas.py @@ -52,6 +52,8 @@ class EdgeSave(BaseModel): custom_color: str | None = None path_style: str | None = None animated: str = 'none' + marker_start: bool = False + marker_end: bool = False source_handle: str | None = None target_handle: str | None = None waypoints: list[dict[str, float]] | None = None diff --git a/backend/app/schemas/edges.py b/backend/app/schemas/edges.py index 85964d8..1924115 100644 --- a/backend/app/schemas/edges.py +++ b/backend/app/schemas/edges.py @@ -15,6 +15,8 @@ class EdgeBase(BaseModel): custom_color: str | None = None path_style: str | None = None animated: str = 'none' + marker_start: bool = False + marker_end: bool = False source_handle: str | None = None target_handle: str | None = None waypoints: list[dict[str, float]] | None = None @@ -37,6 +39,8 @@ class EdgeUpdate(BaseModel): custom_color: str | None = None path_style: str | None = None animated: str | None = None + marker_start: bool | None = None + marker_end: bool | None = None source_handle: str | None = None target_handle: str | None = None waypoints: list[dict[str, float]] | None = None diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index a41e3b4..689685d 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -51,6 +51,28 @@ async def test_save_canvas_creates_nodes_and_edges(client: AsyncClient, headers: assert canvas["viewport"] == {"x": 1, "y": 2, "zoom": 1.5} +async def test_save_canvas_round_trips_arrow_markers(client: AsyncClient, headers: dict): + n1 = node_payload(label="Router", type="router") + n2 = node_payload(label="Switch", type="switch") + e1 = edge_payload(n1["id"], n2["id"], marker_start=True, marker_end=True) + await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers) + + edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0] + assert edge["marker_start"] is True + assert edge["marker_end"] is True + + +async def test_save_canvas_defaults_arrow_markers_off(client: AsyncClient, headers: dict): + n1 = node_payload(label="Router", type="router") + n2 = node_payload(label="Switch", type="switch") + e1 = edge_payload(n1["id"], n2["id"]) + await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers) + + edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0] + assert edge["marker_start"] is False + assert edge["marker_end"] is False + + async def test_save_canvas_round_trips_per_side_handles(client: AsyncClient, headers: dict): # Regression (#243): top/left/right_handles must persist across save+reload, # not just bottom_handles. diff --git a/backend/tests/test_edges.py b/backend/tests/test_edges.py index e9a560d..489aa30 100644 --- a/backend/tests/test_edges.py +++ b/backend/tests/test_edges.py @@ -91,6 +91,31 @@ async def test_update_edge_custom_color_and_path_style(client: AsyncClient, head assert res.json()["path_style"] == "smooth" +async def test_create_edge_with_arrow_markers(client: AsyncClient, headers: dict, two_nodes): + src, tgt = two_nodes + res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "marker_start": True, "marker_end": True}, headers=headers) + assert res.status_code == 201 + assert res.json()["marker_start"] is True + assert res.json()["marker_end"] is True + + +async def test_create_edge_defaults_arrow_markers_off(client: AsyncClient, headers: dict, two_nodes): + src, tgt = two_nodes + res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers) + assert res.status_code == 201 + assert res.json()["marker_start"] is False + assert res.json()["marker_end"] is False + + +async def test_update_edge_arrow_markers(client: AsyncClient, headers: dict, two_nodes): + src, tgt = two_nodes + edge_id = (await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)).json()["id"] + res = await client.patch(f"/api/v1/edges/{edge_id}", json={"marker_end": True}, headers=headers) + assert res.status_code == 200 + assert res.json()["marker_end"] is True + assert res.json()["marker_start"] is False + + async def test_create_edge_requires_auth(client: AsyncClient, two_nodes): src, tgt = two_nodes res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}) diff --git a/frontend/src/components/canvas/edges/__tests__/HomelableEdge.markers.test.tsx b/frontend/src/components/canvas/edges/__tests__/HomelableEdge.markers.test.tsx new file mode 100644 index 0000000..b526946 --- /dev/null +++ b/frontend/src/components/canvas/edges/__tests__/HomelableEdge.markers.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest' +import { render } from '@testing-library/react' +import { ReactFlowProvider } from '@xyflow/react' +import type { EdgeProps, Edge } from '@xyflow/react' +import { HomelableEdge } from '../index' +import type { EdgeData } from '@/types' + +/** + * Arrowhead endpoints: filled-triangle defs, independently toggleable + * at start/end, filled with the live stroke color, and referenced by BaseEdge + * via markerStart/markerEnd URLs. + */ +function renderEdge(data: Partial = {}, selected = false) { + const props = { + id: 'e1', + source: 'a', + target: 'b', + sourceX: 0, + sourceY: 0, + targetX: 100, + targetY: 100, + sourcePosition: 'bottom', + targetPosition: 'top', + data: { type: 'ethernet', ...data } as EdgeData, + selected, + } as unknown as EdgeProps> + + return render( + + + + + , + ) +} + +describe('HomelableEdge arrow markers', () => { + it('renders no marker defs by default', () => { + const { container } = renderEdge() + expect(container.querySelector('marker')).toBeNull() + }) + + it('renders an end marker referenced by the edge path', () => { + const { container } = renderEdge({ marker_end: true }) + const marker = container.querySelector('#arrow-end-e1') + expect(marker).toBeTruthy() + expect(container.querySelector('#arrow-start-e1')).toBeNull() + const referenced = Array.from(container.querySelectorAll('path')).some( + (p) => p.getAttribute('marker-end') === 'url(#arrow-end-e1)', + ) + expect(referenced).toBe(true) + }) + + it('renders a start marker with reversed orientation', () => { + const { container } = renderEdge({ marker_start: true }) + const marker = container.querySelector('#arrow-start-e1') + expect(marker).toBeTruthy() + expect(marker?.getAttribute('orient')).toBe('auto-start-reverse') + }) + + it('renders both markers when both ends enabled', () => { + const { container } = renderEdge({ marker_start: true, marker_end: true }) + expect(container.querySelector('#arrow-start-e1')).toBeTruthy() + expect(container.querySelector('#arrow-end-e1')).toBeTruthy() + }) + + it('fills the marker with the resolved custom color', () => { + const { container } = renderEdge({ marker_end: true, custom_color: '#ff6e00' }) + const fill = container.querySelector('#arrow-end-e1 path')?.getAttribute('fill') + expect(fill).toBe('#ff6e00') + }) +}) diff --git a/frontend/src/components/canvas/edges/index.tsx b/frontend/src/components/canvas/edges/index.tsx index bae77b8..52e456b 100644 --- a/frontend/src/components/canvas/edges/index.tsx +++ b/frontend/src/components/canvas/edges/index.tsx @@ -351,9 +351,48 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle ? segmentMidpoints(sourceX, sourceY, waypoints, targetX, targetY, pathStyle, sourcePosition) : [] + // ── Arrowheads ───────────────────────────────────────────────────────────── + // Custom inline defs filled with the live strokeColor so they recolor + // reactively (custom_color / vlan / selected). Sized from the stroke width. + const markerStart = data?.marker_start === true + const markerEnd = data?.marker_end === true + const strokeW = (style.strokeWidth as number) ?? 2 + const markerSize = 6 + strokeW * 2 + const startMarkerId = `arrow-start-${id}` + const endMarkerId = `arrow-end-${id}` + + const arrowMarker = (markerId: string, orient: string) => ( + + + + ) + return ( <> - + {(markerStart || markerEnd) && ( + + {markerStart && arrowMarker(startMarkerId, 'auto-start-reverse')} + {markerEnd && arrowMarker(endMarkerId, 'auto')} + + )} + + {animMode === 'basic' && ( Snake + +
+
Arrows
+
+ {([['Start', 'arrowStart'], ['End', 'arrowEnd']] as [string, 'arrowStart' | 'arrowEnd'][]).map(([label, key]) => ( + + ))} +
+
+ ))} + + +
diff --git a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx index a64cbff..65cd47b 100644 --- a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx +++ b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx @@ -124,6 +124,28 @@ describe('CustomStyleModal', () => { expect(markUnsaved).not.toHaveBeenCalled() }) + it('edge editor exposes Start/End arrow toggles defaulting off', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Edges' })) + fireEvent.click(screen.getByRole('button', { name: /Ethernet/ })) + const startBtn = screen.getByRole('button', { name: 'Start' }) + const endBtn = screen.getByRole('button', { name: 'End' }) + expect(startBtn.getAttribute('aria-pressed')).toBe('false') + expect(endBtn.getAttribute('aria-pressed')).toBe('false') + }) + + it('toggling End arrow feeds arrowEnd to applyTypeEdgeStyle', () => { + const applyTypeEdgeStyle = vi.fn() + useCanvasStore.setState({ applyTypeEdgeStyle }) + render() + fireEvent.click(screen.getByRole('button', { name: 'Edges' })) + fireEvent.click(screen.getByRole('button', { name: /Ethernet/ })) + fireEvent.click(screen.getByRole('button', { name: 'End' })) + fireEvent.click(screen.getByRole('button', { name: /Apply to existing Ethernet/ })) + expect(applyTypeEdgeStyle.mock.calls[0][1].arrowEnd).toBe(true) + expect(applyTypeEdgeStyle.mock.calls[0][1].arrowStart).toBe(false) + }) + it('editing path style updates the edge draft', () => { render() fireEvent.click(screen.getByRole('button', { name: 'Edges' })) diff --git a/frontend/src/components/modals/__tests__/EdgeModal.test.tsx b/frontend/src/components/modals/__tests__/EdgeModal.test.tsx index 51e6faa..75dcf82 100644 --- a/frontend/src/components/modals/__tests__/EdgeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/EdgeModal.test.tsx @@ -165,6 +165,41 @@ describe('EdgeModal', () => { expect(onSubmit.mock.calls[0][0].animated).toBe('basic') }) + // ── Arrow markers ───────────────────────────────────────────────────────── + + it('arrows default to off', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].marker_start).toBe(false) + expect(onSubmit.mock.calls[0][0].marker_end).toBe(false) + }) + + it('toggling End arrow sends marker_end: true', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /Arrow End/ })) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].marker_end).toBe(true) + expect(onSubmit.mock.calls[0][0].marker_start).toBe(false) + }) + + it('toggling Start arrow sends marker_start: true', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /Arrow Start/ })) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].marker_start).toBe(true) + }) + + it('pre-fills arrows from initial', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].marker_start).toBe(true) + expect(onSubmit.mock.calls[0][0].marker_end).toBe(true) + }) + it('selecting None after Snake omits animated from payload', () => { const onSubmit = vi.fn() render() diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index a96f961..35c53ae 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -332,6 +332,25 @@ describe('canvasStore', () => { expect(edges[0].data?.animated).toBe('snake') }) + it('onConnect allows multiple edges between the same two nodes (no dedupe)', () => { + // Regression: React Flow addEdge() dropped a second edge between the same + // source+target when handles were null/equal. A homelab has multiple links + // between two devices, so every connect must add an edge. + useCanvasStore.getState().onConnect({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }) + useCanvasStore.getState().onConnect({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }) + const { edges } = useCanvasStore.getState() + expect(edges).toHaveLength(2) + expect(edges[0].id).not.toBe(edges[1].id) + }) + + it('onConnect preserves arrow markers from edge data', () => { + const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }, { type: 'ethernet', marker_start: true, marker_end: true }) + useCanvasStore.getState().onConnect(conn) + const { edges } = useCanvasStore.getState() + expect(edges[0].data?.marker_start).toBe(true) + expect(edges[0].data?.marker_end).toBe(true) + }) + it('onConnect preserves sourceHandle and targetHandle for cluster edges', () => { const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: 'cluster-right', targetHandle: 'cluster-left' }, { type: 'cluster' }) useCanvasStore.getState().onConnect(conn) @@ -1301,14 +1320,17 @@ describe('canvasStore — custom style apply', () => { const e2: Edge = { id: 'e2', source: 'n1', target: 'n2', type: 'wifi', data: { type: 'wifi' } } useCanvasStore.setState({ nodes: [], edges: [e1, e2] }) - useCanvasStore.getState().applyTypeEdgeStyle('ethernet', { color: '#00ff00', opacity: 1, pathStyle: 'smooth', animated: 'flow' }) + useCanvasStore.getState().applyTypeEdgeStyle('ethernet', { color: '#00ff00', opacity: 1, pathStyle: 'smooth', animated: 'flow', arrowStart: true, arrowEnd: true }) const updated1 = useCanvasStore.getState().edges.find((e) => e.id === 'e1')! const updated2 = useCanvasStore.getState().edges.find((e) => e.id === 'e2')! expect(updated1.data?.custom_color).toBe('#00ff00') expect(updated1.data?.path_style).toBe('smooth') expect(updated1.data?.animated).toBe('flow') + expect(updated1.data?.marker_start).toBe(true) + expect(updated1.data?.marker_end).toBe(true) expect(updated2.data?.custom_color).toBeUndefined() + expect(updated2.data?.marker_end).toBeUndefined() }) it('applyAllCustomStyles applies all defined types', () => { @@ -1322,7 +1344,7 @@ describe('canvasStore — custom style apply', () => { proxmox: { borderColor: '#ff6e00', borderOpacity: 1, bgColor: '#111', bgOpacity: 1, iconColor: '#ff6e00', iconOpacity: 1, width: 0, height: 0 }, }, edges: { - ethernet: { color: '#aabbcc', opacity: 1, pathStyle: 'bezier', animated: 'none' }, + ethernet: { color: '#aabbcc', opacity: 1, pathStyle: 'bezier', animated: 'none', arrowStart: false, arrowEnd: true }, }, }) @@ -1332,6 +1354,8 @@ describe('canvasStore — custom style apply', () => { expect(np.data.custom_colors?.border).toBe('#ff6e00') expect(ns.data.custom_colors?.border).toBeUndefined() expect(e.data?.custom_color).toBe('#aabbcc') + expect(e.data?.marker_end).toBe(true) + expect(e.data?.marker_start).toBe(false) expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) }) diff --git a/frontend/src/stores/__tests__/themeStore.test.ts b/frontend/src/stores/__tests__/themeStore.test.ts index ee07407..db6ac1f 100644 --- a/frontend/src/stores/__tests__/themeStore.test.ts +++ b/frontend/src/stores/__tests__/themeStore.test.ts @@ -39,7 +39,7 @@ describe('themeStore', () => { it('setCustomStyle replaces the entire definition', () => { const def: CustomStyleDef = { nodes: { server: { borderColor: '#ff0000', borderOpacity: 1, bgColor: '#000000', bgOpacity: 1, iconColor: '#ff0000', iconOpacity: 1, width: 200, height: 80 } }, - edges: { ethernet: { color: '#00ff00', opacity: 0.8, pathStyle: 'bezier', animated: 'none' } }, + edges: { ethernet: { color: '#00ff00', opacity: 0.8, pathStyle: 'bezier', animated: 'none', arrowStart: false, arrowEnd: false } }, } useThemeStore.getState().setCustomStyle(def) expect(useThemeStore.getState().customStyle.nodes.server?.borderColor).toBe('#ff0000') diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index bdd0151..cc34be0 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -7,7 +7,6 @@ import { type Connection, applyNodeChanges, applyEdgeChanges, - addEdge, } from '@xyflow/react' import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, ServiceStatus, FloorMapConfig } from '@/types' import { generateUUID } from '@/utils/uuid' @@ -268,14 +267,22 @@ export const useCanvasStore = create((set) => ({ set((state) => { const extra = connection as Connection & Partial const edgeType = extra.type ?? 'ethernet' + // Build the edge with our own unique id and append directly instead of + // React Flow's addEdge(): addEdge silently drops any new edge whose + // source+target already match an existing edge when handles are null/equal + // (connectionExists dedupe). A homelab legitimately has multiple links + // between the same two devices, so we allow them. + const newEdge: Edge = { + id: `edge-${generateUUID()}`, + source: connection.source, + target: connection.target, + sourceHandle: normalizeHandle(extra.sourceHandle), + targetHandle: normalizeHandle(extra.targetHandle), + type: edgeType, + data: { type: edgeType, label: extra.label, vlan_id: extra.vlan_id, custom_color: extra.custom_color, path_style: extra.path_style, animated: extra.animated, marker_start: extra.marker_start, marker_end: extra.marker_end }, + } return { - edges: addEdge({ - ...connection, - sourceHandle: normalizeHandle(extra.sourceHandle), - targetHandle: normalizeHandle(extra.targetHandle), - type: edgeType, - data: { type: edgeType, label: extra.label, vlan_id: extra.vlan_id, custom_color: extra.custom_color, path_style: extra.path_style, animated: extra.animated }, - }, state.edges), + edges: [...state.edges, newEdge], hasUnsavedChanges: true, } }), @@ -816,6 +823,8 @@ export const useCanvasStore = create((set) => ({ custom_color: applyOpacity(style.color, style.opacity), path_style: style.pathStyle, animated: style.animated, + marker_start: style.arrowStart, + marker_end: style.arrowEnd, } as EdgeData, } }), @@ -854,6 +863,8 @@ export const useCanvasStore = create((set) => ({ custom_color: applyOpacity(style.color, style.opacity), path_style: style.pathStyle, animated: style.animated, + marker_start: style.arrowStart, + marker_end: style.arrowEnd, } as EdgeData, } }) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index cfcd833..5fd59f1 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -169,6 +169,10 @@ export interface EdgeData extends Record { custom_color?: string path_style?: EdgePathStyle animated?: boolean | 'snake' | 'flow' | 'basic' | 'none' + /** Filled arrowhead at the source end. */ + marker_start?: boolean + /** Filled arrowhead at the target end. */ + marker_end?: boolean waypoints?: Waypoint[] } @@ -257,6 +261,10 @@ export interface EdgeTypeStyle { opacity: number pathStyle: EdgePathStyle animated: 'none' | 'snake' | 'flow' | 'basic' + /** Default filled arrowhead at the source end for new edges of this type. */ + arrowStart: boolean + /** Default filled arrowhead at the target end for new edges of this type. */ + arrowEnd: boolean } export interface CustomStyleDef { diff --git a/frontend/src/utils/__tests__/canvasSerializer.test.ts b/frontend/src/utils/__tests__/canvasSerializer.test.ts index 4cd4fdd..c1162fc 100644 --- a/frontend/src/utils/__tests__/canvasSerializer.test.ts +++ b/frontend/src/utils/__tests__/canvasSerializer.test.ts @@ -243,6 +243,19 @@ describe('serializeEdge', () => { expect(result.animated).toBe(true) }) + it('serializes arrow markers', () => { + const edge = makeRfEdge({ data: { type: 'ethernet', marker_start: true, marker_end: true } }) + const result = serializeEdge(edge) + expect(result.marker_start).toBe(true) + expect(result.marker_end).toBe(true) + }) + + it('defaults arrow markers to false when absent', () => { + const result = serializeEdge(makeRfEdge()) + expect(result.marker_start).toBe(false) + expect(result.marker_end).toBe(false) + }) + it('nulls optional fields when absent', () => { const result = serializeEdge(makeRfEdge({ sourceHandle: undefined, targetHandle: undefined })) expect(result.source_handle).toBeNull() diff --git a/frontend/src/utils/__tests__/collapseFilter.test.ts b/frontend/src/utils/__tests__/collapseFilter.test.ts index aa2fe54..c5e8290 100644 --- a/frontend/src/utils/__tests__/collapseFilter.test.ts +++ b/frontend/src/utils/__tests__/collapseFilter.test.ts @@ -182,6 +182,17 @@ describe('rewireEdgesForCollapse', () => { expect(out[0]).toBe(edges[0]) }) + it('keeps multiple parallel edges between two visible nodes (no dedupe)', () => { + // Regression: parallel links between the same two visible devices must all + // render. The seen-key dedupe applies only to rewired collapse stubs. + const nodes = [mkNode('a'), mkNode('b', { position: FAR })] + const edges = [mkEdge('e1', 'a', 'b'), mkEdge('e2', 'a', 'b'), mkEdge('e3', 'a', 'b')] + const info = computeCollapseInfo(nodes) + const out = rewireEdgesForCollapse(edges, nodes, info.visibleIds, info.hiddenBy) + expect(out).toHaveLength(3) + expect(out.map((e) => e.id)).toEqual(['e1', 'e2', 'e3']) + }) + it('reroutes a cross-boundary edge to the collapsed parentId ancestor', () => { const nodes = [ mkNode('zone', { collapsed: true }), diff --git a/frontend/src/utils/canvasSerializer.ts b/frontend/src/utils/canvasSerializer.ts index 01942bd..5c53690 100644 --- a/frontend/src/utils/canvasSerializer.ts +++ b/frontend/src/utils/canvasSerializer.ts @@ -49,6 +49,8 @@ export interface ApiEdge { custom_color?: string | null path_style?: string | null animated?: boolean | 'snake' | 'flow' | 'basic' | 'none' + marker_start?: boolean | null + marker_end?: boolean | null source_handle?: string | null target_handle?: string | null waypoints?: Waypoint[] | null @@ -142,6 +144,8 @@ export function serializeEdge(e: Edge): Record { custom_color: e.data?.custom_color ?? null, path_style: e.data?.path_style ?? null, animated: e.data?.animated ?? false, + marker_start: e.data?.marker_start ?? false, + marker_end: e.data?.marker_end ?? false, source_handle: normalizeHandle(e.sourceHandle), target_handle: normalizeHandle(e.targetHandle), waypoints: e.data?.waypoints?.length ? e.data.waypoints : null, diff --git a/frontend/src/utils/collapseFilter.ts b/frontend/src/utils/collapseFilter.ts index ed66b5a..83357d0 100644 --- a/frontend/src/utils/collapseFilter.ts +++ b/frontend/src/utils/collapseFilter.ts @@ -184,11 +184,18 @@ export function rewireEdgesForCollapse( if (src === null || tgt === null) continue if (src === tgt) continue const key = `${src}->${tgt}` - if (seen.has(key)) continue - seen.add(key) if (src === e.source && tgt === e.target) { + // Real edge between two visible nodes — always keep. A homelab has + // multiple parallel links between the same two devices, so these must + // NOT be de-duplicated. Record the pair so redundant collapse stubs to + // the same pair are still suppressed. + seen.add(key) out.push(e) } else { + // Rewired collapse stub — de-duplicate parallel stubs to the same + // visible pair (prevents a 20-device mesh rendering 20 stacked stubs). + if (seen.has(key)) continue + seen.add(key) out.push({ ...e, source: src, target: tgt, sourceHandle: null, targetHandle: null }) } }