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
This commit is contained in:
Pouzor
2026-09-02 21:14:44 +02:00
committed by Pouzor - Rémy Jardient
parent e988427bb2
commit deff7a34d0
13 changed files with 1071 additions and 30 deletions
+41
View File
@@ -574,6 +574,47 @@ async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_c
return await _with_canvas_counts(db, list(result.scalars().all()))
@router.get("/pending/{device_id}/proxmox-children", response_model=list[InventoryDeviceResponse])
async def list_proxmox_children(
device_id: str,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> list[InventoryDevice]:
"""Inventory rows for the guests a Proxmox host runs.
The Proxmox import records host -> guest as a ``device_inventory_links`` row
with ``discovery_source == "proxmox"``, keyed by IEEE. This resolves those
targets back to inventory rows so the UI can offer "add the children too"
when a host is placed on a canvas. Empty for anything that is not a host.
"""
device = await db.get(InventoryDevice, device_id)
if device is None:
raise HTTPException(status_code=404, detail="Device not found")
if not device.ieee_address:
return []
ieees = list(
(
await db.execute(
select(InventoryDeviceLink.target_ieee).where(
InventoryDeviceLink.source_ieee == device.ieee_address,
InventoryDeviceLink.discovery_source == "proxmox",
)
)
)
.scalars()
.all()
)
if not ieees:
return []
result = await db.execute(
select(InventoryDevice).where(
InventoryDevice.ieee_address.in_(ieees),
InventoryDevice.status != "hidden",
)
)
return await _with_canvas_counts(db, list(result.scalars().all()))
@router.post("/pending/bulk-approve", response_model=dict)
async def bulk_approve_devices(
payload: BulkActionRequest,
+103
View File
@@ -0,0 +1,103 @@
"""GET /api/v1/scan/pending/{id}/proxmox-children.
Resolves the host -> guest links the Proxmox import records into the inventory
rows the UI needs to offer "add the guests too" when a host reaches a canvas.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import InventoryDevice, InventoryDeviceLink
async def _seed(db: AsyncSession) -> dict[str, str]:
host = InventoryDevice(
ieee_address="pve-node-pve1", hostname="pve1", suggested_type="proxmox", status="pending"
)
vm = InventoryDevice(
ieee_address="pve-pve1-101", hostname="web", suggested_type="vm", status="pending"
)
lxc = InventoryDevice(
ieee_address="pve-pve1-102", hostname="dns", suggested_type="lxc", status="pending"
)
other = InventoryDevice(ip="192.168.1.9", hostname="nas", status="pending")
db.add_all([host, vm, lxc, other])
await db.flush()
db.add_all([
InventoryDeviceLink(
source_ieee="pve-node-pve1", target_ieee="pve-pve1-101", discovery_source="proxmox"
),
InventoryDeviceLink(
source_ieee="pve-node-pve1", target_ieee="pve-pve1-102", discovery_source="proxmox"
),
])
await db.commit()
return {"host": host.id, "vm": vm.id, "lxc": lxc.id, "other": other.id}
@pytest.mark.asyncio
async def test_returns_the_guests_of_a_host(client: AsyncClient, headers, db_session):
ids = await _seed(db_session)
res = await client.get(f"/api/v1/scan/pending/{ids['host']}/proxmox-children", headers=headers)
assert res.status_code == 200
assert {d["id"] for d in res.json()} == {ids["vm"], ids["lxc"]}
@pytest.mark.asyncio
async def test_empty_for_a_guest(client: AsyncClient, headers, db_session):
ids = await _seed(db_session)
res = await client.get(f"/api/v1/scan/pending/{ids['vm']}/proxmox-children", headers=headers)
assert res.status_code == 200
assert res.json() == []
@pytest.mark.asyncio
async def test_empty_for_a_device_with_no_ieee(client: AsyncClient, headers, db_session):
ids = await _seed(db_session)
res = await client.get(f"/api/v1/scan/pending/{ids['other']}/proxmox-children", headers=headers)
assert res.status_code == 200
assert res.json() == []
@pytest.mark.asyncio
async def test_skips_hidden_guests(client: AsyncClient, headers, db_session):
ids = await _seed(db_session)
lxc = await db_session.get(InventoryDevice, ids["lxc"])
lxc.status = "hidden"
await db_session.commit()
res = await client.get(f"/api/v1/scan/pending/{ids['host']}/proxmox-children", headers=headers)
assert [d["id"] for d in res.json()] == [ids["vm"]]
@pytest.mark.asyncio
async def test_ignores_cluster_links(client: AsyncClient, headers, db_session):
"""A host↔host cluster link is not a parent/child relation."""
ids = await _seed(db_session)
peer = InventoryDevice(
ieee_address="pve-node-pve2", hostname="pve2", suggested_type="proxmox", status="pending"
)
db_session.add(peer)
db_session.add(
InventoryDeviceLink(
source_ieee="pve-node-pve1",
target_ieee="pve-node-pve2",
discovery_source="proxmox_cluster",
)
)
await db_session.commit()
res = await client.get(f"/api/v1/scan/pending/{ids['host']}/proxmox-children", headers=headers)
assert {d["id"] for d in res.json()} == {ids["vm"], ids["lxc"]}
@pytest.mark.asyncio
async def test_404_for_an_unknown_device(client: AsyncClient, headers):
res = await client.get("/api/v1/scan/pending/nope/proxmox-children", headers=headers)
assert res.status_code == 404
@pytest.mark.asyncio
async def test_requires_auth(client: AsyncClient, db_session):
ids = await _seed(db_session)
res = await client.get(f"/api/v1/scan/pending/{ids['host']}/proxmox-children")
assert res.status_code == 401
+47 -9
View File
@@ -59,8 +59,9 @@ import { useRackStore } from '@/rack/store'
import type { NodeData, EdgeData, CustomStyleDef, DesignType, FloorMapConfig, NodeType } from '@/types'
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types'
import type { ProxmoxNode, ProxmoxEdge } from '@/components/proxmox/types'
import type { ProxmoxNode, ProxmoxEdge, ProxmoxCanvasMode } from '@/components/proxmox/types'
import { buildProxmoxClusterEdges } from '@/components/proxmox/clusterEdges'
import { groupProxmoxGuests, layoutProxmoxContainers, measureProxmoxContainers } from '@/utils/proxmoxContainerLayout'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
@@ -898,28 +899,63 @@ export default function App() {
markUnsaved()
}, [addNode, onConnect, snapshotHistory, markUnsaved])
const handleProxmoxAddToCanvas = useCallback((pmNodes: ProxmoxNode[], pmEdges: ProxmoxEdge[]) => {
const handleProxmoxAddToCanvas = useCallback((
pmNodes: ProxmoxNode[],
pmEdges: ProxmoxEdge[],
mode: ProxmoxCanvasMode = 'linked',
) => {
snapshotHistory()
const COLS = 4
const SPACING_X = 190
const SPACING_Y = 110
const cols = Math.min(COLS, pmNodes.length)
const rows = Math.ceil(pmNodes.length / COLS)
const origin = getCenteredPosition(cols * SPACING_X, rows * SPACING_Y)
// Multiple hosts from one import = a cluster → chain them via left/right
// 'cluster' edges. Those endpoints need one left + one right handle each
// (both default to 0), so grant them to the host nodes up front.
const clusterEdges = buildProxmoxClusterEdges(pmNodes)
const cluster = clusterEdges.length > 0
pmNodes.forEach((pn, i) => {
const col = i % COLS
const row = Math.floor(i / COLS)
const position = { x: origin.x + col * SPACING_X, y: origin.y + row * SPACING_Y }
// Container mode: each host becomes a box and its guests are nested inside
// it. Anything the import gives no host for stays a loose node below.
const { hostIds, childrenByHost, hostOfChild, looseIds } = groupProxmoxGuests(pmNodes, pmEdges)
const hostIdSet = new Set(hostIds)
let placement: Record<string, { x: number; y: number }>
let hostSizes: Record<string, { width: number; height: number }> = {}
if (mode === 'container') {
const measured = measureProxmoxContainers(hostIds, childrenByHost, looseIds.length)
const origin = getCenteredPosition(measured.width, measured.height)
const laid = layoutProxmoxContainers(hostIds, childrenByHost, looseIds, origin)
placement = laid.positions
hostSizes = laid.hostSizes
} else {
const cols = Math.min(COLS, pmNodes.length)
const rows = Math.ceil(pmNodes.length / COLS)
const origin = getCenteredPosition(cols * SPACING_X, rows * SPACING_Y)
placement = {}
pmNodes.forEach((pn, i) => {
placement[pn.id] = {
x: origin.x + (i % COLS) * SPACING_X,
y: origin.y + Math.floor(i / COLS) * SPACING_Y,
}
})
}
// Hosts first: addNode resolves `data.parent_id` against the nodes already
// in the store, so a guest added before its container would not nest.
const ordered = mode === 'container'
? [...pmNodes.filter((n) => hostIdSet.has(n.id)), ...pmNodes.filter((n) => !hostIdSet.has(n.id))]
: pmNodes
ordered.forEach((pn) => {
const position = placement[pn.id] ?? getCenteredPosition()
const isClusterHost = cluster && pn.type === 'proxmox'
const isContainerHost = mode === 'container' && hostIdSet.has(pn.id)
const parentId = mode === 'container' ? hostOfChild[pn.id] : undefined
const size = hostSizes[pn.id]
const newNode: import('@xyflow/react').Node<NodeData> = {
id: pn.id,
type: pn.type,
position,
...(isContainerHost && size ? { width: size.width, height: size.height } : {}),
data: {
label: pn.label,
type: pn.type as NodeData['type'],
@@ -930,6 +966,8 @@ export default function App() {
...(pn.ip ? { ip: pn.ip } : {}),
...(pn.hostname ? { hostname: pn.hostname } : {}),
...(isClusterHost ? { left_handles: 1, right_handles: 1 } : {}),
...(isContainerHost ? { container_mode: true } : {}),
...(parentId ? { parent_id: parentId } : {}),
},
}
addNode(newNode)
+6
View File
@@ -181,6 +181,12 @@ export const scanApi = {
rescanDevice: (id: string, opts?: { full_ports?: boolean; ports?: string; http_probe_enabled?: boolean; verify_tls?: boolean }) =>
api.post<ScanRunSummary>(`/scan/pending/${id}/rescan`, opts ?? {}),
hidden: () => api.get('/scan/hidden'),
/**
* Inventory rows for the guests a Proxmox host runs, resolved from the
* host→guest links the Proxmox import records. Empty for a non-host device.
*/
proxmoxChildren: (id: string) =>
api.get<InventoryEntry[]>(`/scan/pending/${id}/proxmox-children`),
runs: () => api.get('/scan/runs'),
run: (runId: string) => api.get<ScanRunSummary>(`/scan/runs/${runId}`),
clearPending: () => api.delete('/scan/pending'),
@@ -21,6 +21,8 @@ import { formatRelative, formatTimestamp } from '@/utils/timeFormat'
import { getCenteredPosition } from '@/utils/viewportCenter'
import { sourceBuckets, orderedSources, isRackDevice, SOURCE_META, type SourceBucket } from '@/utils/deviceSources'
import { isRackable } from '@/utils/rackable'
import { ProxmoxApproveModal, type ProxmoxApproveChoice } from '@/components/modals/ProxmoxApproveModal'
import { layoutProxmoxContainers, measureProxmoxContainers } from '@/utils/proxmoxContainerLayout'
interface DeviceInventoryModalProps {
open: boolean
@@ -113,6 +115,34 @@ function extractDuplicateConflict(err: unknown): DuplicateNodeConflict | null {
return null
}
/**
* The canvas node an inventory row becomes. Shared by every path that places a
* device (single approve, bulk approve, the Proxmox host + guests flow) so they
* cannot drift on labels, properties or the wireless status shortcut.
*/
function inventoryNodeData(d: InventoryEntry) {
const type = deviceType(d) ?? 'generic'
const zwave = isZwaveType(type)
const wireless = isZigbeeType(type) || zwave
return {
type,
data: {
label: deviceLabel(d),
type,
ip: d.ip ?? undefined,
mac: d.mac ?? undefined,
hostname: d.hostname ?? undefined,
status: wireless ? ('online' as const) : ('unknown' as const),
services: (d.services ?? []) as ServiceInfo[],
properties: zwave
? buildZwaveProperties(d)
: isZigbeeType(type)
? buildZigbeeProperties(d)
: [...(d.properties ?? []), ...buildMacProperty(d.mac)],
},
}
}
function injectAutoEdges(edges: AutoEdge[] | undefined) {
if (!edges || edges.length === 0) return
useCanvasStore.setState((state) => ({
@@ -145,6 +175,9 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
// Set when a single approve is refused because the same host is already on
// this design — the user decides: link to the existing node or duplicate.
const [dupPrompt, setDupPrompt] = useState<{ device: InventoryEntry; conflict: DuplicateNodeConflict } | null>(null)
// Set when a Proxmox host with guests is about to be placed — the user picks
// whether the guests come along, and nested or linked.
const [proxmoxPrompt, setProxmoxPrompt] = useState<{ host: InventoryEntry; children: InventoryEntry[] } | null>(null)
const load = useCallback(async () => {
// Tour/demo mode: show injected devices, never touch the backend.
@@ -351,7 +384,98 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
}
}
const handleApprove = (device: InventoryEntry) => approveDevice(device, false)
// A Proxmox host is rarely wanted on its own: ask about its guests first.
// Anything else — and a host with no guests in the inventory — goes straight
// through the normal single-device approve.
const handleApprove = (device: InventoryEntry) => {
if (deviceType(device) === 'proxmox') { void promptProxmoxChildren(device); return }
return approveDevice(device, false)
}
const promptProxmoxChildren = async (device: InventoryEntry) => {
let guests: InventoryEntry[] = []
try {
guests = (await scanApi.proxmoxChildren(device.id)).data
} catch {
// No link data (or the call failed): fall back to placing the host alone.
guests = []
}
if (guests.length === 0) { await approveDevice(device, false); return }
// Close the device-detail modal first: two Base UI dialogs open at once trap
// focus on the underlying one and the prompt never shows.
setSelected(null)
setProxmoxPrompt({ host: device, children: guests })
}
const handleProxmoxConfirm = async ({ childIds, mode }: ProxmoxApproveChoice) => {
const prompt = proxmoxPrompt
if (!prompt) return
setProxmoxPrompt(null)
if (childIds.length === 0) { await approveDevice(prompt.host, false); return }
const wanted = new Set(childIds)
const guests = prompt.children.filter((c) => wanted.has(c.id))
try {
const res = await scanApi.bulkApprove([prompt.host.id, ...guests.map((g) => g.id)], activeDesignId)
const deviceToNode: Record<string, string> = {}
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
const hostNodeId = deviceToNode[prompt.host.id]
const placedGuests = guests.filter((g) => deviceToNode[g.id])
const guestNodeIds = placedGuests.map((g) => deviceToNode[g.id])
// The host was skipped (already on this canvas): there is no container to
// nest into, so drop the guests in a plain grid instead.
const nested = mode === 'container' && !!hostNodeId
const hostIds = hostNodeId ? [hostNodeId] : []
const childrenByHost: Record<string, string[]> = hostNodeId
? { [hostNodeId]: nested ? guestNodeIds : [] }
: {}
const loose = nested ? [] : guestNodeIds
const measured = measureProxmoxContainers(hostIds, childrenByHost, loose.length)
const origin = getCenteredPosition(measured.width, measured.height)
const { positions, hostSizes } = layoutProxmoxContainers(hostIds, childrenByHost, loose, origin)
if (hostNodeId) {
const { type, data } = inventoryNodeData(prompt.host)
const size = hostSizes[hostNodeId]
addNode({
id: hostNodeId,
type,
position: positions[hostNodeId],
...(nested && size ? { width: size.width, height: size.height } : {}),
data: { ...data, ...(nested ? { container_mode: true } : {}) },
})
}
// Hosts are added first above — addNode resolves `parent_id` against what
// is already in the store.
placedGuests.forEach((g) => {
const nodeId = deviceToNode[g.id]
const { type, data } = inventoryNodeData(g)
addNode({
id: nodeId,
type,
position: positions[nodeId] ?? getCenteredPosition(),
data: { ...data, ...(nested && hostNodeId ? { parent_id: hostNodeId } : {}) },
})
})
injectAutoEdges(res.data.edges)
setSelected(null)
await load()
toast.success(
`Added ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}` +
(nested ? ' (nested in the host)' : ''),
)
const dupes = res.data.skipped_devices ?? []
if (dupes.length > 0) {
const names = dupes.slice(0, 3).map((d) => d.label).join(', ')
const more = dupes.length > 3 ? ` +${dupes.length - 3} more` : ''
toast.info(`${dupes.length} already on this canvas, skipped: ${names}${more}`)
}
} catch {
toast.error('Failed to add the Proxmox host to the canvas')
}
}
const goToExistingNode = (nodeId: string) => {
setSelectedNode(nodeId)
@@ -402,27 +526,12 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
approvedDevices.forEach((d, i) => {
const nodeId = deviceToNode[d.id]
if (!nodeId) return
const type = deviceType(d) ?? 'generic'
const zwave = isZwaveType(type)
const wireless = isZigbeeType(type) || zwave
const { type, data } = inventoryNodeData(d)
addNode({
id: nodeId,
type,
position: { x: origin.x + (i % 4) * 160, y: origin.y + Math.floor(i / 4) * 100 },
data: {
label: deviceLabel(d),
type,
ip: d.ip ?? undefined,
mac: d.mac ?? undefined,
hostname: d.hostname ?? undefined,
status: wireless ? ('online' as const) : ('unknown' as const),
services: (d.services ?? []) as ServiceInfo[],
properties: zwave
? buildZwaveProperties(d)
: isZigbeeType(type)
? buildZigbeeProperties(d)
: [...(d.properties ?? []), ...buildMacProperty(d.mac)],
},
data,
})
})
injectAutoEdges(res.data.edges)
@@ -809,6 +918,14 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
setSelected(saved)
}}
/>
<ProxmoxApproveModal
open={proxmoxPrompt !== null}
host={proxmoxPrompt?.host ?? null}
guests={proxmoxPrompt?.children ?? []}
onCancel={() => setProxmoxPrompt(null)}
onConfirm={handleProxmoxConfirm}
/>
</>
)
}
@@ -0,0 +1,166 @@
import { useState } from 'react'
import { Server, Box, Container, Plus } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import type { InventoryEntry } from '@/types'
import type { ProxmoxCanvasMode } from '@/components/proxmox/types'
const ACCENT = '#e57000'
export interface ProxmoxApproveChoice {
/** Guest inventory ids to place alongside the host. Empty = host only. */
childIds: string[]
mode: ProxmoxCanvasMode
}
interface ProxmoxApproveModalProps {
open: boolean
host: InventoryEntry | null
/** Guests the host runs, from `scanApi.proxmoxChildren`. Deliberately not
* named `children` — React would treat the array as renderable child nodes. */
guests: InventoryEntry[]
onCancel: () => void
onConfirm: (choice: ProxmoxApproveChoice) => void
}
function label(d: InventoryEntry): string {
return d.label ?? d.friendly_name ?? d.hostname ?? d.ip ?? d.ieee_address ?? 'device'
}
/**
* Asked before a Proxmox host from the Device Inventory reaches a canvas: bring
* its VMs/LXCs along, and if so draw them nested inside the host
* (`container_mode`) or as separate nodes joined by virtual edges.
*
* Only shown when the host actually has guests in the inventory — a host with
* none is approved straight away.
*/
export function ProxmoxApproveModal({
open,
host,
guests,
onCancel,
onConfirm,
}: ProxmoxApproveModalProps) {
const [includeChildren, setIncludeChildren] = useState(true)
const [mode, setMode] = useState<ProxmoxCanvasMode>('container')
if (!host) return null
const confirm = () => {
onConfirm({
childIds: includeChildren ? guests.map((c) => c.id) : [],
mode: includeChildren ? mode : 'linked',
})
// Next host starts from the defaults again.
setIncludeChildren(true)
setMode('container')
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onCancel()}>
<DialogContent className="bg-[#161b22] border-border max-w-lg">
<DialogHeader>
<DialogTitle className="text-foreground flex items-center gap-2">
<Server size={16} style={{ color: ACCENT }} />
Add {label(host)} to canvas
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-1">
<label className="flex items-start gap-2 rounded-md border border-border bg-[#0d1117]/60 px-3 py-2.5 text-xs cursor-pointer">
<input
type="checkbox"
checked={includeChildren}
onChange={(e) => setIncludeChildren(e.target.checked)}
className="w-3 h-3 mt-0.5 cursor-pointer shrink-0"
style={{ accentColor: ACCENT }}
/>
<span>
<span className="block text-foreground">
Also add its {guests.length} guest{guests.length !== 1 ? 's' : ''}
</span>
<span className="block text-[11px] text-muted-foreground">
VMs and LXC containers this host runs, as recorded by the Proxmox import.
</span>
</span>
</label>
{includeChildren && (
<div className="space-y-2 rounded-md border border-border bg-[#0d1117]/60 px-3 py-2.5">
<span className="block text-xs text-muted-foreground">Draw the guests as</span>
<label className="flex items-start gap-2 text-xs cursor-pointer text-foreground">
<input
type="radio"
name="proxmox-approve-mode"
checked={mode === 'container'}
onChange={() => setMode('container')}
className="mt-0.5 cursor-pointer shrink-0"
style={{ accentColor: ACCENT }}
/>
<span>
Nested inside the host
<span className="block text-[11px] text-muted-foreground">
Container mode — the host becomes a box holding its guests.
</span>
</span>
</label>
<label className="flex items-start gap-2 text-xs cursor-pointer text-foreground">
<input
type="radio"
name="proxmox-approve-mode"
checked={mode === 'linked'}
onChange={() => setMode('linked')}
className="mt-0.5 cursor-pointer shrink-0"
style={{ accentColor: ACCENT }}
/>
<span>
Separate nodes linked to the host
<span className="block text-[11px] text-muted-foreground">
Guests sit beside the host, joined by virtual edges.
</span>
</span>
</label>
</div>
)}
{includeChildren && guests.length > 0 && (
<div className="max-h-48 overflow-y-auto space-y-1">
{guests.map((c) => {
const type = (c.type ?? c.suggested_type) ?? 'vm'
const Icon = type === 'lxc' ? Container : Box
const color = type === 'lxc' ? '#39d353' : '#00d4ff'
return (
<div
key={c.id}
className="flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs"
>
<Icon size={12} style={{ color }} className="shrink-0" />
<span className="text-foreground truncate">{label(c)}</span>
{c.ip && (
<span className="font-mono text-[10px] text-muted-foreground truncate ml-auto">
{c.ip}
</span>
)}
</div>
)
})}
</div>
)}
</div>
<DialogFooter className="gap-2">
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
<Button
onClick={confirm}
style={{ background: ACCENT, color: '#0d1117' }}
className="gap-1.5"
>
<Plus size={13} />
Add {includeChildren ? guests.length + 1 : 1} to canvas
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -15,6 +15,7 @@ const mockPending = vi.fn()
const mockHidden = vi.fn()
const mockAddNode = vi.fn()
const mockSetSelectedNode = vi.fn()
const mockProxmoxChildren = vi.fn()
vi.mock('@/api/client', () => ({
scanApi: {
@@ -28,6 +29,7 @@ vi.mock('@/api/client', () => ({
bulkHide: (...a: unknown[]) => mockBulkHide(...a),
restore: (...a: unknown[]) => mockRestore(...a),
bulkRestore: (...a: unknown[]) => mockBulkRestore(...a),
proxmoxChildren: (...a: unknown[]) => mockProxmoxChildren(...a),
},
}))
@@ -148,6 +150,7 @@ beforeEach(() => {
mockHidden.mockResolvedValue({ data: [] })
mockApprove.mockResolvedValue({ data: { node_id: 'n1', edges: [], edges_created: 0 } })
mockHide.mockResolvedValue({ data: {} })
mockProxmoxChildren.mockResolvedValue({ data: [] })
mockBulkApprove.mockResolvedValue({
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], edges: [], edges_created: 0, skipped_devices: [] },
})
@@ -760,3 +763,130 @@ describe('DeviceInventoryModal — the curated type wins over the discovery gues
expect(mockApprove.mock.calls[0][1]).toMatchObject({ type: 'printer', label: 'Office printer' })
})
})
// --- Proxmox host: ask about the guests before placing it ---
const PVE_HOST = {
id: 'dev-pve',
ip: '10.0.0.1',
hostname: 'pve1',
mac: null,
os: null,
services: [],
suggested_type: 'proxmox',
status: 'pending',
discovery_source: 'proxmox',
ieee_address: 'pve-node-pve1',
friendly_name: 'pve1',
discovered_at: '2026-01-07T00:00:00Z',
}
const PVE_GUESTS = [
{ ...DEVICE_PROXMOX, id: 'dev-g1', friendly_name: 'web' },
{ ...DEVICE_PROXMOX, id: 'dev-g2', friendly_name: 'dns', suggested_type: 'lxc', ieee_address: 'pve-pve1-102' },
]
async function openProxmoxPrompt() {
mockPending.mockResolvedValue({ data: [PVE_HOST] })
mockProxmoxChildren.mockResolvedValue({ data: PVE_GUESTS })
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-pve')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-pve'))
fireEvent.click(screen.getByTestId('do-approve'))
await waitFor(() => expect(screen.getByRole('button', { name: /add 3 to canvas/i })).toBeInTheDocument())
}
describe('DeviceInventoryModal — Proxmox host approval', () => {
beforeEach(() => {
mockBulkApprove.mockResolvedValue({
data: {
approved: 3,
node_ids: ['n-host', 'n-g1', 'n-g2'],
device_ids: ['dev-pve', 'dev-g1', 'dev-g2'],
edges: [], edges_created: 0, skipped: 0, skipped_devices: [],
},
})
})
it('approves a host with no guests straight away, no prompt', async () => {
mockPending.mockResolvedValue({ data: [PVE_HOST] })
mockProxmoxChildren.mockResolvedValue({ data: [] })
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-pve')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-pve'))
fireEvent.click(screen.getByTestId('do-approve'))
await waitFor(() => expect(mockApprove).toHaveBeenCalled())
expect(mockBulkApprove).not.toHaveBeenCalled()
})
it('asks about the guests when the host has some', async () => {
await openProxmoxPrompt()
expect(screen.getByText('web')).toBeInTheDocument()
expect(screen.getByText('dns')).toBeInTheDocument()
expect(mockApprove).not.toHaveBeenCalled()
})
it('nests the guests in the host in container mode', async () => {
await openProxmoxPrompt()
fireEvent.click(screen.getByRole('button', { name: /add 3 to canvas/i }))
await waitFor(() =>
expect(mockBulkApprove).toHaveBeenCalledWith(['dev-pve', 'dev-g1', 'dev-g2'], null),
)
const added = mockAddNode.mock.calls.map((c) => c[0])
const hostNode = added.find((n) => n.id === 'n-host')
expect(hostNode.data.container_mode).toBe(true)
expect(hostNode.width).toBeGreaterThan(0)
// The host must be added before its guests — addNode resolves parent_id
// against what is already in the store.
expect(added[0].id).toBe('n-host')
added.filter((n) => n.id !== 'n-host').forEach((n) => {
expect(n.data.parent_id).toBe('n-host')
})
})
it('leaves the guests unnested in linked mode', async () => {
await openProxmoxPrompt()
fireEvent.click(screen.getByRole('radio', { name: /separate nodes linked to the host/i }))
fireEvent.click(screen.getByRole('button', { name: /add 3 to canvas/i }))
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalled())
const added = mockAddNode.mock.calls.map((c) => c[0])
expect(added.find((n) => n.id === 'n-host').data.container_mode).toBeUndefined()
added.forEach((n) => expect(n.data.parent_id).toBeUndefined())
})
it('falls back to the single approve when the guests are unticked', async () => {
await openProxmoxPrompt()
fireEvent.click(screen.getByRole('checkbox', { name: /also add its 2 guests/i }))
fireEvent.click(screen.getByRole('button', { name: /add 1 to canvas/i }))
await waitFor(() => expect(mockApprove).toHaveBeenCalled())
expect(mockBulkApprove).not.toHaveBeenCalled()
})
it('places the guests loose when the host itself was skipped as a duplicate', async () => {
mockBulkApprove.mockResolvedValue({
data: {
approved: 2,
node_ids: ['n-g1', 'n-g2'],
device_ids: ['dev-g1', 'dev-g2'],
edges: [], edges_created: 0, skipped: 1,
skipped_devices: [{ device_id: 'dev-pve', label: 'pve1', match: 'ip', value: '10.0.0.1' }],
},
})
await openProxmoxPrompt()
fireEvent.click(screen.getByRole('button', { name: /add 3 to canvas/i }))
await waitFor(() => expect(mockAddNode).toHaveBeenCalled())
const added = mockAddNode.mock.calls.map((c) => c[0])
expect(added.map((n) => n.id)).toEqual(['n-g1', 'n-g2'])
added.forEach((n) => expect(n.data.parent_id).toBeUndefined())
})
it('still places the host when the children lookup fails', async () => {
mockPending.mockResolvedValue({ data: [PVE_HOST] })
mockProxmoxChildren.mockRejectedValue(new Error('boom'))
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-pve')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-pve'))
fireEvent.click(screen.getByTestId('do-approve'))
await waitFor(() => expect(mockApprove).toHaveBeenCalled())
})
})
@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { ProxmoxApproveModal } from '../ProxmoxApproveModal'
import type { InventoryEntry } from '@/types'
const host = {
id: 'h1', label: 'pve1', type: 'proxmox', status: 'pending', services: [],
} as unknown as InventoryEntry
const guests = [
{ id: 'g1', label: 'web', type: 'vm', ip: '10.0.0.5', status: 'pending', services: [] },
{ id: 'g2', label: 'dns', type: 'lxc', ip: '10.0.0.6', status: 'pending', services: [] },
] as unknown as InventoryEntry[]
const props = {
open: true,
host,
guests,
onCancel: vi.fn(),
onConfirm: vi.fn(),
}
describe('ProxmoxApproveModal', () => {
beforeEach(() => {
props.onCancel.mockReset()
props.onConfirm.mockReset()
})
it('renders nothing without a host', () => {
const { container } = render(<ProxmoxApproveModal {...props} host={null} />)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('lists the guests and counts them in the confirm button', () => {
render(<ProxmoxApproveModal {...props} />)
expect(screen.getByText('web')).toBeDefined()
expect(screen.getByText('dns')).toBeDefined()
expect(screen.getByRole('button', { name: /add 3 to canvas/i })).toBeDefined()
})
it('defaults to bringing the guests along, nested', () => {
render(<ProxmoxApproveModal {...props} />)
fireEvent.click(screen.getByRole('button', { name: /add 3 to canvas/i }))
expect(props.onConfirm).toHaveBeenCalledWith({ childIds: ['g1', 'g2'], mode: 'container' })
})
it('passes the linked mode when the user picks it', () => {
render(<ProxmoxApproveModal {...props} />)
fireEvent.click(screen.getByRole('radio', { name: /separate nodes linked to the host/i }))
fireEvent.click(screen.getByRole('button', { name: /add 3 to canvas/i }))
expect(props.onConfirm).toHaveBeenCalledWith({ childIds: ['g1', 'g2'], mode: 'linked' })
})
it('drops the guests — and the mode picker — when the box is unticked', () => {
render(<ProxmoxApproveModal {...props} />)
fireEvent.click(screen.getByRole('checkbox', { name: /also add its 2 guests/i }))
expect(screen.queryByRole('radio', { name: /nested inside the host/i })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /add 1 to canvas/i }))
expect(props.onConfirm).toHaveBeenCalledWith({ childIds: [], mode: 'linked' })
})
it('cancels without confirming', () => {
render(<ProxmoxApproveModal {...props} />)
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
expect(props.onCancel).toHaveBeenCalled()
expect(props.onConfirm).not.toHaveBeenCalled()
})
})
@@ -6,12 +6,12 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { proxmoxApi, type ProxmoxConnection } from '@/api/client'
import { toast } from 'sonner'
import type { ProxmoxNode, ProxmoxEdge, ProxmoxNodeType } from './types'
import type { ProxmoxNode, ProxmoxEdge, ProxmoxNodeType, ProxmoxCanvasMode } from './types'
interface ProxmoxImportModalProps {
open: boolean
onClose: () => void
onAddToCanvas: (nodes: ProxmoxNode[], edges: ProxmoxEdge[]) => void
onAddToCanvas: (nodes: ProxmoxNode[], edges: ProxmoxEdge[], mode: ProxmoxCanvasMode) => void
onInventoryImported?: () => void
}
@@ -62,6 +62,9 @@ export function ProxmoxImportModal({ open, onClose, onAddToCanvas, onInventoryIm
const [edges, setEdges] = useState<ProxmoxEdge[]>([])
const [checked, setChecked] = useState<Set<string>>(new Set())
const [importMode, setImportMode] = useState<ImportMode>('pending')
// Opt-in: draw each host as a box holding its guests instead of laying them
// out side by side and joining them with 'virtual' edges.
const [canvasMode, setCanvasMode] = useState<ProxmoxCanvasMode>('linked')
const updateField = (field: keyof ConnectionForm, value: string) =>
setForm((f) => ({ ...f, [field]: value }))
@@ -136,7 +139,7 @@ export function ProxmoxImportModal({ open, onClose, onAddToCanvas, onInventoryIm
const selectedDevices = devices.filter((d) => checked.has(d.id))
const selectedIds = new Set(selectedDevices.map((d) => d.id))
const selectedEdges = edges.filter((e) => selectedIds.has(e.source) && selectedIds.has(e.target))
onAddToCanvas(selectedDevices, selectedEdges)
onAddToCanvas(selectedDevices, selectedEdges, canvasMode)
toast.success(`Added ${selectedDevices.length} device${selectedDevices.length !== 1 ? 's' : ''} to canvas`)
onClose()
}
@@ -148,6 +151,7 @@ export function ProxmoxImportModal({ open, onClose, onAddToCanvas, onInventoryIm
setConnectionStatus('idle')
setConnectionMsg('')
setImportMode('pending')
setCanvasMode('linked')
onClose()
}
@@ -265,6 +269,24 @@ export function ProxmoxImportModal({ open, onClose, onAddToCanvas, onInventoryIm
</label>
</div>
</div>
{importMode === 'canvas' && (
<label className="flex items-start gap-2 rounded-md border border-border bg-[#0d1117]/60 px-3 py-2.5 text-xs cursor-pointer">
<input
type="checkbox"
checked={canvasMode === 'container'}
onChange={(e) => setCanvasMode(e.target.checked ? 'container' : 'linked')}
className="w-3 h-3 mt-0.5 cursor-pointer shrink-0"
style={{ accentColor: ACCENT }}
/>
<span>
<span className="block text-foreground">Nest guests inside their host</span>
<span className="block text-[11px] text-muted-foreground">
Container mode: each Proxmox host is drawn as a box holding its VMs and LXCs.
Off, they are separate nodes linked by virtual edges.
</span>
</span>
</label>
)}
<div className="flex flex-wrap gap-2 pt-1">
<Button
size="sm"
@@ -104,6 +104,45 @@ describe('ProxmoxImportModal', () => {
expect(toast.success).toHaveBeenCalledWith('Found 2 devices')
})
it('hides the container-mode option until canvas mode is picked', () => {
render(<ProxmoxImportModal {...defaultProps} />)
expect(screen.queryByRole('checkbox', { name: /nest guests inside their host/i })).toBeNull()
fireEvent.click(screen.getByRole('radio', { name: /inventory \+ canvas/i }))
expect(screen.getByRole('checkbox', { name: /nest guests inside their host/i })).toBeDefined()
})
it("adds to canvas in 'linked' mode by default", async () => {
vi.mocked(proxmoxApi.importNetwork).mockResolvedValue({
data: { nodes: sampleNodes, edges: [{ source: 'pve-node-pve1', target: 'pve-pve1-101' }], device_count: 2 },
} as never)
render(<ProxmoxImportModal {...defaultProps} />)
fireEvent.click(screen.getByRole('radio', { name: /inventory \+ canvas/i }))
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or pve.local'), { target: { value: 'pve' } })
fireEvent.click(screen.getByRole('button', { name: /fetch guests/i }))
await waitFor(() => expect(screen.getByText('pve1')).toBeDefined())
fireEvent.click(screen.getByRole('button', { name: /add 2 to canvas/i }))
expect(defaultProps.onAddToCanvas).toHaveBeenCalledWith(
expect.any(Array), expect.any(Array), 'linked',
)
})
it("passes 'container' when the nest option is ticked", async () => {
vi.mocked(proxmoxApi.importNetwork).mockResolvedValue({
data: { nodes: sampleNodes, edges: [{ source: 'pve-node-pve1', target: 'pve-pve1-101' }], device_count: 2 },
} as never)
render(<ProxmoxImportModal {...defaultProps} />)
fireEvent.click(screen.getByRole('radio', { name: /inventory \+ canvas/i }))
fireEvent.click(screen.getByRole('checkbox', { name: /nest guests inside their host/i }))
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or pve.local'), { target: { value: 'pve' } })
fireEvent.click(screen.getByRole('button', { name: /fetch guests/i }))
await waitFor(() => expect(screen.getByText('pve1')).toBeDefined())
fireEvent.click(screen.getByRole('button', { name: /add 2 to canvas/i }))
const [nodes, edges, mode] = vi.mocked(defaultProps.onAddToCanvas).mock.calls[0]
expect(mode).toBe('container')
expect(nodes).toHaveLength(2)
expect(edges).toHaveLength(1)
})
it('sends the token from the form in the payload', async () => {
vi.mocked(proxmoxApi.importNetwork).mockResolvedValue({
data: { nodes: [], edges: [], device_count: 0 },
+7
View File
@@ -31,3 +31,10 @@ export interface ProxmoxImportResponse {
edges: ProxmoxEdge[]
device_count: number
}
/** How a Proxmox selection is laid out when it reaches the canvas. */
export type ProxmoxCanvasMode =
/** Hosts and guests as sibling nodes, joined by 'virtual' edges. */
| 'linked'
/** Host drawn as a box with its guests nested inside (`container_mode`). */
| 'container'
@@ -0,0 +1,143 @@
import { describe, it, expect } from 'vitest'
import {
CONTAINER_LAYOUT as C,
groupProxmoxGuests,
layoutProxmoxContainers,
measureProxmoxContainers,
} from '@/utils/proxmoxContainerLayout'
const ORIGIN = { x: 100, y: 50 }
describe('layoutProxmoxContainers', () => {
it('places a lone host at the origin with the minimum box size', () => {
const { positions, hostSizes } = layoutProxmoxContainers(['h1'], { h1: [] }, [], ORIGIN)
expect(positions.h1).toEqual(ORIGIN)
expect(hostSizes.h1).toEqual({ width: C.minHostWidth, height: C.minHostHeight })
})
it('lays children out in rows inside the host, below its header band', () => {
const kids = ['a', 'b', 'c', 'd']
const { positions } = layoutProxmoxContainers(['h1'], { h1: kids }, [], ORIGIN)
// maxCols is 3, so d starts the second row.
expect(positions.a).toEqual({ x: ORIGIN.x + C.padX, y: ORIGIN.y + C.padTop })
expect(positions.b.y).toBe(positions.a.y)
expect(positions.c.y).toBe(positions.a.y)
expect(positions.b.x - positions.a.x).toBe(C.childWidth + C.gapX)
expect(positions.d).toEqual({
x: ORIGIN.x + C.padX,
y: ORIGIN.y + C.padTop + C.childHeight + C.gapY,
})
})
it('grows the host box to fit its children', () => {
const { hostSizes } = layoutProxmoxContainers(['h1'], { h1: ['a', 'b', 'c'] }, [], ORIGIN)
expect(hostSizes.h1.width).toBe(C.padX * 2 + 3 * C.childWidth + 2 * C.gapX)
expect(hostSizes.h1.height).toBe(C.minHostHeight)
})
it('every child sits inside its host box', () => {
const kids = ['a', 'b', 'c', 'd', 'e']
const { positions, hostSizes } = layoutProxmoxContainers(['h1'], { h1: kids }, [], ORIGIN)
const host = positions.h1
const size = hostSizes.h1
for (const k of kids) {
expect(positions[k].x).toBeGreaterThanOrEqual(host.x)
expect(positions[k].y).toBeGreaterThanOrEqual(host.y)
expect(positions[k].x + C.childWidth).toBeLessThanOrEqual(host.x + size.width)
expect(positions[k].y + C.childHeight).toBeLessThanOrEqual(host.y + size.height)
}
})
it('puts several hosts side by side without overlapping', () => {
const { positions, hostSizes } = layoutProxmoxContainers(
['h1', 'h2'],
{ h1: ['a', 'b', 'c'], h2: [] },
[],
ORIGIN,
)
expect(positions.h1.y).toBe(positions.h2.y)
expect(positions.h2.x).toBe(positions.h1.x + hostSizes.h1.width + C.hostGap)
})
it('drops loose nodes in a grid below the tallest host', () => {
const kids = Array.from({ length: 7 }, (_, i) => `k${i}`)
const { positions, hostSizes } = layoutProxmoxContainers(['h1'], { h1: kids }, ['x', 'y'], ORIGIN)
const looseY = ORIGIN.y + hostSizes.h1.height + C.hostGap
expect(positions.x).toEqual({ x: ORIGIN.x, y: looseY })
expect(positions.y).toEqual({ x: ORIGIN.x + C.looseSpacingX, y: looseY })
})
it('lays loose nodes out at the origin when there is no host', () => {
const { positions } = layoutProxmoxContainers([], {}, ['x'], ORIGIN)
expect(positions.x).toEqual(ORIGIN)
})
})
describe('measureProxmoxContainers', () => {
it('covers the whole layout', () => {
const hostIds = ['h1', 'h2']
const childrenByHost = { h1: ['a', 'b'], h2: ['c'] }
const size = measureProxmoxContainers(hostIds, childrenByHost, 3)
const { positions, hostSizes } = layoutProxmoxContainers(
hostIds,
childrenByHost,
['l0', 'l1', 'l2'],
{ x: 0, y: 0 },
)
for (const [id, pos] of Object.entries(positions)) {
const s = hostSizes[id]
expect(pos.x + (s?.width ?? C.childWidth)).toBeLessThanOrEqual(size.width)
expect(pos.y + (s?.height ?? C.childHeight)).toBeLessThanOrEqual(size.height)
}
})
})
describe('groupProxmoxGuests', () => {
const host = { id: 'h1', type: 'proxmox' }
const host2 = { id: 'h2', type: 'proxmox' }
const vm = { id: 'v1', type: 'vm' }
const lxc = { id: 'l1', type: 'lxc' }
it('buckets guests under the host that runs them', () => {
const g = groupProxmoxGuests([host, vm, lxc], [
{ source: 'h1', target: 'v1' },
{ source: 'h1', target: 'l1' },
])
expect(g.hostIds).toEqual(['h1'])
expect(g.childrenByHost).toEqual({ h1: ['v1', 'l1'] })
expect(g.hostOfChild).toEqual({ v1: 'h1', l1: 'h1' })
expect(g.looseIds).toEqual([])
})
it('gives a host with no guests an empty bucket', () => {
const g = groupProxmoxGuests([host, host2, vm], [{ source: 'h1', target: 'v1' }])
expect(g.childrenByHost).toEqual({ h1: ['v1'], h2: [] })
})
it('keeps the first host that claims a guest — a node has one parent', () => {
const g = groupProxmoxGuests([host, host2, vm], [
{ source: 'h1', target: 'v1' },
{ source: 'h2', target: 'v1' },
])
expect(g.childrenByHost).toEqual({ h1: ['v1'], h2: [] })
expect(g.hostOfChild.v1).toBe('h1')
})
it('ignores host-to-host cluster links', () => {
const g = groupProxmoxGuests([host, host2], [{ source: 'h1', target: 'h2' }])
expect(g.childrenByHost).toEqual({ h1: [], h2: [] })
expect(g.looseIds).toEqual([])
})
it('ignores an edge pointing outside the selection', () => {
const g = groupProxmoxGuests([host, vm], [{ source: 'h1', target: 'not-selected' }])
expect(g.childrenByHost).toEqual({ h1: [] })
expect(g.looseIds).toEqual(['v1'])
})
it('treats a guest with no host in the selection as loose', () => {
const g = groupProxmoxGuests([vm, lxc], [{ source: 'h1', target: 'v1' }])
expect(g.hostIds).toEqual([])
expect(g.looseIds).toEqual(['v1', 'l1'])
})
})
@@ -0,0 +1,161 @@
/**
* 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 }
}