diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 6504004..c574b5e 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 TEXT NOT NULL DEFAULT 'none'") + with suppress(OperationalError): + await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN marker_end TEXT NOT NULL DEFAULT 'none'") 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..a81a805 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[str] = mapped_column(String, nullable=False, default='none') + marker_end: Mapped[str] = mapped_column(String, nullable=False, default='none') 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..6bbfd52 100644 --- a/backend/app/schemas/canvas.py +++ b/backend/app/schemas/canvas.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, field_validator from app.schemas.edges import EdgeResponse from app.schemas.nodes import NodeResponse -from app.schemas.utils import normalize_animated +from app.schemas.utils import normalize_animated, normalize_marker class NodeSave(BaseModel): @@ -52,6 +52,8 @@ class EdgeSave(BaseModel): custom_color: str | None = None path_style: str | None = None animated: str = 'none' + marker_start: str = 'none' + marker_end: str = 'none' source_handle: str | None = None target_handle: str | None = None waypoints: list[dict[str, float]] | None = None @@ -61,6 +63,11 @@ class EdgeSave(BaseModel): def validate_animated(cls, v: object) -> str: return normalize_animated(v) + @field_validator('marker_start', 'marker_end', mode='before') + @classmethod + def validate_marker(cls, v: object) -> str: + return normalize_marker(v) + class CanvasSaveRequest(BaseModel): nodes: list[NodeSave] = [] diff --git a/backend/app/schemas/edges.py b/backend/app/schemas/edges.py index 85964d8..6fe021e 100644 --- a/backend/app/schemas/edges.py +++ b/backend/app/schemas/edges.py @@ -2,7 +2,7 @@ from datetime import datetime from pydantic import BaseModel, field_validator -from app.schemas.utils import normalize_animated +from app.schemas.utils import normalize_animated, normalize_marker class EdgeBase(BaseModel): @@ -15,6 +15,8 @@ class EdgeBase(BaseModel): custom_color: str | None = None path_style: str | None = None animated: str = 'none' + marker_start: str = 'none' + marker_end: str = 'none' source_handle: str | None = None target_handle: str | None = None waypoints: list[dict[str, float]] | None = None @@ -24,6 +26,11 @@ class EdgeBase(BaseModel): def validate_animated(cls, v: object) -> str: return normalize_animated(v) + @field_validator('marker_start', 'marker_end', mode='before') + @classmethod + def validate_marker(cls, v: object) -> str: + return normalize_marker(v) + class EdgeCreate(EdgeBase): design_id: str | None = None @@ -37,6 +44,8 @@ class EdgeUpdate(BaseModel): custom_color: str | None = None path_style: str | None = None animated: str | None = None + marker_start: str | None = None + marker_end: str | None = None source_handle: str | None = None target_handle: str | None = None waypoints: list[dict[str, float]] | None = None @@ -48,6 +57,13 @@ class EdgeUpdate(BaseModel): return None return normalize_animated(v) + @field_validator('marker_start', 'marker_end', mode='before') + @classmethod + def validate_marker(cls, v: object) -> str | None: + if v is None: + return None + return normalize_marker(v) + class EdgeResponse(EdgeBase): id: str diff --git a/backend/app/schemas/utils.py b/backend/app/schemas/utils.py index 21e8770..6f9a924 100644 --- a/backend/app/schemas/utils.py +++ b/backend/app/schemas/utils.py @@ -7,3 +7,21 @@ def normalize_animated(v: object) -> str: if v in ('snake', 'flow', 'basic'): return str(v) return 'none' + + +MARKER_SHAPES = {'none', 'arrow', 'arrow-open', 'circle', 'diamond', 'square'} + + +def normalize_marker(v: object) -> str: + """Normalize an edge endpoint marker to a shape string. + + Legacy saves stored a boolean (True = filled arrow); coerce those and any + unknown value to a valid MarkerShape ('none' when off/unknown). + """ + if v is True or v == 1 or v == '1': + return 'arrow' + if v is False or v == 0 or v == '0' or v is None: + return 'none' + if isinstance(v, str) and v in MARKER_SHAPES: + return v + return 'none' diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index a41e3b4..9e320e5 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -51,6 +51,39 @@ 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_marker_shapes(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="diamond", marker_end="arrow") + 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"] == "diamond" + assert edge["marker_end"] == "arrow" + + +async def test_save_canvas_coerces_legacy_boolean_marker(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=False) + 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"] == "arrow" + assert edge["marker_end"] == "none" + + +async def test_save_canvas_defaults_markers_none(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"] == "none" + assert edge["marker_end"] == "none" + + 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..d60a6ab 100644 --- a/backend/tests/test_edges.py +++ b/backend/tests/test_edges.py @@ -91,6 +91,45 @@ 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_marker_shapes(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": "diamond", "marker_end": "arrow"}, headers=headers) + assert res.status_code == 201 + assert res.json()["marker_start"] == "diamond" + assert res.json()["marker_end"] == "arrow" + + +async def test_create_edge_defaults_markers_none(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"] == "none" + assert res.json()["marker_end"] == "none" + + +async def test_create_edge_coerces_legacy_boolean_marker(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_end": True}, headers=headers) + assert res.status_code == 201 + assert res.json()["marker_end"] == "arrow" + + +async def test_create_edge_rejects_unknown_marker_shape(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_end": "bogus"}, headers=headers) + assert res.status_code == 201 + assert res.json()["marker_end"] == "none" + + +async def test_update_edge_marker_shape(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": "circle"}, headers=headers) + assert res.status_code == 200 + assert res.json()["marker_end"] == "circle" + assert res.json()["marker_start"] == "none" + + 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..67e9916 --- /dev/null +++ b/frontend/src/components/canvas/edges/__tests__/HomelableEdge.markers.test.tsx @@ -0,0 +1,110 @@ +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' + +/** + * Endpoint markers: per-end shape (arrow / arrow-open / circle / diamond / + * square) defs, independently selectable at start/end, filled with the + * live stroke color, referenced by BaseEdge via markerStart/markerEnd URLs. + * Legacy boolean values coerce to the filled 'arrow' shape. + */ +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: 'arrow' }) + 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('coerces a legacy boolean marker to the filled arrow shape', () => { + const { container } = renderEdge({ marker_end: true }) + const path = container.querySelector('#arrow-end-e1 path') + expect(path?.getAttribute('d')).toBe('M 0 0 L 10 5 L 0 10 z') + }) + + it('renders a directional start marker with reversed orientation', () => { + const { container } = renderEdge({ marker_start: 'arrow' }) + const marker = container.querySelector('#arrow-start-e1') + expect(marker).toBeTruthy() + expect(marker?.getAttribute('orient')).toBe('auto-start-reverse') + }) + + it('renders a circle marker as a , not a triangle', () => { + const { container } = renderEdge({ marker_end: 'circle' }) + expect(container.querySelector('#arrow-end-e1 circle')).toBeTruthy() + expect(container.querySelector('#arrow-end-e1 path')).toBeNull() + }) + + it('renders a square marker as a ', () => { + const { container } = renderEdge({ marker_end: 'square' }) + expect(container.querySelector('#arrow-end-e1 rect')).toBeTruthy() + }) + + it('uses fixed orientation for symmetric shapes', () => { + const { container } = renderEdge({ marker_end: 'circle' }) + expect(container.querySelector('#arrow-end-e1')?.getAttribute('orient')).toBe('0') + }) + + it('supports different shapes on each end', () => { + const { container } = renderEdge({ marker_start: 'diamond', marker_end: 'arrow-open' }) + // diamond is a filled path + const startPath = container.querySelector('#arrow-start-e1 path') + expect(startPath?.getAttribute('d')).toContain('9.5') + expect(startPath?.getAttribute('fill')).not.toBe('none') + // arrow-open is stroked, not filled + expect(container.querySelector('#arrow-end-e1 path')?.getAttribute('fill')).toBe('none') + }) + + it('renders both markers when both ends enabled', () => { + const { container } = renderEdge({ marker_start: 'arrow', marker_end: 'arrow' }) + expect(container.querySelector('#arrow-start-e1')).toBeTruthy() + expect(container.querySelector('#arrow-end-e1')).toBeTruthy() + }) + + it('renders no marker for the "none" shape', () => { + const { container } = renderEdge({ marker_start: 'none', marker_end: 'none' }) + expect(container.querySelector('marker')).toBeNull() + }) + + it('fills the marker with the resolved custom color', () => { + const { container } = renderEdge({ marker_end: 'arrow', 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..9cb1f22 100644 --- a/frontend/src/components/canvas/edges/index.tsx +++ b/frontend/src/components/canvas/edges/index.tsx @@ -13,6 +13,7 @@ import type { EdgeData, EdgeType, Waypoint } from '@/types' import { useThemeStore } from '@/stores/themeStore' import { useCanvasStore } from '@/stores/canvasStore' import { THEMES } from '@/utils/themes' +import { MARKER_GEOMETRY, normalizeMarker, type NonNoneMarkerShape } from '@/utils/edgeMarkers' import { buildWaypointPath, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from './waypointUtils' const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149'] @@ -22,6 +23,22 @@ function getVlanColor(vlanId?: number): string { return VLAN_COLORS[vlanId % VLAN_COLORS.length] } +/** Inner SVG element for an edge , drawn in a 0..10 viewBox. */ +function markerInnerElement(shape: NonNoneMarkerShape, color: string): React.ReactElement { + switch (shape) { + case 'arrow': + return + case 'arrow-open': + return + case 'circle': + return + case 'diamond': + return + case 'square': + return + } +} + // ── Waypoint drag handle ───────────────────────────────────────────────────── interface WaypointHandleProps { @@ -351,9 +368,54 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle ? segmentMidpoints(sourceX, sourceY, waypoints, targetX, targetY, pathStyle, sourcePosition) : [] + // ── Endpoint markers ─────────────────────────────────────────────────────── + // Custom inline defs filled with the live strokeColor so they recolor + // reactively (custom_color / vlan / selected). Sized from the stroke width. + // Each end picks its own shape (arrow / arrow-open / circle / diamond / square) + // independently; 'none' renders no marker. + const startShape = normalizeMarker(data?.marker_start) + const endShape = normalizeMarker(data?.marker_end) + const hasMarkers = startShape !== 'none' || endShape !== 'none' + 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, shape: NonNoneMarkerShape, orient: string) => { + const geo = MARKER_GEOMETRY[shape] + return ( + + {markerInnerElement(shape, strokeColor)} + + ) + } + return ( <> - + {hasMarkers && ( + + {startShape !== 'none' && arrowMarker(startMarkerId, startShape, 'auto-start-reverse')} + {endShape !== 'none' && arrowMarker(endMarkerId, endShape, 'auto')} + + )} + + {animMode === 'basic' && ( Snake + +
+
Endpoints
+
+ set('arrowStart', s)} /> + set('arrowEnd', s)} /> +
+
+ ) + })} + + + ) +} diff --git a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx index a64cbff..6d27d64 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 marker pickers defaulting to none', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Edges' })) + fireEvent.click(screen.getByRole('button', { name: /Ethernet/ })) + const startNone = screen.getByRole('button', { name: 'Start marker none' }) + const endNone = screen.getByRole('button', { name: 'End marker none' }) + expect(startNone.getAttribute('aria-pressed')).toBe('true') + expect(endNone.getAttribute('aria-pressed')).toBe('true') + }) + + it('picking an End shape 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 marker diamond' })) + fireEvent.click(screen.getByRole('button', { name: /Apply to existing Ethernet/ })) + expect(applyTypeEdgeStyle.mock.calls[0][1].arrowEnd).toBe('diamond') + expect(applyTypeEdgeStyle.mock.calls[0][1].arrowStart).toBe('none') + }) + 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..ea32cde 100644 --- a/frontend/src/components/modals/__tests__/EdgeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/EdgeModal.test.tsx @@ -165,6 +165,58 @@ describe('EdgeModal', () => { expect(onSubmit.mock.calls[0][0].animated).toBe('basic') }) + // ── Endpoint markers ────────────────────────────────────────────────────── + + it('endpoints default to none', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].marker_start).toBe('none') + expect(onSubmit.mock.calls[0][0].marker_end).toBe('none') + }) + + it('picking an End arrow sends marker_end: "arrow"', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'End marker arrow' })) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].marker_end).toBe('arrow') + expect(onSubmit.mock.calls[0][0].marker_start).toBe('none') + }) + + it('picking a Start circle sends marker_start: "circle"', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Start marker circle' })) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].marker_start).toBe('circle') + }) + + it('allows a different shape on each end', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Start marker diamond' })) + fireEvent.click(screen.getByRole('button', { name: 'End marker square' })) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].marker_start).toBe('diamond') + expect(onSubmit.mock.calls[0][0].marker_end).toBe('square') + }) + + it('pre-fills endpoint shapes from initial', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].marker_start).toBe('diamond') + expect(onSubmit.mock.calls[0][0].marker_end).toBe('arrow-open') + }) + + it('coerces a legacy boolean initial marker to "arrow"', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + expect(onSubmit.mock.calls[0][0].marker_end).toBe('arrow') + }) + 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..6e87063 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 endpoint marker shapes from edge data', () => { + const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }, { type: 'ethernet', marker_start: 'diamond', marker_end: 'arrow' }) + useCanvasStore.getState().onConnect(conn) + const { edges } = useCanvasStore.getState() + expect(edges[0].data?.marker_start).toBe('diamond') + expect(edges[0].data?.marker_end).toBe('arrow') + }) + 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: 'circle', arrowEnd: 'arrow' }) 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('circle') + expect(updated1.data?.marker_end).toBe('arrow') 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: 'none', arrowEnd: 'square' }, }, }) @@ -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('square') + expect(e.data?.marker_start).toBe('none') 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..70c5d8c 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: 'none', arrowEnd: 'none' } }, } 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..9fdddcf 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -156,6 +156,13 @@ export interface NodeData extends Record { export type EdgePathStyle = 'bezier' | 'smooth' +/** + * Endpoint marker shape for an edge end. `none` = no marker. + * Legacy saves stored a boolean (`true` = filled arrow) — coerced via + * `normalizeMarker` in utils/edgeMarkers. + */ +export type MarkerShape = 'none' | 'arrow' | 'arrow-open' | 'circle' | 'diamond' | 'square' + export interface Waypoint { x: number y: number @@ -169,6 +176,10 @@ export interface EdgeData extends Record { custom_color?: string path_style?: EdgePathStyle animated?: boolean | 'snake' | 'flow' | 'basic' | 'none' + /** Marker shape at the source end. Legacy boolean (`true`=arrow) coerced on read. */ + marker_start?: MarkerShape | boolean + /** Marker shape at the target end. Legacy boolean (`true`=arrow) coerced on read. */ + marker_end?: MarkerShape | boolean waypoints?: Waypoint[] } @@ -257,6 +268,10 @@ export interface EdgeTypeStyle { opacity: number pathStyle: EdgePathStyle animated: 'none' | 'snake' | 'flow' | 'basic' + /** Default marker shape at the source end for new edges of this type. */ + arrowStart: MarkerShape + /** Default marker shape at the target end for new edges of this type. */ + arrowEnd: MarkerShape } export interface CustomStyleDef { diff --git a/frontend/src/utils/__tests__/canvasSerializer.test.ts b/frontend/src/utils/__tests__/canvasSerializer.test.ts index 4cd4fdd..dc88d48 100644 --- a/frontend/src/utils/__tests__/canvasSerializer.test.ts +++ b/frontend/src/utils/__tests__/canvasSerializer.test.ts @@ -243,6 +243,26 @@ describe('serializeEdge', () => { expect(result.animated).toBe(true) }) + it('serializes endpoint marker shapes', () => { + const edge = makeRfEdge({ data: { type: 'ethernet', marker_start: 'diamond', marker_end: 'arrow' } }) + const result = serializeEdge(edge) + expect(result.marker_start).toBe('diamond') + expect(result.marker_end).toBe('arrow') + }) + + it('coerces legacy boolean markers to shape strings', () => { + const edge = makeRfEdge({ data: { type: 'ethernet', marker_start: true, marker_end: false } }) + const result = serializeEdge(edge) + expect(result.marker_start).toBe('arrow') + expect(result.marker_end).toBe('none') + }) + + it('defaults endpoint markers to "none" when absent', () => { + const result = serializeEdge(makeRfEdge()) + expect(result.marker_start).toBe('none') + expect(result.marker_end).toBe('none') + }) + 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..86bc16c 100644 --- a/frontend/src/utils/canvasSerializer.ts +++ b/frontend/src/utils/canvasSerializer.ts @@ -1,6 +1,7 @@ import type { Node, Edge } from '@xyflow/react' import type { NodeData, EdgeData, Waypoint } from '@/types' import { normalizeHandle, clampHandles, handleId, handleCountField, type Side } from '@/utils/handleUtils' +import { normalizeMarker } from '@/utils/edgeMarkers' // ── Types ──────────────────────────────────────────────────────────────────── @@ -49,6 +50,8 @@ export interface ApiEdge { custom_color?: string | null path_style?: string | null animated?: boolean | 'snake' | 'flow' | 'basic' | 'none' + marker_start?: string | boolean | null + marker_end?: string | boolean | null source_handle?: string | null target_handle?: string | null waypoints?: Waypoint[] | null @@ -142,6 +145,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: normalizeMarker(e.data?.marker_start), + marker_end: normalizeMarker(e.data?.marker_end), 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 }) } } diff --git a/frontend/src/utils/edgeMarkers.ts b/frontend/src/utils/edgeMarkers.ts new file mode 100644 index 0000000..736b4cf --- /dev/null +++ b/frontend/src/utils/edgeMarkers.ts @@ -0,0 +1,42 @@ +import type { MarkerShape } from '@/types' + +/** All selectable marker shapes, in picker order. */ +export const MARKER_SHAPES: MarkerShape[] = [ + 'none', 'arrow', 'arrow-open', 'circle', 'diamond', 'square', +] + +const SHAPE_SET = new Set(MARKER_SHAPES) + +/** + * Coerce any stored/legacy marker value into a MarkerShape. + * - legacy boolean `true` → 'arrow' + * - legacy boolean `false` / null / undefined → 'none' + * - a valid shape string passes through + * - anything unknown → 'none' + */ +export function normalizeMarker(v: unknown): MarkerShape { + if (v === true) return 'arrow' + if (v === false || v == null) return 'none' + if (typeof v === 'string' && SHAPE_SET.has(v)) return v as MarkerShape + return 'none' +} + +export type NonNoneMarkerShape = Exclude + +/** + * SVG geometry per shape, drawn in a 0..10 viewBox. + * - `refX` positions the shape on the endpoint: directional shapes (arrow, + * arrow-open) put their tip on the point; symmetric caps (circle, diamond, + * square) centre on it. + * - `directional` shapes rotate with the edge; symmetric ones don't care. + */ +export const MARKER_GEOMETRY: Record< + NonNoneMarkerShape, + { refX: number; directional: boolean } +> = { + arrow: { refX: 9, directional: true }, + 'arrow-open': { refX: 8.5, directional: true }, + circle: { refX: 5, directional: false }, + diamond: { refX: 5, directional: false }, + square: { refX: 5, directional: false }, +}