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:
committed by
Pouzor - Rémy Jardient
parent
e988427bb2
commit
deff7a34d0
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user