7bc3e5d8a0
YAML round-trip dropped every edge's handles: export never wrote them
and import hardcoded sourceHandle='bottom'/targetHandle='top-t'. After
import all connections collapsed onto slot 0 ("converge at a single
point"). Per-side handle counts were dropped too, so the extra bottom
slots did not even exist to attach to.
- yaml types: add sourceHandle/targetHandle to connections and
top/bottom/left/rightHandles to nodes
- export: write each edge's real handles + per-side counts (only above
the side default); orient parent-edge handles parent->child
- import: restore handle counts onto node data and use the stored
handles, falling back to the legacy defaults for pre-existing YAML
Positions remain dagre-managed (unchanged); this restores connection
points only.
ha-relevant: yes
170 lines
6.6 KiB
TypeScript
170 lines
6.6 KiB
TypeScript
import type { Node, Edge } from '@xyflow/react'
|
|
import type { NodeData, EdgeData, EdgeType } from '@/types'
|
|
import type { YamlNode, YamlNodeConnection } from '@/types/yaml'
|
|
import { SIDES, sideDefault, sideHandleCount } from '@/utils/handleUtils'
|
|
import yaml from 'js-yaml'
|
|
|
|
/** Build a map of node id → label for edge resolution */
|
|
function buildIdToLabel(nodes: Node<NodeData>[]): Map<string, string> {
|
|
const m = new Map<string, string>()
|
|
for (const n of nodes) m.set(n.id, n.data.label)
|
|
return m
|
|
}
|
|
|
|
function makeConnection(
|
|
targetLabel: string,
|
|
edgeType: EdgeType,
|
|
edgeLabel: string | undefined,
|
|
edge?: Edge<EdgeData>,
|
|
): YamlNodeConnection {
|
|
const conn: YamlNodeConnection = {
|
|
label: targetLabel,
|
|
linkType: edgeType,
|
|
linkLabel: edgeLabel ?? '',
|
|
}
|
|
// Preserve the exact connection points so the layout round-trips.
|
|
if (edge?.sourceHandle) conn.sourceHandle = edge.sourceHandle
|
|
if (edge?.targetHandle) conn.targetHandle = edge.targetHandle
|
|
return conn
|
|
}
|
|
|
|
/** Write each side's handle count onto the entry when it exceeds the side default. */
|
|
function attachHandleCounts(entry: YamlNode, data: NodeData): void {
|
|
for (const side of SIDES) {
|
|
const count = sideHandleCount(data, side)
|
|
if (count > sideDefault(side)) {
|
|
entry[`${side}Handles` as 'topHandles' | 'bottomHandles' | 'leftHandles' | 'rightHandles'] =
|
|
count
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Serialize React Flow canvas state to a YAML string.
|
|
* Each node becomes one entry; edges are embedded as parent/clusterR/clusterL sub-objects.
|
|
* Edge deduplication: each edge is written on exactly one side (source as clusterR, target as clusterL)
|
|
* unless the edge type is 'virtual' or there is a parentId relationship, in which case
|
|
* it becomes the 'parent' field of the child node.
|
|
*/
|
|
export function exportCanvasToYaml(nodes: Node<NodeData>[], edges: Edge<EdgeData>[]): string {
|
|
const idToLabel = buildIdToLabel(nodes)
|
|
|
|
// Build per-node edge maps (id → connections)
|
|
// We use a Set to track already-serialized edge ids (deduplication).
|
|
const serializedEdges = new Set<string>()
|
|
|
|
// Index edges by source and target for quick lookup
|
|
const edgesBySource = new Map<string, Edge<EdgeData>[]>()
|
|
const edgesByTarget = new Map<string, Edge<EdgeData>[]>()
|
|
for (const e of edges) {
|
|
if (!edgesBySource.has(e.source)) edgesBySource.set(e.source, [])
|
|
edgesBySource.get(e.source)!.push(e)
|
|
if (!edgesByTarget.has(e.target)) edgesByTarget.set(e.target, [])
|
|
edgesByTarget.get(e.target)!.push(e)
|
|
}
|
|
|
|
const yamlNodes: YamlNode[] = []
|
|
|
|
for (const node of nodes) {
|
|
const d = node.data
|
|
|
|
// Skip groupRect nodes — they are canvas decoration only
|
|
if (d.type === 'groupRect') continue
|
|
|
|
const entry: YamlNode = {
|
|
nodeType: d.type,
|
|
label: d.label,
|
|
}
|
|
|
|
if (d.custom_icon) entry.nodeIcon = d.custom_icon
|
|
if (d.hostname) entry.hostname = d.hostname
|
|
if (d.ip) entry.ipAddress = d.ip
|
|
if (d.check_method && d.check_method !== 'none') entry.checkMethod = d.check_method
|
|
if (d.check_target) entry.checkTarget = d.check_target
|
|
if (d.notes) entry.notes = d.notes
|
|
|
|
// Hardware specs — omit zero values
|
|
if (d.cpu_model) entry.cpuModel = d.cpu_model
|
|
if (d.cpu_count && d.cpu_count > 0) entry.cpuCore = d.cpu_count
|
|
if (d.ram_gb && d.ram_gb > 0) entry.ram = d.ram_gb
|
|
if (d.disk_gb && d.disk_gb > 0) entry.disk = d.disk_gb
|
|
|
|
// Custom connection-point counts, so imported edges land on real slots.
|
|
attachHandleCounts(entry, d)
|
|
|
|
// Parent relationship: if this node has a parentId in React Flow,
|
|
// encode it as a 'parent' connection using any virtual edge between them.
|
|
if (node.parentId) {
|
|
const parentLabel = idToLabel.get(node.parentId) ?? node.parentId
|
|
// Find an edge between parent and this node (either direction)
|
|
const parentEdges = [
|
|
...(edgesBySource.get(node.parentId) ?? []).filter((e) => e.target === node.id),
|
|
...(edgesByTarget.get(node.parentId) ?? []).filter((e) => e.source === node.id),
|
|
]
|
|
const pEdge = parentEdges[0]
|
|
const linkType: EdgeType = (pEdge?.data?.type as EdgeType) ?? 'virtual'
|
|
const linkLabel = pEdge?.data?.label ?? ''
|
|
entry.parent = { label: parentLabel, linkType, linkLabel: linkLabel as string }
|
|
// Import always rebuilds this edge parent→child, so orient the handles the
|
|
// same way (swap when the stored edge runs child→parent).
|
|
if (pEdge) {
|
|
const parentToChild = pEdge.source === node.parentId
|
|
const srcH = parentToChild ? pEdge.sourceHandle : pEdge.targetHandle
|
|
const tgtH = parentToChild ? pEdge.targetHandle : pEdge.sourceHandle
|
|
if (srcH) entry.parent.sourceHandle = srcH
|
|
if (tgtH) entry.parent.targetHandle = tgtH
|
|
serializedEdges.add(pEdge.id)
|
|
}
|
|
}
|
|
|
|
// Outgoing edges (this node is the source):
|
|
// - cluster type → clusterR (Proxmox cluster link, directional)
|
|
// - everything else → links array (supports multiple connections)
|
|
const outgoingEdges = (edgesBySource.get(node.id) ?? []).filter(
|
|
(e) => !serializedEdges.has(e.id) && e.target !== node.parentId,
|
|
)
|
|
for (const e of outgoingEdges) {
|
|
const targetLabel = idToLabel.get(e.target)
|
|
if (!targetLabel) continue
|
|
const edgeType: EdgeType = (e.data?.type as EdgeType) ?? 'ethernet'
|
|
const edgeLabel = e.data?.label as string | undefined
|
|
const conn = makeConnection(targetLabel, edgeType, edgeLabel, e)
|
|
if (edgeType === 'cluster') {
|
|
if (!entry.clusterR) entry.clusterR = conn
|
|
} else {
|
|
entry.links = [...(entry.links ?? []), conn]
|
|
}
|
|
serializedEdges.add(e.id)
|
|
}
|
|
|
|
// Incoming cluster edges not yet serialized → clusterL
|
|
const incomingClusterEdges = (edgesByTarget.get(node.id) ?? []).filter(
|
|
(e) => !serializedEdges.has(e.id) && (e.data?.type as EdgeType) === 'cluster',
|
|
)
|
|
for (const e of incomingClusterEdges) {
|
|
const sourceLabel = idToLabel.get(e.source)
|
|
if (!sourceLabel) continue
|
|
const edgeLabel = e.data?.label as string | undefined
|
|
if (!entry.clusterL) entry.clusterL = makeConnection(sourceLabel, 'cluster', edgeLabel, e)
|
|
serializedEdges.add(e.id)
|
|
}
|
|
|
|
yamlNodes.push(entry)
|
|
}
|
|
|
|
return yaml.dump(yamlNodes, { lineWidth: -1, noRefs: true })
|
|
}
|
|
|
|
/** Trigger a browser file download with the given YAML content */
|
|
export function downloadYaml(content: string, filename = 'homelable-export.yaml'): void {
|
|
const blob = new Blob([content], { type: 'text/yaml;charset=utf-8' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = filename
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
URL.revokeObjectURL(url)
|
|
}
|