diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py index eeb3874..a271d93 100644 --- a/backend/app/api/routes/scan.py +++ b/backend/app/api/routes/scan.py @@ -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, diff --git a/backend/tests/test_scan_proxmox_children.py b/backend/tests/test_scan_proxmox_children.py new file mode 100644 index 0000000..9dbcff3 --- /dev/null +++ b/backend/tests/test_scan_proxmox_children.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a168857..249f016 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 + let hostSizes: Record = {} + 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 = { 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) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d3bbb22..4adb0d7 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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(`/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(`/scan/pending/${id}/proxmox-children`), runs: () => api.get('/scan/runs'), run: (runId: string) => api.get(`/scan/runs/${runId}`), clearPending: () => api.delete('/scan/pending'), diff --git a/frontend/src/components/modals/DeviceInventoryModal.tsx b/frontend/src/components/modals/DeviceInventoryModal.tsx index 1e62c95..799d534 100644 --- a/frontend/src/components/modals/DeviceInventoryModal.tsx +++ b/frontend/src/components/modals/DeviceInventoryModal.tsx @@ -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 = {} + 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 = 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) }} /> + + setProxmoxPrompt(null)} + onConfirm={handleProxmoxConfirm} + /> ) } diff --git a/frontend/src/components/modals/ProxmoxApproveModal.tsx b/frontend/src/components/modals/ProxmoxApproveModal.tsx new file mode 100644 index 0000000..2c11977 --- /dev/null +++ b/frontend/src/components/modals/ProxmoxApproveModal.tsx @@ -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('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 ( + !v && onCancel()}> + + + + + Add {label(host)} to canvas + + + +
+ + + {includeChildren && ( +
+ Draw the guests as + + +
+ )} + + {includeChildren && guests.length > 0 && ( +
+ {guests.map((c) => { + const type = (c.type ?? c.suggested_type) ?? 'vm' + const Icon = type === 'lxc' ? Container : Box + const color = type === 'lxc' ? '#39d353' : '#00d4ff' + return ( +
+ + {label(c)} + {c.ip && ( + + {c.ip} + + )} +
+ ) + })} +
+ )} +
+ + + + + +
+
+ ) +} diff --git a/frontend/src/components/modals/__tests__/DeviceInventoryModal.test.tsx b/frontend/src/components/modals/__tests__/DeviceInventoryModal.test.tsx index 40d7f69..4d8ca2c 100644 --- a/frontend/src/components/modals/__tests__/DeviceInventoryModal.test.tsx +++ b/frontend/src/components/modals/__tests__/DeviceInventoryModal.test.tsx @@ -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() + 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() + 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() + 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()) + }) +}) diff --git a/frontend/src/components/modals/__tests__/ProxmoxApproveModal.test.tsx b/frontend/src/components/modals/__tests__/ProxmoxApproveModal.test.tsx new file mode 100644 index 0000000..8faf831 --- /dev/null +++ b/frontend/src/components/modals/__tests__/ProxmoxApproveModal.test.tsx @@ -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() + expect(container.querySelector('[role="dialog"]')).toBeNull() + }) + + it('lists the guests and counts them in the confirm button', () => { + render() + 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() + 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() + 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() + 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() + fireEvent.click(screen.getByRole('button', { name: /cancel/i })) + expect(props.onCancel).toHaveBeenCalled() + expect(props.onConfirm).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/proxmox/ProxmoxImportModal.tsx b/frontend/src/components/proxmox/ProxmoxImportModal.tsx index bf04ccb..fb8e3e9 100644 --- a/frontend/src/components/proxmox/ProxmoxImportModal.tsx +++ b/frontend/src/components/proxmox/ProxmoxImportModal.tsx @@ -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([]) const [checked, setChecked] = useState>(new Set()) const [importMode, setImportMode] = useState('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('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 + {importMode === 'canvas' && ( + + )}