diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index feac20a..2530d25 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect, useCallback, useRef, useState } from 'react' import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react' import { type Node } from '@xyflow/react' import { applyDagreLayout } from '@/utils/layout' +import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer' import { generateUUID } from '@/utils/uuid' import { generateMarkdownTable } from '@/utils/exportMarkdown' import { exportToPng } from '@/utils/export' @@ -60,76 +61,8 @@ export default function App() { toast.success('Canvas saved') return } - const nodesToSave = nodes.map((n) => { - if (n.data.type === 'groupRect') { - return { - id: n.id, - type: 'groupRect', - label: n.data.label, - hostname: null, - ip: null, - mac: null, - os: null, - status: 'unknown', - check_method: null, - check_target: null, - services: [], - notes: null, - parent_id: null, - container_mode: false, - custom_icon: null, - pos_x: n.position.x, - pos_y: n.position.y, - // Persist size and all rect config inside custom_colors - custom_colors: { - ...n.data.custom_colors, - width: n.measured?.width ?? n.width ?? 360, - height: n.measured?.height ?? n.height ?? 240, - }, - } - } - return { - id: n.id, - type: n.data.type, - label: n.data.label, - hostname: n.data.hostname ?? null, - ip: n.data.ip ?? null, - mac: n.data.mac ?? null, - os: n.data.os ?? null, - status: n.data.status, - check_method: n.data.check_method ?? null, - check_target: n.data.check_target ?? null, - services: n.data.services ?? [], - notes: n.data.notes ?? null, - parent_id: n.data.parent_id ?? null, - container_mode: n.data.container_mode ?? false, - custom_colors: n.data.custom_colors ?? null, - custom_icon: n.data.custom_icon ?? null, - cpu_count: n.data.cpu_count ?? null, - cpu_model: n.data.cpu_model ?? null, - ram_gb: n.data.ram_gb ?? null, - disk_gb: n.data.disk_gb ?? null, - show_hardware: n.data.show_hardware ?? false, - pos_x: n.position.x, - pos_y: n.position.y, - } - }) - const edgesToSave = edges.map((e) => ({ - id: e.id, - source: e.source, - target: e.target, - type: e.data?.type ?? 'ethernet', - label: e.data?.label ?? null, - vlan_id: e.data?.vlan_id ?? null, - speed: e.data?.speed ?? null, - custom_color: e.data?.custom_color ?? null, - path_style: e.data?.path_style ?? null, - animated: e.data?.animated ?? false, - // Normalize stub handle IDs: "top-t" / "bottom-t" are invisible target stubs; - // map them back to their canonical source handle ID so reload works correctly. - source_handle: e.sourceHandle === 'top-t' ? 'top' : e.sourceHandle === 'bottom-t' ? 'bottom' : (e.sourceHandle ?? null), - target_handle: e.targetHandle === 'top-t' ? 'top' : e.targetHandle === 'bottom-t' ? 'bottom' : (e.targetHandle ?? null), - })) + const nodesToSave = nodes.map(serializeNode) + const edgesToSave = edges.map(serializeEdge) await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme } }) markSaved() toast.success('Canvas saved') @@ -166,44 +99,12 @@ export default function App() { if (apiNodes.length > 0) { // Build a map of proxmox container mode to know if children should be nested const proxmoxContainerMap = new Map( - apiNodes - .filter((n: NodeData & { id: string }) => n.type === 'proxmox') - .map((n: NodeData & { id: string }) => [n.id, n.container_mode !== false]) + (apiNodes as ApiNode[]) + .filter((n) => n.type === 'proxmox') + .map((n) => [n.id, n.container_mode !== false]) ) - const rfNodes = apiNodes.map((n: NodeData & { id: string; pos_x: number; pos_y: number; parent_id?: string }) => { - if (n.type === 'groupRect') { - const w = n.custom_colors?.width ?? 360 - const h = n.custom_colors?.height ?? 240 - const z = n.custom_colors?.z_order ?? 1 - return { - id: n.id, - type: 'groupRect', - position: { x: n.pos_x, y: n.pos_y }, - data: n, - width: w, - height: h, - zIndex: z - 10, - } - } - const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false - return { - id: n.id, - type: n.type, - position: { x: n.pos_x, y: n.pos_y }, - data: n, - ...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}), - ...(n.type === 'proxmox' && n.container_mode !== false ? { width: 300, height: 200 } : {}), - } - }) - const rfEdges = apiEdges.map((e: EdgeData & { id: string; source: string; target: string; source_handle?: string; target_handle?: string }) => ({ - id: e.id, - source: e.source, - target: e.target, - type: e.type, - sourceHandle: e.source_handle ?? null, - targetHandle: e.target_handle ?? null, - data: e, - })) + const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)) + const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge) const savedTheme = res.data.viewport?.theme_id if (savedTheme) setTheme(savedTheme) loadCanvas(rfNodes, rfEdges) diff --git a/frontend/src/utils/__tests__/canvasSerializer.test.ts b/frontend/src/utils/__tests__/canvasSerializer.test.ts new file mode 100644 index 0000000..42b0bac --- /dev/null +++ b/frontend/src/utils/__tests__/canvasSerializer.test.ts @@ -0,0 +1,384 @@ +import { describe, it, expect } from 'vitest' +import type { Node, Edge } from '@xyflow/react' +import type { NodeData, EdgeData } from '@/types' +import { + serializeNode, + serializeEdge, + deserializeApiNode, + deserializeApiEdge, + type ApiNode, + type ApiEdge, +} from '@/utils/canvasSerializer' + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeRfNode(overrides: Partial> = {}): Node { + return { + id: 'n1', + type: 'server', + position: { x: 100, y: 200 }, + data: { + label: 'My Server', + type: 'server', + status: 'online', + services: [], + }, + ...overrides, + } +} + +function makeApiNode(overrides: Partial = {}): ApiNode { + return { + id: 'n1', + type: 'server', + label: 'My Server', + pos_x: 100, + pos_y: 200, + status: 'online', + services: [], + ...overrides, + } +} + +function makeRfEdge(overrides: Partial> = {}): Edge { + return { + id: 'e1', + source: 'n1', + target: 'n2', + type: 'ethernet', + data: { type: 'ethernet' }, + ...overrides, + } +} + +function makeApiEdge(overrides: Partial = {}): ApiEdge { + return { + id: 'e1', + source: 'n1', + target: 'n2', + type: 'ethernet', + ...overrides, + } +} + +// ── serializeNode — regular nodes ──────────────────────────────────────────── + +describe('serializeNode — regular node', () => { + it('maps position to pos_x/pos_y', () => { + const result = serializeNode(makeRfNode({ position: { x: 42, y: 99 } })) + expect(result.pos_x).toBe(42) + expect(result.pos_y).toBe(99) + }) + + it('includes all data fields', () => { + const node = makeRfNode({ + data: { + label: 'Router', type: 'router', status: 'online', services: [], + hostname: 'gw.local', ip: '192.168.1.1', mac: 'aa:bb:cc:dd:ee:ff', + os: 'OpenWRT', check_method: 'ping', check_target: '192.168.1.1', + notes: 'main router', + }, + }) + const result = serializeNode(node) + expect(result.hostname).toBe('gw.local') + expect(result.ip).toBe('192.168.1.1') + expect(result.mac).toBe('aa:bb:cc:dd:ee:ff') + expect(result.os).toBe('OpenWRT') + expect(result.check_method).toBe('ping') + expect(result.check_target).toBe('192.168.1.1') + expect(result.notes).toBe('main router') + }) + + it('serializes width and height when node has been resized', () => { + const node = makeRfNode({ width: 280, height: 120 }) + const result = serializeNode(node) + expect(result.width).toBe(280) + expect(result.height).toBe(120) + }) + + it('serializes width/height as null when node has default size', () => { + const result = serializeNode(makeRfNode()) + expect(result.width).toBeNull() + expect(result.height).toBeNull() + }) + + it('serializes hardware fields', () => { + const node = makeRfNode({ + data: { + label: 'Server', type: 'server', status: 'online', services: [], + cpu_count: 8, cpu_model: 'Intel i7', ram_gb: 32, disk_gb: 500, show_hardware: true, + }, + }) + const result = serializeNode(node) + expect(result.cpu_count).toBe(8) + expect(result.cpu_model).toBe('Intel i7') + expect(result.ram_gb).toBe(32) + expect(result.disk_gb).toBe(500) + expect(result.show_hardware).toBe(true) + }) + + it('serializes custom_colors', () => { + const node = makeRfNode({ data: { label: 'S', type: 'server', status: 'unknown', services: [], custom_colors: { border: '#ff0000' } } }) + const result = serializeNode(node) + expect(result.custom_colors).toEqual({ border: '#ff0000' }) + }) + + it('serializes parent_id and container_mode', () => { + const node = makeRfNode({ data: { label: 'VM', type: 'vm', status: 'unknown', services: [], parent_id: 'px1', container_mode: false } }) + const result = serializeNode(node) + expect(result.parent_id).toBe('px1') + expect(result.container_mode).toBe(false) + }) + + it('nulls optional fields when absent', () => { + const result = serializeNode(makeRfNode()) + expect(result.hostname).toBeNull() + expect(result.ip).toBeNull() + expect(result.mac).toBeNull() + expect(result.os).toBeNull() + expect(result.check_method).toBeNull() + expect(result.check_target).toBeNull() + expect(result.notes).toBeNull() + expect(result.parent_id).toBeNull() + expect(result.cpu_count).toBeNull() + expect(result.cpu_model).toBeNull() + expect(result.ram_gb).toBeNull() + expect(result.disk_gb).toBeNull() + }) +}) + +// ── serializeNode — groupRect ───────────────────────────────────────────────── + +describe('serializeNode — groupRect', () => { + it('stores dimensions inside custom_colors', () => { + const node = makeRfNode({ + type: 'groupRect', + data: { label: 'Zone A', type: 'groupRect', status: 'unknown', services: [] }, + width: 400, + height: 250, + }) + const result = serializeNode(node) + expect((result.custom_colors as Record).width).toBe(400) + expect((result.custom_colors as Record).height).toBe(250) + }) + + it('falls back to measured dimensions over explicit width/height', () => { + const node: Node = { + ...makeRfNode({ type: 'groupRect', data: { label: 'Z', type: 'groupRect', status: 'unknown', services: [] }, width: 400 }), + measured: { width: 420, height: 260 }, + } + const result = serializeNode(node) + expect((result.custom_colors as Record).width).toBe(420) + expect((result.custom_colors as Record).height).toBe(260) + }) + + it('falls back to defaults when no dimensions available', () => { + const node = makeRfNode({ type: 'groupRect', data: { label: 'Z', type: 'groupRect', status: 'unknown', services: [] } }) + const result = serializeNode(node) + expect((result.custom_colors as Record).width).toBe(360) + expect((result.custom_colors as Record).height).toBe(240) + }) + + it('preserves existing custom_colors fields alongside dimensions', () => { + const node = makeRfNode({ + type: 'groupRect', + data: { label: 'Z', type: 'groupRect', status: 'unknown', services: [], custom_colors: { border: '#aaa', z_order: 2 } }, + width: 300, height: 200, + }) + const result = serializeNode(node) + const cc = result.custom_colors as Record + expect(cc.border).toBe('#aaa') + expect(cc.z_order).toBe(2) + expect(cc.width).toBe(300) + expect(cc.height).toBe(200) + }) +}) + +// ── serializeEdge ───────────────────────────────────────────────────────────── + +describe('serializeEdge', () => { + it('serializes basic fields', () => { + const result = serializeEdge(makeRfEdge()) + expect(result.id).toBe('e1') + expect(result.source).toBe('n1') + expect(result.target).toBe('n2') + expect(result.type).toBe('ethernet') + }) + + it('normalizes top-t handle to top', () => { + const result = serializeEdge(makeRfEdge({ sourceHandle: 'top-t', targetHandle: 'bottom-t' })) + expect(result.source_handle).toBe('top') + expect(result.target_handle).toBe('bottom') + }) + + it('passes through non-stub handles unchanged', () => { + const result = serializeEdge(makeRfEdge({ sourceHandle: 'cluster-right', targetHandle: 'cluster-left' })) + expect(result.source_handle).toBe('cluster-right') + expect(result.target_handle).toBe('cluster-left') + }) + + it('serializes optional edge data', () => { + const edge = makeRfEdge({ data: { type: 'vlan', label: 'uplink', vlan_id: 10, custom_color: '#ff0', path_style: 'smooth', animated: true } }) + const result = serializeEdge(edge) + expect(result.label).toBe('uplink') + expect(result.vlan_id).toBe(10) + expect(result.custom_color).toBe('#ff0') + expect(result.path_style).toBe('smooth') + expect(result.animated).toBe(true) + }) + + it('nulls optional fields when absent', () => { + const result = serializeEdge(makeRfEdge({ sourceHandle: undefined, targetHandle: undefined })) + expect(result.source_handle).toBeNull() + expect(result.target_handle).toBeNull() + expect(result.label).toBeNull() + expect(result.vlan_id).toBeNull() + expect(result.custom_color).toBeNull() + expect(result.path_style).toBeNull() + }) +}) + +// ── deserializeApiNode — regular nodes ─────────────────────────────────────── + +describe('deserializeApiNode — regular node', () => { + const emptyMap = new Map() + + it('maps pos_x/pos_y to position', () => { + const result = deserializeApiNode(makeApiNode({ pos_x: 50, pos_y: 75 }), emptyMap) + expect(result.position).toEqual({ x: 50, y: 75 }) + }) + + it('restores width and height when node was resized', () => { + const result = deserializeApiNode(makeApiNode({ width: 280, height: 120 }), emptyMap) + expect(result.width).toBe(280) + expect(result.height).toBe(120) + }) + + it('leaves width/height undefined for default-sized nodes', () => { + const result = deserializeApiNode(makeApiNode(), emptyMap) + expect(result.width).toBeUndefined() + expect(result.height).toBeUndefined() + }) + + it('sets parentId and extent for children of container proxmox', () => { + const map = new Map([['px1', true]]) + const result = deserializeApiNode(makeApiNode({ parent_id: 'px1' }), map) + expect(result.parentId).toBe('px1') + expect(result.extent).toBe('parent') + }) + + it('does not set parentId when parent is not in container mode', () => { + const map = new Map([['px1', false]]) + const result = deserializeApiNode(makeApiNode({ parent_id: 'px1' }), map) + expect(result.parentId).toBeUndefined() + }) + + it('sets proxmox dimensions using saved values', () => { + const result = deserializeApiNode( + makeApiNode({ type: 'proxmox', container_mode: true, width: 450, height: 300 }), + emptyMap, + ) + expect(result.width).toBe(450) + expect(result.height).toBe(300) + }) + + it('falls back to 300x200 for proxmox container with no saved dimensions', () => { + const result = deserializeApiNode(makeApiNode({ type: 'proxmox', container_mode: true }), emptyMap) + expect(result.width).toBe(300) + expect(result.height).toBe(200) + }) + + it('does not set dimensions for non-container proxmox', () => { + const result = deserializeApiNode(makeApiNode({ type: 'proxmox', container_mode: false }), emptyMap) + expect(result.width).toBeUndefined() + expect(result.height).toBeUndefined() + }) +}) + +// ── deserializeApiNode — groupRect ──────────────────────────────────────────── + +describe('deserializeApiNode — groupRect', () => { + const emptyMap = new Map() + + it('restores width/height from custom_colors', () => { + const result = deserializeApiNode( + makeApiNode({ type: 'groupRect', custom_colors: { width: 400, height: 250, z_order: 2 } }), + emptyMap, + ) + expect(result.width).toBe(400) + expect(result.height).toBe(250) + expect(result.zIndex).toBe(-8) + }) + + it('defaults to 360x240 when custom_colors has no dimensions', () => { + const result = deserializeApiNode(makeApiNode({ type: 'groupRect' }), emptyMap) + expect(result.width).toBe(360) + expect(result.height).toBe(240) + }) +}) + +// ── deserializeApiEdge ──────────────────────────────────────────────────────── + +describe('deserializeApiEdge', () => { + it('maps source_handle/target_handle to sourceHandle/targetHandle', () => { + const result = deserializeApiEdge(makeApiEdge({ source_handle: 'top', target_handle: 'bottom' })) + expect(result.sourceHandle).toBe('top') + expect(result.targetHandle).toBe('bottom') + }) + + it('sets sourceHandle/targetHandle to null when absent', () => { + const result = deserializeApiEdge(makeApiEdge()) + expect(result.sourceHandle).toBeNull() + expect(result.targetHandle).toBeNull() + }) + + it('preserves id, source, target, type', () => { + const result = deserializeApiEdge(makeApiEdge({ id: 'e99', source: 'a', target: 'b', type: 'wifi' })) + expect(result.id).toBe('e99') + expect(result.source).toBe('a') + expect(result.target).toBe('b') + expect(result.type).toBe('wifi') + }) +}) + +// ── Round-trip ──────────────────────────────────────────────────────────────── + +describe('round-trip: serialize → deserialize', () => { + const emptyMap = new Map() + + it('preserves position through serialize/deserialize', () => { + const node = makeRfNode({ position: { x: 123, y: 456 } }) + const serialized = serializeNode(node) as ApiNode + const restored = deserializeApiNode(serialized, emptyMap) + expect(restored.position).toEqual({ x: 123, y: 456 }) + }) + + it('preserves width/height through serialize/deserialize', () => { + const node = makeRfNode({ width: 300, height: 160 }) + const serialized = serializeNode(node) as ApiNode + const restored = deserializeApiNode(serialized, emptyMap) + expect(restored.width).toBe(300) + expect(restored.height).toBe(160) + }) + + it('preserves null width/height for default-sized nodes', () => { + const node = makeRfNode() + const serialized = serializeNode(node) as ApiNode + const restored = deserializeApiNode(serialized, emptyMap) + expect(restored.width).toBeUndefined() + expect(restored.height).toBeUndefined() + }) + + it('preserves groupRect dimensions through serialize/deserialize', () => { + const node = makeRfNode({ + type: 'groupRect', + data: { label: 'Z', type: 'groupRect', status: 'unknown', services: [] }, + width: 500, + height: 300, + }) + const serialized = serializeNode(node) as ApiNode + const restored = deserializeApiNode(serialized, emptyMap) + expect(restored.width).toBe(500) + expect(restored.height).toBe(300) + }) +}) diff --git a/frontend/src/utils/canvasSerializer.ts b/frontend/src/utils/canvasSerializer.ts new file mode 100644 index 0000000..84b7029 --- /dev/null +++ b/frontend/src/utils/canvasSerializer.ts @@ -0,0 +1,172 @@ +import type { Node, Edge } from '@xyflow/react' +import type { NodeData, EdgeData } from '@/types' + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface ApiNode extends Record { + id: string + type: string + label: string + pos_x: number + pos_y: number + status: string + services: unknown[] + hostname?: string | null + ip?: string | null + mac?: string | null + os?: string | null + check_method?: string | null + check_target?: string | null + notes?: string | null + parent_id?: string | null + container_mode?: boolean + custom_colors?: Record | null + custom_icon?: string | null + cpu_count?: number | null + cpu_model?: string | null + ram_gb?: number | null + disk_gb?: number | null + show_hardware?: boolean + width?: number | null + height?: number | null +} + +export interface ApiEdge { + id: string + source: string + target: string + type: string + label?: string | null + vlan_id?: number | null + speed?: string | null + custom_color?: string | null + path_style?: string | null + animated?: boolean + source_handle?: string | null + target_handle?: string | null +} + +// ── Serialization (RF node → API save payload) ─────────────────────────────── + +export function serializeNode(n: Node): Record { + if (n.data.type === 'groupRect') { + return { + id: n.id, + type: 'groupRect', + label: n.data.label, + hostname: null, + ip: null, + mac: null, + os: null, + status: 'unknown', + check_method: null, + check_target: null, + services: [], + notes: null, + parent_id: null, + container_mode: false, + custom_icon: null, + pos_x: n.position.x, + pos_y: n.position.y, + custom_colors: { + ...n.data.custom_colors, + width: n.measured?.width ?? n.width ?? 360, + height: n.measured?.height ?? n.height ?? 240, + }, + } + } + return { + id: n.id, + type: n.data.type, + label: n.data.label, + hostname: n.data.hostname ?? null, + ip: n.data.ip ?? null, + mac: n.data.mac ?? null, + os: n.data.os ?? null, + status: n.data.status, + check_method: n.data.check_method ?? null, + check_target: n.data.check_target ?? null, + services: n.data.services ?? [], + notes: n.data.notes ?? null, + parent_id: n.data.parent_id ?? null, + container_mode: n.data.container_mode ?? false, + custom_colors: n.data.custom_colors ?? null, + custom_icon: n.data.custom_icon ?? null, + cpu_count: n.data.cpu_count ?? null, + cpu_model: n.data.cpu_model ?? null, + ram_gb: n.data.ram_gb ?? null, + disk_gb: n.data.disk_gb ?? null, + show_hardware: n.data.show_hardware ?? false, + width: n.width ?? null, + height: n.height ?? null, + pos_x: n.position.x, + pos_y: n.position.y, + } +} + +const normalizeHandle = (h: string | null | undefined): string | null => + h === 'top-t' ? 'top' : h === 'bottom-t' ? 'bottom' : (h ?? null) + +export function serializeEdge(e: Edge): Record { + return { + id: e.id, + source: e.source, + target: e.target, + type: e.data?.type ?? 'ethernet', + label: e.data?.label ?? null, + vlan_id: e.data?.vlan_id ?? null, + speed: e.data?.speed ?? null, + custom_color: e.data?.custom_color ?? null, + path_style: e.data?.path_style ?? null, + animated: e.data?.animated ?? false, + source_handle: normalizeHandle(e.sourceHandle), + target_handle: normalizeHandle(e.targetHandle), + } +} + +// ── Deserialization (API response → RF node/edge) ──────────────────────────── + +export function deserializeApiNode( + n: ApiNode, + proxmoxContainerMap: Map, +): Node { + if (n.type === 'groupRect') { + const w = (n.custom_colors?.width as number | undefined) ?? 360 + const h = (n.custom_colors?.height as number | undefined) ?? 240 + const z = (n.custom_colors?.z_order as number | undefined) ?? 1 + return { + id: n.id, + type: 'groupRect', + position: { x: n.pos_x, y: n.pos_y }, + data: n as unknown as NodeData, + width: w, + height: h, + zIndex: z - 10, + } + } + const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false + return { + id: n.id, + type: n.type, + position: { x: n.pos_x, y: n.pos_y }, + data: n as unknown as NodeData, + ...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}), + ...(n.type === 'proxmox' && n.container_mode !== false + ? { width: n.width ?? 300, height: n.height ?? 200 } + : {}), + ...(n.width && n.type !== 'proxmox' ? { width: n.width } : {}), + ...(n.height && n.type !== 'proxmox' ? { height: n.height } : {}), + } +} + +export function deserializeApiEdge(e: ApiEdge): Edge { + return { + id: e.id, + source: e.source, + target: e.target, + type: e.type, + sourceHandle: e.source_handle ?? null, + targetHandle: e.target_handle ?? null, + data: e as unknown as EdgeData, + } +}