From 75c7f25a30b7b4a9ebe7e98b5adb19f4155fe706 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Wed, 8 Apr 2026 12:22:39 +0200 Subject: [PATCH 01/14] feat: display app version in sidebar with GitHub release check --- backend/data/homelab.db.back | Bin 0 -> 106496 bytes frontend/src/components/panels/Sidebar.tsx | 31 +++++ .../panels/__tests__/VersionBadge.test.tsx | 121 ++++++++++++++++++ .../hooks/__tests__/useLatestRelease.test.ts | 105 +++++++++++++++ frontend/src/hooks/useLatestRelease.ts | 42 ++++++ frontend/src/vite-env.d.ts | 3 + frontend/vite.config.ts | 4 + 7 files changed, 306 insertions(+) create mode 100644 backend/data/homelab.db.back create mode 100644 frontend/src/components/panels/__tests__/VersionBadge.test.tsx create mode 100644 frontend/src/hooks/__tests__/useLatestRelease.test.ts create mode 100644 frontend/src/hooks/useLatestRelease.ts create mode 100644 frontend/src/vite-env.d.ts diff --git a/backend/data/homelab.db.back b/backend/data/homelab.db.back new file mode 100644 index 0000000000000000000000000000000000000000..9d2fd3f6d0348b2fd6737a1f8201ca070b25d2b7 GIT binary patch literal 106496 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 )} + + {!collapsed && } ) } @@ -548,6 +551,34 @@ function SettingsPanel() { ) } +function VersionBadge() { + const current = __APP_VERSION__ + const { latest, hasUpdate } = useLatestRelease(current) + + return ( +
+ + v{current} + + {hasUpdate && latest && ( + + ↑ v{latest.version} available + + )} +
+ ) +} + const MAC_OUI: Record = { '52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' }, 'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' }, diff --git a/frontend/src/components/panels/__tests__/VersionBadge.test.tsx b/frontend/src/components/panels/__tests__/VersionBadge.test.tsx new file mode 100644 index 0000000..7aad9de --- /dev/null +++ b/frontend/src/components/panels/__tests__/VersionBadge.test.tsx @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { Sidebar } from '../Sidebar' +import { useCanvasStore } from '@/stores/canvasStore' + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock('@/stores/canvasStore') + +vi.mock('@/api/client', () => ({ + scanApi: { + trigger: vi.fn().mockResolvedValue({}), + pending: vi.fn().mockResolvedValue({ data: [] }), + hidden: vi.fn().mockResolvedValue({ data: [] }), + runs: vi.fn().mockResolvedValue({ data: [] }), + stop: vi.fn().mockResolvedValue({}), + getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }), + }, + settingsApi: { + get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }), + save: vi.fn().mockResolvedValue({}), + }, +})) + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +vi.mock('@/components/ui/Logo', () => ({ Logo: () => null })) +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: () => null, +})) +vi.mock('@/components/modals/PendingDeviceModal', () => ({ PendingDeviceModal: () => null })) +vi.mock('@/components/modals/StatusTimelineModal', () => ({ StatusTimelineModal: () => null })) + +vi.mock('@/hooks/useLatestRelease', () => ({ + useLatestRelease: vi.fn(), +})) + +import { useLatestRelease } from '@/hooks/useLatestRelease' + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function renderSidebar() { + vi.mocked(useCanvasStore).mockReturnValue({ + nodes: [], + hasUnsavedChanges: false, + hideIp: false, + toggleHideIp: vi.fn(), + addNode: vi.fn(), + scanEventTs: 0, + } as unknown as ReturnType) + + return render( + , + ) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('VersionBadge', () => { + beforeEach(() => { + vi.mocked(useLatestRelease).mockReturnValue({ latest: null, hasUpdate: false }) + }) + + it('displays the current app version', () => { + renderSidebar() + expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument() + }) + + it('links current version to its GitHub release page', () => { + renderSidebar() + const link = screen.getByText(`v${__APP_VERSION__}`).closest('a') + expect(link).toHaveAttribute( + 'href', + `https://github.com/Pouzor/homelable/releases/tag/v${__APP_VERSION__}`, + ) + expect(link).toHaveAttribute('target', '_blank') + }) + + it('does not show update badge when on latest version', () => { + renderSidebar() + expect(screen.queryByText(/available/)).not.toBeInTheDocument() + }) + + it('shows update badge when a newer version is available', async () => { + vi.mocked(useLatestRelease).mockReturnValue({ + latest: { version: '9.9.9', url: 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9' }, + hasUpdate: true, + }) + renderSidebar() + await waitFor(() => expect(screen.getByText('↑ v9.9.9 available')).toBeInTheDocument()) + }) + + it('update badge links to the latest release URL', async () => { + vi.mocked(useLatestRelease).mockReturnValue({ + latest: { version: '9.9.9', url: 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9' }, + hasUpdate: true, + }) + renderSidebar() + await waitFor(() => { + const badge = screen.getByText('↑ v9.9.9 available').closest('a') + expect(badge).toHaveAttribute('href', 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9') + expect(badge).toHaveAttribute('target', '_blank') + }) + }) + + it('does not show update badge when hasUpdate is false even if latest exists', () => { + vi.mocked(useLatestRelease).mockReturnValue({ + latest: { version: __APP_VERSION__, url: 'https://github.com' }, + hasUpdate: false, + }) + renderSidebar() + expect(screen.queryByText(/available/)).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/hooks/__tests__/useLatestRelease.test.ts b/frontend/src/hooks/__tests__/useLatestRelease.test.ts new file mode 100644 index 0000000..54092bc --- /dev/null +++ b/frontend/src/hooks/__tests__/useLatestRelease.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' + +// Reset module between tests so the module-level cache is cleared +async function freshHook() { + vi.resetModules() + const mod = await import('../useLatestRelease') + return mod.useLatestRelease +} + +const CURRENT = '1.8.3' + +function mockFetch(payload: unknown, ok = true) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok, + json: () => Promise.resolve(payload), + }), + ) +} + +describe('useLatestRelease', () => { + beforeEach(() => { + vi.unstubAllGlobals() + }) + + it('returns no update when latest version matches current', async () => { + mockFetch({ tag_name: 'v1.8.3', html_url: 'https://github.com/Pouzor/homelable/releases/tag/v1.8.3' }) + const useLatestRelease = await freshHook() + const { result } = renderHook(() => useLatestRelease(CURRENT)) + await waitFor(() => expect(result.current.latest).not.toBeNull()) + expect(result.current.hasUpdate).toBe(false) + }) + + it('returns update when latest version is newer', async () => { + mockFetch({ tag_name: 'v1.9.0', html_url: 'https://github.com/Pouzor/homelable/releases/tag/v1.9.0' }) + const useLatestRelease = await freshHook() + const { result } = renderHook(() => useLatestRelease(CURRENT)) + await waitFor(() => expect(result.current.hasUpdate).toBe(true)) + expect(result.current.latest?.version).toBe('1.9.0') + expect(result.current.latest?.url).toBe('https://github.com/Pouzor/homelable/releases/tag/v1.9.0') + }) + + it('strips leading v from tag_name', async () => { + mockFetch({ tag_name: 'v2.0.0', html_url: 'https://github.com/example' }) + const useLatestRelease = await freshHook() + const { result } = renderHook(() => useLatestRelease(CURRENT)) + await waitFor(() => expect(result.current.latest).not.toBeNull()) + expect(result.current.latest?.version).toBe('2.0.0') + }) + + it('does not show update when API returns non-ok response', async () => { + mockFetch({ message: 'Not Found' }, false) + const useLatestRelease = await freshHook() + const { result } = renderHook(() => useLatestRelease(CURRENT)) + await new Promise((r) => setTimeout(r, 50)) + expect(result.current.hasUpdate).toBe(false) + expect(result.current.latest).toBeNull() + }) + + it('does not show update when API returns missing tag_name', async () => { + mockFetch({ html_url: 'https://github.com/example' }) + const useLatestRelease = await freshHook() + const { result } = renderHook(() => useLatestRelease(CURRENT)) + await new Promise((r) => setTimeout(r, 50)) + expect(result.current.hasUpdate).toBe(false) + }) + + it('does not show update when API returns missing html_url', async () => { + mockFetch({ tag_name: 'v2.0.0' }) + const useLatestRelease = await freshHook() + const { result } = renderHook(() => useLatestRelease(CURRENT)) + await new Promise((r) => setTimeout(r, 50)) + expect(result.current.hasUpdate).toBe(false) + }) + + it('does not show update when fetch throws', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error'))) + const useLatestRelease = await freshHook() + const { result } = renderHook(() => useLatestRelease(CURRENT)) + await new Promise((r) => setTimeout(r, 50)) + expect(result.current.hasUpdate).toBe(false) + }) + + it('fetches only once when hook is mounted multiple times concurrently', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ tag_name: 'v1.8.3', html_url: 'https://github.com' }), + }) + vi.stubGlobal('fetch', fetchMock) + const useLatestRelease = await freshHook() + // Mount all three before the fetch resolves — cache is set to 'pending' after first mount + const a = renderHook(() => useLatestRelease(CURRENT)) + const b = renderHook(() => useLatestRelease(CURRENT)) + const c = renderHook(() => useLatestRelease(CURRENT)) + await waitFor(() => { + expect(a.result.current.latest).not.toBeNull() + }) + expect(fetchMock).toHaveBeenCalledTimes(1) + // All hooks see the same result once cache resolves + expect(b.result.current.hasUpdate).toBe(false) + expect(c.result.current.hasUpdate).toBe(false) + }) +}) diff --git a/frontend/src/hooks/useLatestRelease.ts b/frontend/src/hooks/useLatestRelease.ts new file mode 100644 index 0000000..443d379 --- /dev/null +++ b/frontend/src/hooks/useLatestRelease.ts @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react' + +interface ReleaseInfo { + version: string + url: string +} + +let cache: ReleaseInfo | null | 'error' | 'pending' = null + +export function useLatestRelease(currentVersion: string) { + const [latest, setLatest] = useState( + cache && cache !== 'error' && cache !== 'pending' ? cache : null, + ) + + useEffect(() => { + if (cache !== null) return + cache = 'pending' + fetch('https://api.github.com/repos/Pouzor/homelable/releases/latest', { + headers: { Accept: 'application/vnd.github+json' }, + }) + .then((res) => { + if (!res.ok) { cache = 'error'; return } + return res.json() + }) + .then((data) => { + if (!data || typeof data.tag_name !== 'string' || !data.html_url) { + cache = 'error' + return + } + const version = data.tag_name.replace(/^v/, '') + const info: ReleaseInfo = { version, url: data.html_url } + cache = info + setLatest(info) + }) + .catch(() => { + cache = 'error' + }) + }, []) + + const hasUpdate = latest !== null && latest.version !== currentVersion + return { latest, hasUpdate } +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..54eaa07 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,3 @@ +/// + +declare const __APP_VERSION__: string diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index db648cc..c7f1f73 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,8 +2,12 @@ import path from 'path' import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +import pkg from './package.json' export default defineConfig({ + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + }, plugins: [react(), tailwindcss()], resolve: { alias: { From b0df8f389ad10b6008ca7e27d31be716738259ab Mon Sep 17 00:00:00 2001 From: Pouzor Date: Wed, 8 Apr 2026 13:58:27 +0200 Subject: [PATCH 02/14] fix: align dot grid to snap grid and fix node selection layout shift --- frontend/src/components/canvas/CanvasContainer.tsx | 2 +- frontend/src/components/canvas/nodes/BaseNode.tsx | 10 ++++++---- frontend/src/components/canvas/nodes/GroupRectNode.tsx | 5 +++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index 5c80571..3847dc6 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -98,7 +98,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o > diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index 9519846..34aada0 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -39,11 +39,13 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: style={{ background: colors.background, borderColor: colors.border, - borderWidth: selected ? 2 : 1, - boxShadow: isOnline + borderWidth: 1, + boxShadow: isOnline && selected + ? `0 0 0 1px ${colors.border}, 0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a` + : isOnline ? `0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a` : selected - ? `0 0 8px ${colors.border}44` + ? `0 0 0 1px ${colors.border}, 0 0 8px ${colors.border}44` : 'none', opacity: data.status === 'offline' ? 0.55 : 1, minWidth: 140, @@ -55,7 +57,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: isVisible={selected} minWidth={140} minHeight={50} - lineStyle={{ borderColor: colors.border, borderWidth: 1 }} + lineStyle={{ borderColor: 'transparent' }} handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8 }} /> >) background: '#00d4ff', border: '1px solid #0d1117', }} - lineStyle={{ borderColor: '#00d4ff55', borderWidth: 1 }} + lineStyle={{ borderColor: 'transparent' }} />
>) justifyContent: posStyle.justifyContent, padding: 12, background: backgroundColor, - border: `${selected ? borderWidth + 1 : borderWidth}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`, + border: `${borderWidth}px ${borderStyle} ${borderColor}`, + boxShadow: selected ? '0 0 0 1px #00d4ff, 0 0 8px #00d4ff44' : 'none', borderRadius: 10, boxSizing: 'border-box', cursor: 'default', From 9e8bab5deccced120a200109bb9fe20651e0b393 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Wed, 8 Apr 2026 22:42:18 +0200 Subject: [PATCH 03/14] feat: add interactive edge waypoints with smooth path editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drag waypoints to reshape edges; double-click a waypoint to remove it - + handles at segment midpoints to insert new waypoints - Bezier style: catmull-rom smooth curves through waypoints - Smooth style: rounded-corner polyline with soft 45° snap (snaps within 15px) - First + handle biased to source axis for perpendicular node exit - snap45both: ray-intersection solver ensures both adjacent segments snap to 45° simultaneously - Clear path button in EdgeModal when waypoints exist - Waypoints serialised/deserialised with canvas state --- frontend/src/App.tsx | 8 + .../edges/__tests__/waypointUtils.test.ts | 175 +++++++++++++ .../src/components/canvas/edges/index.tsx | 244 +++++++++++++++++- .../components/canvas/edges/waypointUtils.ts | 167 ++++++++++++ frontend/src/components/modals/EdgeModal.tsx | 13 +- .../modals/__tests__/EdgeModal.test.tsx | 51 ++++ frontend/src/types/index.ts | 6 + .../utils/__tests__/canvasSerializer.test.ts | 30 +++ frontend/src/utils/canvasSerializer.ts | 4 +- 9 files changed, 687 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts create mode 100644 frontend/src/components/canvas/edges/waypointUtils.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 54dbfb5..a36b9d0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -357,6 +357,13 @@ export default function App() { setEditEdgeId(null) }, [editEdgeId, deleteEdge, snapshotHistory]) + const handleClearWaypoints = useCallback(() => { + if (!editEdgeId) return + snapshotHistory() + updateEdge(editEdgeId, { waypoints: [] }) + setEditEdgeId(null) + }, [editEdgeId, updateEdge, snapshotHistory]) + const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null @@ -446,6 +453,7 @@ export default function App() { onClose={() => setEditEdgeId(null)} onSubmit={handleEdgeUpdate} onDelete={handleEdgeDelete} + onClearWaypoints={handleClearWaypoints} initial={editEdge?.data} title="Edit Link" /> diff --git a/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts b/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts new file mode 100644 index 0000000..a42a943 --- /dev/null +++ b/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect } from 'vitest' +import { buildWaypointPath, distToSegment, findInsertIndex, snap45, snap45both } from '../waypointUtils' + +describe('buildWaypointPath — bezier (default)', () => { + it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => { + // With only 2 pts (src + target), catmull-rom = cubic bezier + const path = buildWaypointPath(0, 0, [], 100, 100) + expect(path).toMatch(/^M 0 0 C/) + }) + + it('routes through a single waypoint with smooth curve', () => { + const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }], 100, 100) + expect(path).toMatch(/^M 0 0 C/) + // Should not be a straight polyline + expect(path).not.toContain(' L ') + }) + + it('routes through multiple waypoints', () => { + const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100) + expect(path).toMatch(/^M 0 0 C/) + }) +}) + +describe('buildWaypointPath — smooth style', () => { + it('builds a direct straight line with no waypoints (no bend)', () => { + // Only 2 points → no intermediate vertex → no rounding needed + expect(buildWaypointPath(0, 0, [], 100, 100, 'smooth')).toBe('M 0 0 L 100 100') + }) + + it('routes through a single waypoint with straight lines (no intermediate bend)', () => { + // 3 pts: src → wp → target — only 1 intermediate → rounded corners at wp + const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }], 100, 100, 'smooth') + // Should start at source and end at target + expect(path).toMatch(/^M 0 0/) + expect(path).toMatch(/100 100$/) + // Should contain a quadratic bezier at the waypoint corner + expect(path).toContain('Q') + }) + + it('routes through multiple waypoints with rounded corners', () => { + const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 'smooth') + expect(path).toMatch(/^M 0 0/) + expect(path).toMatch(/100 100$/) + expect(path).toContain('Q') + }) + + it('does not round corners when segment is too short (r clamped to 0)', () => { + // Adjacent waypoints very close together — r → 0, falls back to L + const path = buildWaypointPath(0, 0, [{ x: 1, y: 0 }, { x: 2, y: 0 }], 100, 0, 'smooth') + expect(path).toMatch(/^M 0 0/) + }) +}) + +describe('snap45', () => { + // Use positions very close to a 45° angle so deviation < SNAP_THRESHOLD (15px) + it('snaps horizontal direction when close (deviation < threshold)', () => { + // (100, 3) — nearly horizontal, deviation from 0° ≈ 3px → snaps + const r = snap45({ x: 0, y: 0 }, { x: 100, y: 3 }) + expect(r.y).toBe(0) + expect(r.x).toBeGreaterThan(0) + }) + + it('snaps vertical direction when close', () => { + const r = snap45({ x: 0, y: 0 }, { x: 3, y: 100 }) + expect(r.x).toBe(0) + expect(r.y).toBeGreaterThan(0) + }) + + it('snaps 45° diagonal when close', () => { + // (80, 83) — nearly 45°, deviation ≈ 2px → snaps + const r = snap45({ x: 0, y: 0 }, { x: 80, y: 83 }) + expect(r.x).toBe(r.y) + }) + + it('does NOT snap when deviation exceeds threshold', () => { + // (100, 40) — deviation from 0° is ~40px > 15 → no snap + const pos = { x: 100, y: 40 } + const r = snap45({ x: 0, y: 0 }, pos) + expect(r).toEqual(pos) + }) + + it('returns pos unchanged when distance < 1', () => { + const pos = { x: 5, y: 5 } + expect(snap45({ x: 5, y: 5 }, pos)).toBe(pos) + }) + + it('preserves distance from origin when snapping', () => { + const from = { x: 0, y: 0 } + const pos = { x: 100, y: 3 } // close to horizontal + const r = snap45(from, pos) + const origDist = Math.hypot(pos.x - from.x, pos.y - from.y) + const snapDist = Math.hypot(r.x - from.x, r.y - from.y) + expect(snapDist).toBeCloseTo(origDist, 0) + }) +}) + +describe('snap45both', () => { + it('finds intersection satisfying 45° from both adjacent points (axis-aligned)', () => { + // prev=(0,0), next=(100,100): diagonal — midpoint (50,50) should satisfy both + const r = snap45both({ x: 0, y: 0 }, { x: 100, y: 100 }, { x: 50, y: 50 }) + // Result must be on a 45°-ray from (0,0) + const a1 = Math.atan2(r.y - 0, r.x - 0) / (Math.PI / 4) + expect(Math.abs(a1 - Math.round(a1))).toBeLessThan(0.05) + // Result must be on a 45°-ray from (100,100) + const a2 = Math.atan2(r.y - 100, r.x - 100) / (Math.PI / 4) + expect(Math.abs(a2 - Math.round(a2))).toBeLessThan(0.05) + }) + + it('snaps so both incoming and outgoing segments are at 45° when within threshold', () => { + // prev=(0,0), next=(200,0) — valid intersection at (100,100) (45° from each) + // pos=(100,93) is 7px away → within 15px threshold → should snap to (100,100) + const r = snap45both({ x: 0, y: 0 }, { x: 200, y: 0 }, { x: 100, y: 93 }) + const a1 = Math.atan2(r.y - 0, r.x - 0) / (Math.PI / 4) + expect(Math.abs(a1 - Math.round(a1))).toBeLessThan(0.05) + const a2 = Math.atan2(r.y - 0, r.x - 200) / (Math.PI / 4) + expect(Math.abs(a2 - Math.round(a2))).toBeLessThan(0.05) + }) + + it('returns raw pos when beyond threshold', () => { + // pos=(100,80) is 20px from nearest intersection (100,100) → no snap + const pos = { x: 100, y: 80 } + const r = snap45both({ x: 0, y: 0 }, { x: 200, y: 0 }, pos) + expect(r).toEqual(pos) + }) + + it('falls back gracefully when prev === next', () => { + // No valid intersection → fallback to snap45 + const r = snap45both({ x: 50, y: 50 }, { x: 50, y: 50 }, { x: 100, y: 90 }) + expect(r).toBeDefined() + }) +}) + +describe('distToSegment', () => { + it('returns 0 when point is on the segment', () => { + expect(distToSegment({ x: 50, y: 0 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(0) + }) + + it('returns perpendicular distance when point is beside segment', () => { + expect(distToSegment({ x: 50, y: 10 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(10) + }) + + it('returns distance to nearest endpoint when point is past the segment', () => { + expect(distToSegment({ x: 200, y: 0 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(100) + }) + + it('handles zero-length segment (a === b)', () => { + expect(distToSegment({ x: 3, y: 4 }, { x: 0, y: 0 }, { x: 0, y: 0 })).toBeCloseTo(5) + }) +}) + +describe('findInsertIndex', () => { + it('returns 0 when there are no waypoints (only one segment)', () => { + expect(findInsertIndex(0, 0, [], 100, 0, { x: 50, y: 5 })).toBe(0) + }) + + it('inserts before first waypoint when click is on first segment', () => { + const idx = findInsertIndex(0, 0, [{ x: 100, y: 0 }], 200, 0, { x: 30, y: 5 }) + expect(idx).toBe(0) + }) + + it('inserts after first waypoint when click is on second segment', () => { + const idx = findInsertIndex(0, 0, [{ x: 100, y: 0 }], 200, 0, { x: 160, y: 5 }) + expect(idx).toBe(1) + }) + + it('picks the closest segment among multiple', () => { + const idx = findInsertIndex( + 0, 0, + [{ x: 100, y: 0 }, { x: 100, y: 100 }], + 200, 100, + { x: 150, y: 105 }, + ) + expect(idx).toBe(2) + }) +}) diff --git a/frontend/src/components/canvas/edges/index.tsx b/frontend/src/components/canvas/edges/index.tsx index 9917d25..23ac77c 100644 --- a/frontend/src/components/canvas/edges/index.tsx +++ b/frontend/src/components/canvas/edges/index.tsx @@ -1,15 +1,19 @@ +import { useCallback } from 'react' import { BaseEdge, EdgeLabelRenderer, getBezierPath, getSmoothStepPath, + useReactFlow, useStore, type EdgeProps, type Edge, } from '@xyflow/react' -import type { EdgeData, EdgeType } from '@/types' +import type { EdgeData, EdgeType, Waypoint } from '@/types' import { useThemeStore } from '@/stores/themeStore' +import { useCanvasStore } from '@/stores/canvasStore' import { THEMES } from '@/utils/themes' +import { buildWaypointPath, snap45, snap45both } from './waypointUtils' const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149'] @@ -18,6 +22,165 @@ function getVlanColor(vlanId?: number): string { return VLAN_COLORS[vlanId % VLAN_COLORS.length] } +// ── Waypoint drag handle ───────────────────────────────────────────────────── + +interface WaypointHandleProps { + edgeId: string + index: number + waypoint: Waypoint + waypoints: Waypoint[] + color: string + pathStyle?: string + prevPoint: Waypoint + nextPoint: Waypoint +} + +function WaypointHandle({ edgeId, index, waypoint, waypoints, color, pathStyle, prevPoint, nextPoint }: WaypointHandleProps) { + const { screenToFlowPosition } = useReactFlow() + const updateEdge = useCanvasStore((s) => s.updateEdge) + + const handlePointerDown = useCallback((e: React.PointerEvent) => { + e.stopPropagation() + e.currentTarget.setPointerCapture(e.pointerId) + }, []) + + const handlePointerMove = useCallback((e: React.PointerEvent) => { + if (e.buttons !== 1) return + let pos = screenToFlowPosition({ x: e.clientX, y: e.clientY }) + if (pathStyle === 'smooth') { + // Find the intersection of 45°-rays from both adjacent points so that + // ALL segments (prev→this and this→next) snap to 45° simultaneously. + pos = snap45both(prevPoint, nextPoint, pos) + } + const next = [...waypoints] + next[index] = pos + updateEdge(edgeId, { waypoints: next }) + }, [screenToFlowPosition, waypoints, index, edgeId, updateEdge, pathStyle, prevPoint, nextPoint]) + + const handlePointerUp = useCallback((e: React.PointerEvent) => { + e.currentTarget.releasePointerCapture(e.pointerId) + }, []) + + const handleDoubleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + updateEdge(edgeId, { waypoints: waypoints.filter((_, i) => i !== index) }) + }, [edgeId, waypoints, index, updateEdge]) + + return ( +
+ ) +} + +// ── Add waypoint handle (+ button at segment midpoints) ────────────────────── + +interface AddWaypointHandleProps { + edgeId: string + insertIndex: number + x: number + y: number + waypoints: Waypoint[] + color: string + pathStyle?: string + prevPoint: Waypoint +} + +function AddWaypointHandle({ edgeId, insertIndex, x, y, waypoints, color, pathStyle, prevPoint }: AddWaypointHandleProps) { + const updateEdge = useCanvasStore((s) => s.updateEdge) + + const handleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + let pos = { x, y } + if (pathStyle === 'smooth') pos = snap45(prevPoint, pos) + const next = [...waypoints.slice(0, insertIndex), pos, ...waypoints.slice(insertIndex)] + updateEdge(edgeId, { waypoints: next }) + }, [edgeId, insertIndex, x, y, waypoints, updateEdge, pathStyle, prevPoint]) + + return ( +
+ + +
+ ) +} + +// ── Segment midpoints ──────────────────────────────────────────────────────── + +/** + * Compute + handle positions for each path segment. + * For smooth style: bias the first + handle to the source handle axis and the + * last + handle to the target handle axis, so clicking always gives a clean + * perpendicular exit/entry (no diagonal guesswork near the nodes). + */ +function segmentMidpoints( + sourceX: number, sourceY: number, + waypoints: Waypoint[], + targetX: number, targetY: number, + pathStyle?: string, + sourcePosition?: string, +): { x: number; y: number; insertIndex: number }[] { + const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }] + const isSmooth = pathStyle === 'smooth' + + return pts.slice(0, -1).map((a, i) => { + const b = pts[i + 1] + let mx = (a.x + b.x) / 2 + const my = (a.y + b.y) / 2 + + // For smooth style with no existing waypoints, bias the single + handle onto + // the source handle axis so clicking it creates a perpendicular exit. + // Only applies to bottom/top handles (vertical exits) and only when the edge + // has no waypoints yet — once waypoints exist, all + handles stay at the + // real segment midpoint so they remain visually on the edge. + if (isSmooth && i === 0 && pts.length === 2) { + const vertSrc = sourcePosition === 'bottom' || sourcePosition === 'top' + if (vertSrc) mx = a.x // same X as source → + sits directly below/above node + } + + return { x: mx, y: my, insertIndex: i } + }) +} + +// ── Main edge component ────────────────────────────────────────────────────── + export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps>) { const activeTheme = useThemeStore((s) => s.activeTheme) const theme = THEMES[activeTheme] @@ -25,11 +188,26 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t const targetType = useStore((s) => s.nodeLookup.get(target)?.type) const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox' + const waypoints: Waypoint[] = Array.isArray(data?.waypoints) && data.waypoints.length > 0 + ? data.waypoints as Waypoint[] + : [] + + const hasWaypoints = waypoints.length > 0 + + const pathStyle = data?.path_style as string | undefined + const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition } - const [edgePath, labelX] = data?.path_style === 'smooth' + const [autoPath, labelX] = pathStyle === 'smooth' ? getSmoothStepPath({ ...pathArgs, borderRadius: 8 }) : getBezierPath(pathArgs) + const edgePath = hasWaypoints + ? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle) + : autoPath + + const midX = hasWaypoints ? (sourceX + targetX) / 2 : labelX + const midY = (sourceY + targetY) / 2 + const edgeType: EdgeType = data?.type ?? 'ethernet' const edgeColors = theme.colors.edgeColors @@ -43,6 +221,11 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t } const customColor = data?.custom_color as string | undefined + const strokeColor: string = selected + ? theme.colors.edgeSelectedColor + : customColor + ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : (BASE_STYLES[edgeType].stroke as string ?? edgeColors.ethernet)) + const style: React.CSSProperties = { ...BASE_STYLES[edgeType], ...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}), @@ -50,16 +233,20 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t ...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}), } - // Normalize animated value — supports legacy boolean (true → 'snake') const animMode: 'none' | 'snake' | 'flow' = data?.animated === true || data?.animated === 'snake' ? 'snake' : data?.animated === 'flow' ? 'flow' : 'none' const animColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string) + const midpoints = selected + ? segmentMidpoints(sourceX, sourceY, waypoints, targetX, targetY, pathStyle, sourcePosition) + : [] + return ( <> - + + {animMode === 'snake' && ( )} - {data?.label && ( - + + {data?.label && (
{data.label as string}
-
- )} + )} + + {/* Existing waypoint drag handles */} + {selected && waypoints.map((wp, idx) => { + const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1] + const nextPoint = idx === waypoints.length - 1 ? { x: targetX, y: targetY } : waypoints[idx + 1] + return ( + + ) + })} + + {/* + handles at segment midpoints to add new waypoints */} + {selected && midpoints.map((mp) => { + const prevPoint = mp.insertIndex === 0 + ? { x: sourceX, y: sourceY } + : waypoints[mp.insertIndex - 1] + return ( + + ) + })} +
) } diff --git a/frontend/src/components/canvas/edges/waypointUtils.ts b/frontend/src/components/canvas/edges/waypointUtils.ts new file mode 100644 index 0000000..9dc5066 --- /dev/null +++ b/frontend/src/components/canvas/edges/waypointUtils.ts @@ -0,0 +1,167 @@ +import type { Waypoint } from '@/types' + +// ── Path builders ───────────────────────────────────────────────────────────── + +/** Catmull-Rom → cubic bezier for smooth curves through waypoints */ +function buildCatmullRomPath(pts: Waypoint[]): string { + if (pts.length < 2) return `M ${pts[0].x} ${pts[0].y}` + let d = `M ${pts[0].x} ${pts[0].y}` + for (let i = 0; i < pts.length - 1; i++) { + const p0 = pts[Math.max(i - 1, 0)] + const p1 = pts[i] + const p2 = pts[i + 1] + const p3 = pts[Math.min(i + 2, pts.length - 1)] + const cp1x = p1.x + (p2.x - p0.x) / 6 + const cp1y = p1.y + (p2.y - p0.y) / 6 + const cp2x = p2.x - (p3.x - p1.x) / 6 + const cp2y = p2.y - (p3.y - p1.y) / 6 + d += ` C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${p2.x} ${p2.y}` + } + return d +} + +/** Polyline with rounded corners at each waypoint vertex (quadratic bezier) */ +function buildRoundedPolylinePath(pts: Waypoint[], radius = 8): string { + if (pts.length < 2) return `M ${pts[0].x} ${pts[0].y}` + if (pts.length === 2) return `M ${pts[0].x} ${pts[0].y} L ${pts[1].x} ${pts[1].y}` + + let d = `M ${pts[0].x} ${pts[0].y}` + + for (let i = 1; i < pts.length - 1; i++) { + const prev = pts[i - 1] + const curr = pts[i] + const next = pts[i + 1] + + const dx1 = curr.x - prev.x + const dy1 = curr.y - prev.y + const len1 = Math.hypot(dx1, dy1) + + const dx2 = next.x - curr.x + const dy2 = next.y - curr.y + const len2 = Math.hypot(dx2, dy2) + + if (len1 < 1 || len2 < 1) { + d += ` L ${curr.x} ${curr.y}` + continue + } + + const r = Math.min(radius, len1 / 2, len2 / 2) + + // Approach point (on segment prev→curr, r units before corner) + const bx = curr.x - (dx1 / len1) * r + const by = curr.y - (dy1 / len1) * r + + // Departure point (on segment curr→next, r units after corner) + const ax = curr.x + (dx2 / len2) * r + const ay = curr.y + (dy2 / len2) * r + + d += ` L ${bx} ${by} Q ${curr.x} ${curr.y} ${ax} ${ay}` + } + + d += ` L ${pts[pts.length - 1].x} ${pts[pts.length - 1].y}` + return d +} + +export function buildWaypointPath( + sourceX: number, sourceY: number, + waypoints: Waypoint[], + targetX: number, targetY: number, + pathStyle: string = 'bezier', +): string { + const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }] + return pathStyle === 'smooth' ? buildRoundedPolylinePath(pts) : buildCatmullRomPath(pts) +} + +// ── 45° snapping ────────────────────────────────────────────────────────────── + +/** + * Snap `pos` to the nearest 45°-multiple direction from `from`. + * Only snaps when within SNAP_THRESHOLD px of a 45° position. + */ +export function snap45(from: Waypoint, pos: Waypoint): Waypoint { + const dx = pos.x - from.x + const dy = pos.y - from.y + const dist = Math.hypot(dx, dy) + if (dist < 1) return pos + const angle = Math.atan2(dy, dx) + const snapped = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4) + const candidate = { + x: Math.round(from.x + dist * Math.cos(snapped)), + y: Math.round(from.y + dist * Math.sin(snapped)), + } + const deviation = Math.hypot(candidate.x - pos.x, candidate.y - pos.y) + return deviation <= SNAP_THRESHOLD ? candidate : pos +} + +/** Snap threshold in flow-space pixels. Only snap when this close to a 45° position. */ +const SNAP_THRESHOLD = 15 + +/** + * Find the position closest to `pos` that lies simultaneously on a 45°-ray + * from `prev` AND on a 45°-ray from `next`. + * + * Only snaps when the nearest valid intersection is within SNAP_THRESHOLD px — + * outside that zone the raw drag position is returned, allowing free placement. + */ +export function snap45both(prev: Waypoint, next: Waypoint, pos: Waypoint): Waypoint { + let best: Waypoint | null = null + let bestDist = Infinity + + for (let i = 0; i < 8; i++) { + const a1 = i * Math.PI / 4 + const c1 = Math.cos(a1), s1 = Math.sin(a1) + + for (let j = 0; j < 8; j++) { + const a2 = j * Math.PI / 4 + const c2 = Math.cos(a2), s2 = Math.sin(a2) + + const dx = next.x - prev.x + const dy = next.y - prev.y + const det = -c1 * s2 + c2 * s1 + if (Math.abs(det) < 1e-6) continue + + const t = (-dx * s2 + c2 * dy) / det + const s = (c1 * dy - s1 * dx) / det + if (t < -1e-6 || s < -1e-6) continue + + const ix = prev.x + t * c1 + const iy = prev.y + t * s1 + const d = Math.hypot(ix - pos.x, iy - pos.y) + if (d < bestDist) { + bestDist = d + best = { x: Math.round(ix), y: Math.round(iy) } + } + } + } + + // Only snap if close enough — otherwise let the waypoint move freely + if (best === null || bestDist > SNAP_THRESHOLD) return pos + return best +} + +// ── Geometry helpers ────────────────────────────────────────────────────────── + +export function distToSegment(p: Waypoint, a: Waypoint, b: Waypoint): number { + const dx = b.x - a.x + const dy = b.y - a.y + const lenSq = dx * dx + dy * dy + if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y) + const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq)) + return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)) +} + +export function findInsertIndex( + sourceX: number, sourceY: number, + waypoints: Waypoint[], + targetX: number, targetY: number, + point: Waypoint, +): number { + const allPts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }] + let minDist = Infinity + let best = 0 + for (let i = 0; i < allPts.length - 1; i++) { + const d = distToSegment(point, allPts[i], allPts[i + 1]) + if (d < minDist) { minDist = d; best = i } + } + return best +} diff --git a/frontend/src/components/modals/EdgeModal.tsx b/frontend/src/components/modals/EdgeModal.tsx index 95d07b7..f2f95ea 100644 --- a/frontend/src/components/modals/EdgeModal.tsx +++ b/frontend/src/components/modals/EdgeModal.tsx @@ -23,11 +23,12 @@ interface EdgeModalProps { onClose: () => void onSubmit: (data: EdgeData) => void onDelete?: () => void + onClearWaypoints?: () => void initial?: Partial title?: string } -export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title = 'Connect Nodes' }: EdgeModalProps) { +export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints, initial, title = 'Connect Nodes' }: EdgeModalProps) { const [type, setType] = useState(initial?.type ?? 'ethernet') const [label, setLabel] = useState(initial?.label ?? '') const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '') @@ -175,6 +176,16 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
+ {onClearWaypoints && initial?.waypoints && initial.waypoints.length > 0 && ( + + )} +
{onDelete ? ( - {hardwareOpen && ( -
- Show on node - -
- )} -
- {hardwareOpen && ( -
-
- - set('cpu_model', e.target.value || undefined)} - placeholder="e.g. Intel Xeon E5-2680" - className="bg-[#21262d] border-[#30363d] text-sm h-8" - /> -
-
- - set('cpu_count', e.target.value ? parseInt(e.target.value, 10) : undefined)} - placeholder="e.g. 8" - className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8" - /> -
-
- - set('ram_gb', e.target.value ? parseFloat(e.target.value) : undefined)} - placeholder="e.g. 32" - className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8" - /> -
-
- - set('disk_gb', e.target.value ? parseFloat(e.target.value) : undefined)} - placeholder="e.g. 500" - className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8" - /> -
-
- )} -
- )} - {/* Bottom connection points (not for group containers) */} {form.type !== 'groupRect' && form.type !== 'group' && (
diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx index adef950..4b24085 100644 --- a/frontend/src/components/modals/__tests__/NodeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/NodeModal.test.tsx @@ -293,91 +293,6 @@ describe('NodeModal', () => { expect(screen.getByText(/Using default colors for/)).toBeDefined() }) - // ── Hardware section ────────────────────────────────────────────────── - - it('renders Hardware toggle button', () => { - renderModal() - expect(screen.getByText('Hardware')).toBeDefined() - }) - - it('hardware fields are hidden by default', () => { - renderModal() - expect(screen.queryByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeNull() - }) - - it('expands hardware fields on toggle click', () => { - renderModal() - fireEvent.click(screen.getByText('Hardware')) - expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined() - expect(screen.getByPlaceholderText('e.g. 8')).toBeDefined() - expect(screen.getByPlaceholderText('e.g. 32')).toBeDefined() - expect(screen.getByPlaceholderText('e.g. 500')).toBeDefined() - }) - - it('auto-expands when initial has hardware data', () => { - renderModal({ initial: { ...BASE, cpu_count: 8, ram_gb: 32 } }) - expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined() - }) - - it('pre-fills hardware fields from initial', () => { - renderModal({ initial: { ...BASE, cpu_model: 'Intel i5', cpu_count: 4, ram_gb: 16, disk_gb: 500 } }) - expect((screen.getByPlaceholderText('e.g. Intel Xeon E5-2680') as HTMLInputElement).value).toBe('Intel i5') - }) - - it('submits hardware fields when filled', () => { - const { onSubmit } = renderModal() - fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Homelab' } }) - fireEvent.click(screen.getByText('Hardware')) - fireEvent.change(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680'), { target: { value: 'Intel i7-12700K' } }) - fireEvent.change(screen.getByPlaceholderText('e.g. 8'), { target: { value: '12' } }) - fireEvent.change(screen.getByPlaceholderText('e.g. 32'), { target: { value: '64' } }) - fireEvent.change(screen.getByPlaceholderText('e.g. 500'), { target: { value: '2000' } }) - fireEvent.click(screen.getByRole('button', { name: 'Add' })) - const data = onSubmit.mock.calls[0][0] as Partial - expect(data.cpu_model).toBe('Intel i7-12700K') - expect(data.cpu_count).toBe(12) - expect(data.ram_gb).toBe(64) - expect(data.disk_gb).toBe(2000) - }) - - it('hides Hardware section for groupRect type', () => { - renderModal({ initial: { type: 'groupRect' } }) - expect(screen.queryByText('Hardware')).toBeNull() - }) - - it('show_hardware toggle hidden when section is collapsed', () => { - renderModal() - expect(screen.queryByText('Show on node')).toBeNull() - }) - - it('show_hardware toggle appears when section is expanded', () => { - renderModal() - fireEvent.click(screen.getByText('Hardware')) - expect(screen.getByText('Show on node')).toBeDefined() - }) - - it('show_hardware defaults to falsy', () => { - const { onSubmit } = renderModal() - fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } }) - fireEvent.click(screen.getByRole('button', { name: 'Add' })) - expect(onSubmit.mock.calls[0][0].show_hardware).toBeFalsy() - }) - - it('toggling show_hardware sets it to true', () => { - const { onSubmit } = renderModal() - fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } }) - fireEvent.click(screen.getByText('Hardware')) - fireEvent.click(screen.getByRole('switch')) - fireEvent.click(screen.getByRole('button', { name: 'Add' })) - expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true) - }) - - it('pre-fills show_hardware from initial', () => { - const { onSubmit } = renderModal({ initial: { label: 'Node', show_hardware: true, cpu_count: 8 } }) - fireEvent.click(screen.getByRole('button', { name: 'Add' })) - expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true) - }) - // ── Bottom connection points ─────────────────────────────────────────── it('shows Bottom Connection Points for server type', () => { diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index 690860f..1c9d82b 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -1,10 +1,11 @@ -import { useState } from 'react' +import { createElement, useState } from 'react' import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { useCanvasStore } from '@/stores/canvasStore' -import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData } from '@/types' +import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types' import { getServiceUrl } from '@/utils/serviceUrl' +import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons' import type { Node } from '@xyflow/react' interface DetailPanelProps { @@ -14,6 +15,9 @@ interface DetailPanelProps { type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string } const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' } +type PropForm = { key: string; value: string; icon: string | null; visible: boolean } +const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true } + export function DetailPanel({ onEdit }: DetailPanelProps) { const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore() @@ -24,6 +28,12 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { const [groupName, setGroupName] = useState('') const [creatingGroup, setCreatingGroup] = useState(false) + // Properties state + const [addingProp, setAddingProp] = useState(false) + const [newProp, setNewProp] = useState(EMPTY_PROP) + const [editingPropIndex, setEditingPropIndex] = useState(null) + const [editProp, setEditProp] = useState(EMPTY_PROP) + // Multi-select panel const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id)) @@ -119,6 +129,52 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { setEditingFor(null) } + // --- Property handlers --- + const properties: NodeProperty[] = data.properties ?? [] + + const handleAddProp = () => { + if (!newProp.key.trim() || !newProp.value.trim()) return + snapshotHistory() + const prop: NodeProperty = { key: newProp.key.trim(), value: newProp.value.trim(), icon: newProp.icon, visible: newProp.visible } + updateNode(node.id, { properties: [...properties, prop] }) + setNewProp(EMPTY_PROP) + setAddingProp(false) + } + + const handleRemoveProp = (index: number) => { + snapshotHistory() + updateNode(node.id, { properties: properties.filter((_, i) => i !== index) }) + if (editingPropIndex === index) setEditingPropIndex(null) + } + + const handleTogglePropVisible = (index: number) => { + snapshotHistory() + updateNode(node.id, { + properties: properties.map((p, i) => i === index ? { ...p, visible: !p.visible } : p), + }) + } + + const handleStartEditProp = (index: number) => { + const p = properties[index] + if (!p) return + setEditProp({ key: p.key, value: p.value, icon: p.icon, visible: p.visible }) + setEditingPropIndex(index) + setAddingProp(false) + } + + const handleSaveEditProp = () => { + if (editingPropIndex === null || !editProp.key.trim() || !editProp.value.trim()) return + snapshotHistory() + updateNode(node.id, { + properties: properties.map((p, i) => + i === editingPropIndex + ? { key: editProp.key.trim(), value: editProp.value.trim(), icon: editProp.icon, visible: editProp.visible } + : p + ), + }) + setEditingPropIndex(null) + } + return (