Files
homelable/frontend/src/utils/proxmoxContainerLayout.ts
T
Pouzor deff7a34d0 feat(proxmox): optionally nest guests inside their host on the canvas
Two entry points now offer container mode, where a Proxmox host is drawn as
a box holding its VMs and LXCs instead of sitting beside them.

Import modal: an opt-in "Nest guests inside their host" checkbox, shown only
in "Inventory + canvas" mode. `onAddToCanvas` carries the choice as a
`ProxmoxCanvasMode` ('linked' | 'container').

Device Inventory: approving a Proxmox host opens a new ProxmoxApproveModal
asking whether its guests come along, and nested or linked. The guests come
from a new `GET /scan/pending/{id}/proxmox-children`, which resolves the
host -> guest `device_inventory_links` the Proxmox import records (cluster
links excluded, hidden rows skipped). A host with no guests, or a failed
lookup, falls through to the plain single approve.

Placement lives in the new `utils/proxmoxContainerLayout`: `groupProxmoxGuests`
splits a selection into hosts, guests and loose nodes (one parent per guest),
and `layoutProxmoxContainers` grids the guests inside their host box, hosts
side by side, anything left over below. Positions are absolute — `addNode`
turns them relative when it nests. A host skipped as a same-canvas duplicate
leaves its guests loose rather than orphaning a parent_id.

Also extracts `inventoryNodeData()` so the single, bulk and Proxmox approve
paths build node data from one place.

ha-relevant: maybe
2026-09-02 21:14:44 +02:00

162 lines
5.2 KiB
TypeScript

/**
* Placement for a Proxmox import that nests guests inside their host.
*
* Container mode draws the host as a box (`data.container_mode`) and parents its
* VMs/LXCs to it (React Flow `parentId` + `extent: 'parent'`). `addNode` turns
* an absolute child position into a parent-relative one by subtracting the
* parent's position, so every coordinate here is absolute canvas space and the
* children are already offset by their host.
*
* Hosts sit side by side on one row; anything with no host in the selection
* ("loose") falls back to the plain grid the non-container import uses, placed
* below the tallest host.
*/
export const CONTAINER_LAYOUT = {
childWidth: 180,
childHeight: 52,
/** Room above the first child row for the host's own header/label. */
padTop: 60,
padX: 20,
padBottom: 20,
gapX: 16,
gapY: 14,
maxCols: 3,
hostGap: 60,
/** Same defaults `setProxmoxContainerMode` gives a host with no size yet. */
minHostWidth: 300,
minHostHeight: 200,
looseCols: 4,
looseSpacingX: 190,
looseSpacingY: 110,
} as const
export interface XY { x: number; y: number }
export interface Size { width: number; height: number }
export interface ProxmoxContainerLayout {
/** Absolute canvas position per node id (hosts, nested guests, loose nodes). */
positions: Record<string, XY>
/** Box size per host id — what the container needs to hold its guests. */
hostSizes: Record<string, Size>
}
export function layoutProxmoxContainers(
hostIds: string[],
childrenByHost: Record<string, string[]>,
looseIds: string[],
origin: XY,
): ProxmoxContainerLayout {
const C = CONTAINER_LAYOUT
const positions: Record<string, XY> = {}
const hostSizes: Record<string, Size> = {}
let cursorX = origin.x
let tallestHost = 0
for (const hostId of hostIds) {
const children = childrenByHost[hostId] ?? []
const cols = Math.max(1, Math.min(C.maxCols, children.length))
const rows = Math.ceil(children.length / cols)
const width = Math.max(
C.minHostWidth,
C.padX * 2 + cols * C.childWidth + (cols - 1) * C.gapX,
)
const height = Math.max(
C.minHostHeight,
C.padTop + C.padBottom + rows * C.childHeight + Math.max(0, rows - 1) * C.gapY,
)
positions[hostId] = { x: cursorX, y: origin.y }
hostSizes[hostId] = { width, height }
children.forEach((childId, i) => {
positions[childId] = {
x: cursorX + C.padX + (i % cols) * (C.childWidth + C.gapX),
y: origin.y + C.padTop + Math.floor(i / cols) * (C.childHeight + C.gapY),
}
})
cursorX += width + C.hostGap
tallestHost = Math.max(tallestHost, height)
}
const looseY = origin.y + (hostIds.length > 0 ? tallestHost + C.hostGap : 0)
looseIds.forEach((id, i) => {
positions[id] = {
x: origin.x + (i % C.looseCols) * C.looseSpacingX,
y: looseY + Math.floor(i / C.looseCols) * C.looseSpacingY,
}
})
return { positions, hostSizes }
}
/**
* Total bounding size of the layout, so a caller can centre it in the viewport
* before it knows any coordinate (`getCenteredPosition(w, h)`).
*/
export function measureProxmoxContainers(
hostIds: string[],
childrenByHost: Record<string, string[]>,
looseCount: number,
): Size {
const { positions, hostSizes } = layoutProxmoxContainers(
hostIds,
childrenByHost,
Array.from({ length: looseCount }, (_, i) => `__loose-${i}`),
{ x: 0, y: 0 },
)
let width = 0
let height = 0
for (const [id, pos] of Object.entries(positions)) {
const size = hostSizes[id]
width = Math.max(width, pos.x + (size?.width ?? CONTAINER_LAYOUT.childWidth))
height = Math.max(height, pos.y + (size?.height ?? CONTAINER_LAYOUT.childHeight))
}
return { width, height }
}
export interface GuestGrouping {
/** Host node ids, in the order they were given. */
hostIds: string[]
/** Guest node ids per host, in edge order. */
childrenByHost: Record<string, string[]>
/** Host id each guest belongs to. */
hostOfChild: Record<string, string>
/** Selected ids that are neither a host nor a guest of a selected host. */
looseIds: string[]
}
/**
* Split a Proxmox selection into hosts, their guests and everything left over.
*
* A guest reaches at most one host: a second edge naming it is ignored rather
* than moving it, since React Flow gives a node one parent. Edges pointing
* outside the selection, or from host to host (a cluster link), are skipped.
*/
export function groupProxmoxGuests(
nodes: { id: string; type: string }[],
edges: { source: string; target: string }[],
): GuestGrouping {
const selected = new Set(nodes.map((n) => n.id))
const hostIds = nodes.filter((n) => n.type === 'proxmox').map((n) => n.id)
const hostIdSet = new Set(hostIds)
const childrenByHost: Record<string, string[]> = {}
const hostOfChild: Record<string, string> = {}
hostIds.forEach((id) => { childrenByHost[id] = [] })
for (const e of edges) {
if (!hostIdSet.has(e.source) || !selected.has(e.target)) continue
if (hostIdSet.has(e.target) || hostOfChild[e.target]) continue
childrenByHost[e.source].push(e.target)
hostOfChild[e.target] = e.source
}
const looseIds = nodes
.filter((n) => !hostIdSet.has(n.id) && !hostOfChild[n.id])
.map((n) => n.id)
return { hostIds, childrenByHost, hostOfChild, looseIds }
}