+15
-1
@@ -6,6 +6,7 @@ import { generateUUID } from '@/utils/uuid'
|
|||||||
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||||
import { exportToPng } from '@/utils/export'
|
import { exportToPng } from '@/utils/export'
|
||||||
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
||||||
|
import { parseYamlToCanvas } from '@/utils/importYaml'
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -33,7 +34,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
|||||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
|
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
const { activeTheme, setTheme } = useThemeStore()
|
const { activeTheme, setTheme } = useThemeStore()
|
||||||
@@ -379,6 +380,18 @@ export default function App() {
|
|||||||
toast.success('Canvas exported as YAML')
|
toast.success('Canvas exported as YAML')
|
||||||
}, [nodes, edges])
|
}, [nodes, edges])
|
||||||
|
|
||||||
|
const handleImportYaml = useCallback((content: string) => {
|
||||||
|
try {
|
||||||
|
const { nodes: merged, edges: mergedEdges, imported } = parseYamlToCanvas(content, nodes, edges)
|
||||||
|
snapshotHistory()
|
||||||
|
loadCanvas(merged, mergedEdges)
|
||||||
|
markUnsaved()
|
||||||
|
toast.success(`Imported ${imported} node${imported !== 1 ? 's' : ''}`)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Import failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
}
|
||||||
|
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
|
||||||
|
|
||||||
const handleExport = useCallback(async () => {
|
const handleExport = useCallback(async () => {
|
||||||
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
||||||
if (!el) { toast.error('Canvas not ready'); return }
|
if (!el) { toast.error('Canvas not ready'); return }
|
||||||
@@ -458,6 +471,7 @@ export default function App() {
|
|||||||
onShortcuts={() => setShortcutsOpen(true)}
|
onShortcuts={() => setShortcutsOpen(true)}
|
||||||
onExportMd={handleExportMd}
|
onExportMd={handleExportMd}
|
||||||
onExportYaml={handleExportYaml}
|
onExportYaml={handleExportYaml}
|
||||||
|
onImportYaml={handleImportYaml}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2, FileDown } from 'lucide-react'
|
import { useRef } from 'react'
|
||||||
|
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2, FileDown, Upload } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
@@ -13,10 +14,24 @@ interface ToolbarProps {
|
|||||||
onShortcuts: () => void
|
onShortcuts: () => void
|
||||||
onExportMd: () => void
|
onExportMd: () => void
|
||||||
onExportYaml: () => void
|
onExportYaml: () => void
|
||||||
|
onImportYaml: (content: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd, onExportYaml }: ToolbarProps) {
|
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd, onExportYaml, onImportYaml }: ToolbarProps) {
|
||||||
const { hasUnsavedChanges, past, future } = useCanvasStore()
|
const { hasUnsavedChanges, past, future } = useCanvasStore()
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
const content = ev.target?.result
|
||||||
|
if (typeof content === 'string') onImportYaml(content)
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex items-center gap-2 px-4 py-2 border-b border-border bg-[#161b22] shrink-0">
|
<header className="flex items-center gap-2 px-4 py-2 border-b border-border bg-[#161b22] shrink-0">
|
||||||
@@ -47,6 +62,16 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
|||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onChangeStyle}>
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onChangeStyle}>
|
||||||
<Palette size={14} /> Style
|
<Palette size={14} /> Style
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
|
||||||
|
<Upload size={14} /> Import
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".yaml,.yml"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport} title="Export as PNG">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport} title="Export as PNG">
|
||||||
<Download size={14} /> Export
|
<Download size={14} /> Export
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { EdgeType, NodeType } from '@/types'
|
import type { NodeType, EdgeType, CheckMethod } from '@/types'
|
||||||
|
|
||||||
export interface YamlNodeConnection {
|
export interface YamlNodeConnection {
|
||||||
label: string
|
label: string
|
||||||
linkType: EdgeType
|
linkType?: EdgeType
|
||||||
linkLabel: string
|
linkLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface YamlNode {
|
export interface YamlNode {
|
||||||
@@ -12,7 +12,7 @@ export interface YamlNode {
|
|||||||
label: string
|
label: string
|
||||||
hostname?: string
|
hostname?: string
|
||||||
ipAddress?: string
|
ipAddress?: string
|
||||||
checkMethod?: string
|
checkMethod?: CheckMethod
|
||||||
checkTarget?: string
|
checkTarget?: string
|
||||||
notes?: string
|
notes?: string
|
||||||
parent?: YamlNodeConnection
|
parent?: YamlNodeConnection
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { parseYamlToCanvas } from '../importYaml'
|
||||||
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
|
||||||
|
// Mock dagre layout to return nodes with predictable positions
|
||||||
|
vi.mock('../layout', () => ({
|
||||||
|
applyDagreLayout: (nodes: Node<NodeData>[]) =>
|
||||||
|
nodes.map((n, i) => ({ ...n, position: { x: i * 200, y: 0 } })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock uuid to return deterministic ids
|
||||||
|
let uuidCounter = 0
|
||||||
|
vi.mock('../uuid', () => ({
|
||||||
|
generateUUID: () => `test-uuid-${++uuidCounter}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
uuidCounter = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const empty: Node<NodeData>[] = []
|
||||||
|
const emptyEdges: Edge<EdgeData>[] = []
|
||||||
|
|
||||||
|
describe('parseYamlToCanvas', () => {
|
||||||
|
it('parses a minimal node (only nodeType + label)', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: server
|
||||||
|
label: "My Server"
|
||||||
|
`
|
||||||
|
const { nodes, edges, imported } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
expect(imported).toBe(1)
|
||||||
|
expect(nodes).toHaveLength(1)
|
||||||
|
expect(nodes[0].data.label).toBe('My Server')
|
||||||
|
expect(nodes[0].data.type).toBe('server')
|
||||||
|
expect(nodes[0].data.status).toBe('unknown')
|
||||||
|
expect(edges).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses all scalar fields', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE1"
|
||||||
|
hostname: "pve1.local"
|
||||||
|
ipAddress: "192.168.1.10"
|
||||||
|
checkMethod: ping
|
||||||
|
checkTarget: "192.168.1.10"
|
||||||
|
notes: "main host"
|
||||||
|
nodeIcon: "custom-icon"
|
||||||
|
cpuModel: "Intel Xeon"
|
||||||
|
cpuCore: 16
|
||||||
|
ram: 64
|
||||||
|
disk: 2000
|
||||||
|
`
|
||||||
|
const { nodes } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
const d = nodes[0].data
|
||||||
|
expect(d.hostname).toBe('pve1.local')
|
||||||
|
expect(d.ip).toBe('192.168.1.10')
|
||||||
|
expect(d.check_method).toBe('ping')
|
||||||
|
expect(d.check_target).toBe('192.168.1.10')
|
||||||
|
expect(d.notes).toBe('main host')
|
||||||
|
expect(d.custom_icon).toBe('custom-icon')
|
||||||
|
expect(d.cpu_model).toBe('Intel Xeon')
|
||||||
|
expect(d.cpu_count).toBe(16)
|
||||||
|
expect(d.ram_gb).toBe(64)
|
||||||
|
expect(d.disk_gb).toBe(2000)
|
||||||
|
expect(d.show_hardware).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets show_hardware only when hardware fields present', () => {
|
||||||
|
const yaml = `- nodeType: server\n label: "NoHW"\n`
|
||||||
|
const { nodes } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
expect(nodes[0].data.show_hardware).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parent relationship sets parentId and creates an edge', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE1"
|
||||||
|
- nodeType: vm
|
||||||
|
label: "VM1"
|
||||||
|
parent:
|
||||||
|
label: "PVE1"
|
||||||
|
linkType: virtual
|
||||||
|
linkLabel: "hosted"
|
||||||
|
`
|
||||||
|
const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
const vm = nodes.find((n) => n.data.label === 'VM1')!
|
||||||
|
const pve = nodes.find((n) => n.data.label === 'PVE1')!
|
||||||
|
expect(vm.parentId).toBe(pve.id)
|
||||||
|
expect(vm.data.parent_id).toBe(pve.id)
|
||||||
|
expect(vm.extent).toBe('parent')
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
expect(edges[0].source).toBe(pve.id)
|
||||||
|
expect(edges[0].target).toBe(vm.id)
|
||||||
|
expect(edges[0].type).toBe('virtual')
|
||||||
|
expect(edges[0].data?.label).toBe('hosted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clusterR creates an edge from this node to target', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE1"
|
||||||
|
clusterR:
|
||||||
|
label: "PVE2"
|
||||||
|
linkType: ethernet
|
||||||
|
linkLabel: "10GbE"
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE2"
|
||||||
|
`
|
||||||
|
const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
const pve1 = nodes.find((n) => n.data.label === 'PVE1')!
|
||||||
|
const pve2 = nodes.find((n) => n.data.label === 'PVE2')!
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
expect(edges[0].source).toBe(pve1.id)
|
||||||
|
expect(edges[0].target).toBe(pve2.id)
|
||||||
|
expect(edges[0].type).toBe('ethernet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clusterL creates an edge from referenced node to this node', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE1"
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE2"
|
||||||
|
clusterL:
|
||||||
|
label: "PVE1"
|
||||||
|
linkType: cluster
|
||||||
|
linkLabel: ""
|
||||||
|
`
|
||||||
|
const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
const pve1 = nodes.find((n) => n.data.label === 'PVE1')!
|
||||||
|
const pve2 = nodes.find((n) => n.data.label === 'PVE2')!
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
expect(edges[0].source).toBe(pve1.id)
|
||||||
|
expect(edges[0].target).toBe(pve2.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates edges when clusterR on A and clusterL on B point to each other', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE1"
|
||||||
|
clusterR:
|
||||||
|
label: "PVE2"
|
||||||
|
linkType: ethernet
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE2"
|
||||||
|
clusterL:
|
||||||
|
label: "PVE1"
|
||||||
|
linkType: ethernet
|
||||||
|
`
|
||||||
|
const { edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips nodes with same label as existing canvas nodes', () => {
|
||||||
|
const existing: Node<NodeData>[] = [{
|
||||||
|
id: 'existing-1',
|
||||||
|
type: 'server',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: { label: 'ExistingServer', type: 'server', status: 'online', services: [] },
|
||||||
|
}]
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: server
|
||||||
|
label: "ExistingServer"
|
||||||
|
- nodeType: router
|
||||||
|
label: "NewRouter"
|
||||||
|
`
|
||||||
|
const { nodes, imported } = parseYamlToCanvas(yaml, existing, emptyEdges)
|
||||||
|
expect(imported).toBe(1)
|
||||||
|
expect(nodes.filter((n) => n.data.label === 'ExistingServer')).toHaveLength(1)
|
||||||
|
expect(nodes.filter((n) => n.data.label === 'NewRouter')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merges with existing edges without duplicating', () => {
|
||||||
|
const existing: Node<NodeData>[] = [
|
||||||
|
{ id: 'a', type: 'server', position: { x: 0, y: 0 }, data: { label: 'A', type: 'server', status: 'online', services: [] } },
|
||||||
|
{ id: 'b', type: 'server', position: { x: 0, y: 0 }, data: { label: 'B', type: 'server', status: 'online', services: [] } },
|
||||||
|
]
|
||||||
|
const existingEdge: Edge<EdgeData>[] = [{
|
||||||
|
id: 'e1', source: 'a', target: 'b', type: 'ethernet',
|
||||||
|
data: { type: 'ethernet' },
|
||||||
|
}]
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: server
|
||||||
|
label: "A"
|
||||||
|
clusterR:
|
||||||
|
label: "B"
|
||||||
|
linkType: ethernet
|
||||||
|
`
|
||||||
|
// A already exists so it's skipped, no new edge created
|
||||||
|
const { edges } = parseYamlToCanvas(yaml, existing, existingEdge)
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on invalid YAML', () => {
|
||||||
|
expect(() => parseYamlToCanvas('{invalid: [yaml', empty, emptyEdges)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when YAML is not an array', () => {
|
||||||
|
const yaml = `nodeType: server\nlabel: oops\n`
|
||||||
|
expect(() => parseYamlToCanvas(yaml, empty, emptyEdges)).toThrow(/list/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when nodeType is missing', () => {
|
||||||
|
const yaml = `- label: "Missing type"\n`
|
||||||
|
expect(() => parseYamlToCanvas(yaml, empty, emptyEdges)).toThrow(/nodeType/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when label is missing', () => {
|
||||||
|
const yaml = `- nodeType: server\n`
|
||||||
|
expect(() => parseYamlToCanvas(yaml, empty, emptyEdges)).toThrow(/label/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('warns and skips unknown parent label without crashing', () => {
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: vm
|
||||||
|
label: "OrphanVM"
|
||||||
|
parent:
|
||||||
|
label: "NonexistentHost"
|
||||||
|
linkType: virtual
|
||||||
|
`
|
||||||
|
const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
expect(nodes).toHaveLength(1)
|
||||||
|
expect(edges).toHaveLength(0)
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('NonexistentHost'))
|
||||||
|
warnSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import yaml from 'js-yaml'
|
||||||
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
import type { YamlNode, YamlNodeConnection } from '@/types/yaml'
|
||||||
|
import { generateUUID } from '@/utils/uuid'
|
||||||
|
import { applyDagreLayout } from '@/utils/layout'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a YAML string and merge the resulting nodes/edges into the existing canvas.
|
||||||
|
* - Nodes with the same label as an existing node are skipped (no duplicates).
|
||||||
|
* - Positions are computed via dagre auto-layout over the full merged set.
|
||||||
|
*/
|
||||||
|
export function parseYamlToCanvas(
|
||||||
|
yamlString: string,
|
||||||
|
existingNodes: Node<NodeData>[],
|
||||||
|
existingEdges: Edge<EdgeData>[],
|
||||||
|
): { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[]; imported: number } {
|
||||||
|
const raw = yaml.load(yamlString)
|
||||||
|
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
throw new Error('YAML must be a list of node objects (top-level array)')
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = raw as unknown[]
|
||||||
|
|
||||||
|
// Build lookup: label → existing node id (existing canvas + nodes being added)
|
||||||
|
const labelToId = new Map<string, string>()
|
||||||
|
for (const n of existingNodes) {
|
||||||
|
labelToId.set(n.data.label, n.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First pass: validate and create nodes (without positions — dagre will assign them)
|
||||||
|
const newNodes: Node<NodeData>[] = []
|
||||||
|
const yamlNodes: YamlNode[] = []
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const raw = entry as Record<string, unknown>
|
||||||
|
|
||||||
|
if (!raw.nodeType || typeof raw.nodeType !== 'string') {
|
||||||
|
throw new Error(`Each YAML entry must have a "nodeType" string field`)
|
||||||
|
}
|
||||||
|
if (!raw.label || typeof raw.label !== 'string') {
|
||||||
|
throw new Error(`Each YAML entry must have a "label" string field`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const yn = raw as unknown as YamlNode
|
||||||
|
|
||||||
|
// Skip if a node with this label already exists on the canvas
|
||||||
|
if (labelToId.has(yn.label)) {
|
||||||
|
console.warn(`[importYaml] Skipping duplicate label: "${yn.label}"`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = generateUUID()
|
||||||
|
labelToId.set(yn.label, id)
|
||||||
|
|
||||||
|
const hasHardware = !!(yn.cpuModel || yn.cpuCore || yn.ram || yn.disk)
|
||||||
|
|
||||||
|
const data: NodeData = {
|
||||||
|
label: yn.label,
|
||||||
|
type: yn.nodeType,
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
...(yn.hostname ? { hostname: yn.hostname } : {}),
|
||||||
|
...(yn.ipAddress ? { ip: yn.ipAddress } : {}),
|
||||||
|
...(yn.checkMethod ? { check_method: yn.checkMethod } : {}),
|
||||||
|
...(yn.checkTarget ? { check_target: yn.checkTarget } : {}),
|
||||||
|
...(yn.notes ? { notes: yn.notes } : {}),
|
||||||
|
...(yn.nodeIcon ? { custom_icon: yn.nodeIcon } : {}),
|
||||||
|
...(yn.cpuModel ? { cpu_model: yn.cpuModel } : {}),
|
||||||
|
...(yn.cpuCore ? { cpu_count: yn.cpuCore } : {}),
|
||||||
|
...(yn.ram ? { ram_gb: yn.ram } : {}),
|
||||||
|
...(yn.disk ? { disk_gb: yn.disk } : {}),
|
||||||
|
...(hasHardware ? { show_hardware: true } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
newNodes.push({
|
||||||
|
id,
|
||||||
|
type: yn.nodeType,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
|
||||||
|
yamlNodes.push(yn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: apply parent relationships (parentId / parent_id)
|
||||||
|
const newEdges: Edge<EdgeData>[] = []
|
||||||
|
// Track edge pairs to deduplicate (store as "sourceId|targetId")
|
||||||
|
const edgePairs = new Set<string>(
|
||||||
|
existingEdges.map((e) => `${e.source}|${e.target}`)
|
||||||
|
)
|
||||||
|
|
||||||
|
function addEdgeIfNew(
|
||||||
|
sourceId: string,
|
||||||
|
targetId: string,
|
||||||
|
conn: YamlNodeConnection,
|
||||||
|
) {
|
||||||
|
const key = `${sourceId}|${targetId}`
|
||||||
|
const reverseKey = `${targetId}|${sourceId}`
|
||||||
|
if (edgePairs.has(key) || edgePairs.has(reverseKey)) return
|
||||||
|
edgePairs.add(key)
|
||||||
|
const edgeType = conn.linkType ?? 'ethernet'
|
||||||
|
newEdges.push({
|
||||||
|
id: generateUUID(),
|
||||||
|
source: sourceId,
|
||||||
|
target: targetId,
|
||||||
|
type: edgeType,
|
||||||
|
data: {
|
||||||
|
type: edgeType,
|
||||||
|
...(conn.linkLabel ? { label: conn.linkLabel } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < newNodes.length; i++) {
|
||||||
|
const node = newNodes[i]
|
||||||
|
const yn = yamlNodes[i]
|
||||||
|
|
||||||
|
if (yn.parent) {
|
||||||
|
const parentId = labelToId.get(yn.parent.label)
|
||||||
|
if (!parentId) {
|
||||||
|
console.warn(`[importYaml] parent label not found: "${yn.parent.label}" — skipping relationship`)
|
||||||
|
} else {
|
||||||
|
// Set React Flow parentId for nesting
|
||||||
|
node.data = { ...node.data, parent_id: parentId }
|
||||||
|
node.parentId = parentId
|
||||||
|
node.extent = 'parent'
|
||||||
|
// Also create an edge
|
||||||
|
addEdgeIfNew(parentId, node.id, yn.parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (yn.clusterR) {
|
||||||
|
const targetId = labelToId.get(yn.clusterR.label)
|
||||||
|
if (!targetId) {
|
||||||
|
console.warn(`[importYaml] clusterR label not found: "${yn.clusterR.label}" — skipping`)
|
||||||
|
} else {
|
||||||
|
addEdgeIfNew(node.id, targetId, yn.clusterR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (yn.clusterL) {
|
||||||
|
const sourceId = labelToId.get(yn.clusterL.label)
|
||||||
|
if (!sourceId) {
|
||||||
|
console.warn(`[importYaml] clusterL label not found: "${yn.clusterL.label}" — skipping`)
|
||||||
|
} else {
|
||||||
|
addEdgeIfNew(sourceId, node.id, yn.clusterL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge and apply layout
|
||||||
|
const mergedNodes = [...existingNodes, ...newNodes]
|
||||||
|
const mergedEdges = [...existingEdges, ...newEdges]
|
||||||
|
const laidOut = applyDagreLayout(mergedNodes, mergedEdges)
|
||||||
|
|
||||||
|
return { nodes: laidOut, edges: mergedEdges, imported: newNodes.length }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user