feat: Proxmox nested nodes — VM/LXC rendered inside resizable group container
- ProxmoxGroupNode: resizable bordered container with orange accent, header bar (icon, label, IP, status dot), NodeResizer on selection - nodeTypes: proxmox now uses ProxmoxGroupNode instead of BaseNode - NodeData: added parent_id field - canvasStore: addNode and loadCanvas wire parentId + extent='parent' for child nodes; loadCanvas sorts parents before children (RF requirement) - NodeModal: Parent Proxmox dropdown appears when type is VM or LXC, lists all Proxmox nodes on the canvas - App.tsx: passes proxmoxNodes to modals; child nodes positioned relative to parent (x:20, y:50); Proxmox nodes get width:300/height:200 on load - layout.ts: Dagre skips child nodes (parentId set); uses actual group dimensions for Proxmox nodes in rank calculation
This commit is contained in:
+16
-3
@@ -57,11 +57,13 @@ export default function App() {
|
|||||||
.then((res) => {
|
.then((res) => {
|
||||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||||
if (apiNodes.length > 0) {
|
if (apiNodes.length > 0) {
|
||||||
const rfNodes = apiNodes.map((n: NodeData & { id: string; pos_x: number; pos_y: number }) => ({
|
const rfNodes = apiNodes.map((n: NodeData & { id: string; pos_x: number; pos_y: number; parent_id?: string }) => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
type: n.type,
|
type: n.type,
|
||||||
position: { x: n.pos_x, y: n.pos_y },
|
position: { x: n.pos_x, y: n.pos_y },
|
||||||
data: n,
|
data: n,
|
||||||
|
...(n.parent_id ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
|
||||||
|
...(n.type === 'proxmox' ? { width: 300, height: 200 } : {}),
|
||||||
}))
|
}))
|
||||||
const rfEdges = apiEdges.map((e: EdgeData & { id: string; source: string; target: string }) => ({
|
const rfEdges = apiEdges.map((e: EdgeData & { id: string; source: string; target: string }) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
@@ -92,15 +94,24 @@ export default function App() {
|
|||||||
|
|
||||||
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
|
const isProxmox = data.type === 'proxmox'
|
||||||
|
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
|
||||||
|
// Children position is relative to parent; place near top-left with padding
|
||||||
|
const position = parentNode
|
||||||
|
? { x: 20, y: 50 }
|
||||||
|
: { x: 300, y: 300 }
|
||||||
|
|
||||||
const newNode: Node<NodeData> = {
|
const newNode: Node<NodeData> = {
|
||||||
id,
|
id,
|
||||||
type: data.type ?? 'generic',
|
type: data.type ?? 'generic',
|
||||||
position: { x: 300, y: 300 },
|
position,
|
||||||
data: { status: 'unknown', services: [], ...data } as NodeData,
|
data: { status: 'unknown', services: [], ...data } as NodeData,
|
||||||
|
...(data.parent_id ? { parentId: data.parent_id, extent: 'parent' as const } : {}),
|
||||||
|
...(isProxmox ? { width: 300, height: 200 } : {}),
|
||||||
}
|
}
|
||||||
addNode(newNode)
|
addNode(newNode)
|
||||||
toast.success(`Added "${data.label}"`)
|
toast.success(`Added "${data.label}"`)
|
||||||
}, [addNode])
|
}, [addNode, nodes])
|
||||||
|
|
||||||
const handleEditNode = useCallback((id: string) => {
|
const handleEditNode = useCallback((id: string) => {
|
||||||
setEditNodeId(id)
|
setEditNodeId(id)
|
||||||
@@ -172,6 +183,7 @@ export default function App() {
|
|||||||
onClose={() => setAddNodeOpen(false)}
|
onClose={() => setAddNodeOpen(false)}
|
||||||
onSubmit={handleAddNode}
|
onSubmit={handleAddNode}
|
||||||
title="Add Node"
|
title="Add Node"
|
||||||
|
proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* key forces re-mount when editing a different node, resetting form state */}
|
{/* key forces re-mount when editing a different node, resetting form state */}
|
||||||
@@ -182,6 +194,7 @@ export default function App() {
|
|||||||
onSubmit={handleUpdateNode}
|
onSubmit={handleUpdateNode}
|
||||||
initial={editNode?.data}
|
initial={editNode?.data}
|
||||||
title="Edit Node"
|
title="Edit Node"
|
||||||
|
proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EdgeModal
|
<EdgeModal
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||||
|
import { Layers } from 'lucide-react'
|
||||||
|
import type { NodeData, NodeStatus } from '@/types'
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<NodeStatus, string> = {
|
||||||
|
online: '#39d353',
|
||||||
|
offline: '#f85149',
|
||||||
|
pending: '#e3b341',
|
||||||
|
unknown: '#8b949e',
|
||||||
|
}
|
||||||
|
|
||||||
|
const GLOW = '#ff6e00'
|
||||||
|
|
||||||
|
export function ProxmoxGroupNode({ data, selected }: NodeProps<Node<NodeData>>) {
|
||||||
|
const statusColor = STATUS_COLORS[data.status]
|
||||||
|
const isOnline = data.status === 'online'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NodeResizer
|
||||||
|
minWidth={220}
|
||||||
|
minHeight={160}
|
||||||
|
isVisible={selected}
|
||||||
|
lineStyle={{ borderColor: GLOW, opacity: 0.6 }}
|
||||||
|
handleStyle={{ borderColor: GLOW, backgroundColor: '#21262d' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Group border */}
|
||||||
|
<div
|
||||||
|
className="w-full h-full rounded-xl border-2 flex flex-col overflow-hidden"
|
||||||
|
style={{
|
||||||
|
borderColor: selected ? GLOW : isOnline ? `${GLOW}66` : '#30363d',
|
||||||
|
background: isOnline ? `${GLOW}08` : '#0d111766',
|
||||||
|
boxShadow: isOnline
|
||||||
|
? `0 0 20px ${GLOW}1a, inset 0 0 40px ${GLOW}08`
|
||||||
|
: selected
|
||||||
|
? `0 0 12px ${GLOW}33`
|
||||||
|
: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header bar */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-2.5 py-1.5 shrink-0"
|
||||||
|
style={{ background: isOnline ? `${GLOW}18` : '#161b2288', borderBottom: `1px solid ${isOnline ? `${GLOW}33` : '#30363d'}` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center w-5 h-5 rounded-md shrink-0"
|
||||||
|
style={{ color: isOnline ? GLOW : '#8b949e', background: '#161b22' }}
|
||||||
|
>
|
||||||
|
<Layers size={12} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
|
<span className="text-[11px] font-semibold leading-tight truncate" style={{ color: isOnline ? GLOW : '#c9d1d9' }}>
|
||||||
|
{data.label}
|
||||||
|
</span>
|
||||||
|
{data.ip && (
|
||||||
|
<span className="font-mono text-[9px] text-[#8b949e] truncate">{data.ip}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Status dot */}
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: statusColor }} title={data.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inner area — React Flow places children here */}
|
||||||
|
<div className="flex-1 relative" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Handle type="target" position={Position.Top} className="!bg-[#30363d] !border-[#8b949e]" />
|
||||||
|
<Handle type="source" position={Position.Bottom} className="!bg-[#30363d] !border-[#8b949e]" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { IspNode, RouterNode, SwitchNode, ServerNode, ProxmoxNode, VmNode, LxcNode, NasNode, IotNode, ApNode, GenericNode } from './index'
|
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, GenericNode } from './index'
|
||||||
|
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||||
|
|
||||||
export const nodeTypes = {
|
export const nodeTypes = {
|
||||||
isp: IspNode,
|
isp: IspNode,
|
||||||
router: RouterNode,
|
router: RouterNode,
|
||||||
switch: SwitchNode,
|
switch: SwitchNode,
|
||||||
server: ServerNode,
|
server: ServerNode,
|
||||||
proxmox: ProxmoxNode,
|
proxmox: ProxmoxGroupNode,
|
||||||
vm: VmNode,
|
vm: VmNode,
|
||||||
lxc: LxcNode,
|
lxc: LxcNode,
|
||||||
nas: NasNode,
|
nas: NasNode,
|
||||||
|
|||||||
@@ -26,11 +26,14 @@ interface NodeModalProps {
|
|||||||
onSubmit: (data: Partial<NodeData>) => void
|
onSubmit: (data: Partial<NodeData>) => void
|
||||||
initial?: Partial<NodeData>
|
initial?: Partial<NodeData>
|
||||||
title?: string
|
title?: string
|
||||||
|
proxmoxNodes?: { id: string; label: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CHILD_TYPES: NodeType[] = ['vm', 'lxc']
|
||||||
|
|
||||||
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
||||||
// initial value is enough — no need for a reset effect.
|
// initial value is enough — no need for a reset effect.
|
||||||
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' }: NodeModalProps) {
|
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', proxmoxNodes = [] }: NodeModalProps) {
|
||||||
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
||||||
|
|
||||||
const set = (key: keyof NodeData, value: unknown) =>
|
const set = (key: keyof NodeData, value: unknown) =>
|
||||||
@@ -129,6 +132,27 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Parent Proxmox (VM / LXC only) */}
|
||||||
|
{CHILD_TYPES.includes(form.type as NodeType) && proxmoxNodes.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Parent Proxmox</Label>
|
||||||
|
<Select
|
||||||
|
value={form.parent_id ?? 'none'}
|
||||||
|
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
||||||
|
<SelectValue placeholder="None (standalone)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
|
<SelectItem value="none" className="text-sm">None (standalone)</SelectItem>
|
||||||
|
{proxmoxNodes.map((n) => (
|
||||||
|
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
<Label className="text-xs text-muted-foreground">Notes</Label>
|
<Label className="text-xs text-muted-foreground">Notes</Label>
|
||||||
|
|||||||
@@ -55,10 +55,17 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
setSelectedNode: (id) => set({ selectedNodeId: id }),
|
setSelectedNode: (id) => set({ selectedNodeId: id }),
|
||||||
|
|
||||||
addNode: (node) =>
|
addNode: (node) =>
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
nodes: [...state.nodes, node],
|
const enriched = node.data.parent_id
|
||||||
hasUnsavedChanges: true,
|
? { ...node, parentId: node.data.parent_id, extent: 'parent' as const }
|
||||||
})),
|
: node
|
||||||
|
// Parents must come before children in the array
|
||||||
|
const withoutNew = state.nodes.filter((n) => n.id !== node.id)
|
||||||
|
if (enriched.parentId) {
|
||||||
|
return { nodes: [...withoutNew, enriched], hasUnsavedChanges: true }
|
||||||
|
}
|
||||||
|
return { nodes: [...withoutNew, enriched], hasUnsavedChanges: true }
|
||||||
|
}),
|
||||||
|
|
||||||
updateNode: (id, data) =>
|
updateNode: (id, data) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -78,6 +85,10 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
|
|
||||||
markSaved: () => set({ hasUnsavedChanges: false }),
|
markSaved: () => set({ hasUnsavedChanges: false }),
|
||||||
|
|
||||||
loadCanvas: (nodes, edges) =>
|
loadCanvas: (nodes, edges) => {
|
||||||
set({ nodes, edges, hasUnsavedChanges: false, selectedNodeId: null }),
|
// React Flow requires parents before children in the array
|
||||||
|
const parents = nodes.filter((n) => !n.parentId)
|
||||||
|
const children = nodes.filter((n) => !!n.parentId)
|
||||||
|
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null })
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface NodeData extends Record<string, unknown> {
|
|||||||
last_seen?: string
|
last_seen?: string
|
||||||
response_time_ms?: number
|
response_time_ms?: number
|
||||||
notes?: string
|
notes?: string
|
||||||
|
parent_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EdgeData extends Record<string, unknown> {
|
export interface EdgeData extends Record<string, unknown> {
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ const NODE_HEIGHT = 52
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply Dagre hierarchical (top-to-bottom) layout to nodes and edges.
|
* Apply Dagre hierarchical (top-to-bottom) layout to nodes and edges.
|
||||||
* Returns new nodes with updated positions.
|
* Child nodes (parentId set) keep their relative position inside the parent — only
|
||||||
|
* top-level nodes are repositioned by Dagre.
|
||||||
*/
|
*/
|
||||||
export function applyDagreLayout(
|
export function applyDagreLayout(
|
||||||
nodes: Node<NodeData>[],
|
nodes: Node<NodeData>[],
|
||||||
@@ -17,22 +18,32 @@ export function applyDagreLayout(
|
|||||||
g.setDefaultEdgeLabel(() => ({}))
|
g.setDefaultEdgeLabel(() => ({}))
|
||||||
g.setGraph({ rankdir: 'TB', nodesep: 60, ranksep: 80 })
|
g.setGraph({ rankdir: 'TB', nodesep: 60, ranksep: 80 })
|
||||||
|
|
||||||
for (const node of nodes) {
|
const topLevel = nodes.filter((n) => !n.parentId)
|
||||||
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
|
|
||||||
|
for (const node of topLevel) {
|
||||||
|
const w = node.type === 'proxmox' ? (node.width ?? 300) : NODE_WIDTH
|
||||||
|
const h = node.type === 'proxmox' ? (node.height ?? 200) : NODE_HEIGHT
|
||||||
|
g.setNode(node.id, { width: w, height: h })
|
||||||
}
|
}
|
||||||
for (const edge of edges) {
|
for (const edge of edges) {
|
||||||
g.setEdge(edge.source, edge.target)
|
// Only add edges between top-level nodes
|
||||||
|
const srcTop = topLevel.some((n) => n.id === edge.source)
|
||||||
|
const tgtTop = topLevel.some((n) => n.id === edge.target)
|
||||||
|
if (srcTop && tgtTop) g.setEdge(edge.source, edge.target)
|
||||||
}
|
}
|
||||||
|
|
||||||
dagre.layout(g)
|
dagre.layout(g)
|
||||||
|
|
||||||
return nodes.map((node) => {
|
return nodes.map((node) => {
|
||||||
|
if (node.parentId) return node // keep children in place
|
||||||
const pos = g.node(node.id)
|
const pos = g.node(node.id)
|
||||||
|
const w = node.type === 'proxmox' ? (node.width ?? 300) : NODE_WIDTH
|
||||||
|
const h = node.type === 'proxmox' ? (node.height ?? 200) : NODE_HEIGHT
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
position: {
|
position: {
|
||||||
x: pos.x - NODE_WIDTH / 2,
|
x: pos.x - w / 2,
|
||||||
y: pos.y - NODE_HEIGHT / 2,
|
y: pos.y - h / 2,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user