fix: extract canvas serializer and add full coverage including width/height

This commit is contained in:
Pouzor
2026-03-28 12:05:54 +01:00
parent 2a9cbc5932
commit 565f4337c8
3 changed files with 564 additions and 107 deletions
+8 -107
View File
@@ -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<string, boolean>(
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)
@@ -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<NodeData>> = {}): Node<NodeData> {
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> = {}): ApiNode {
return {
id: 'n1',
type: 'server',
label: 'My Server',
pos_x: 100,
pos_y: 200,
status: 'online',
services: [],
...overrides,
}
}
function makeRfEdge(overrides: Partial<Edge<EdgeData>> = {}): Edge<EdgeData> {
return {
id: 'e1',
source: 'n1',
target: 'n2',
type: 'ethernet',
data: { type: 'ethernet' },
...overrides,
}
}
function makeApiEdge(overrides: Partial<ApiEdge> = {}): 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<string, unknown>).width).toBe(400)
expect((result.custom_colors as Record<string, unknown>).height).toBe(250)
})
it('falls back to measured dimensions over explicit width/height', () => {
const node: Node<NodeData> = {
...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<string, unknown>).width).toBe(420)
expect((result.custom_colors as Record<string, unknown>).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<string, unknown>).width).toBe(360)
expect((result.custom_colors as Record<string, unknown>).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<string, unknown>
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<string, boolean>()
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<string, boolean>()
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<string, boolean>()
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)
})
})
+172
View File
@@ -0,0 +1,172 @@
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
// ── Types ────────────────────────────────────────────────────────────────────
export interface ApiNode extends Record<string, unknown> {
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<string, unknown> | 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<NodeData>): Record<string, unknown> {
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<EdgeData>): Record<string, unknown> {
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<string, boolean>,
): Node<NodeData> {
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<EdgeData> {
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,
}
}