feat: proxmox import diagnostics, node style fix, and cluster edges

Scan History:
- Proxmox is now a first-class scan kind (badge, filter chip, Server icon,
  completion toast) instead of being mislabeled as an IP scan.
- A done run carrying a non-fatal advisory renders amber (info) with a
  warning toast, distinct from red failures.

Import diagnostics:
- test-connection probes /access/permissions and warns when the API token
  has no ACL (VMs/LXC would be invisible) — points at the PVEAuditor grant.
- import surfaces an advisory when hosts import but no guests are visible
  (privilege-separated token whose rights are the intersection with the user),
  rather than a silent "done".

Node style:
- Proxmox container mode is now opt-in (container_mode === true), matching the
  rest of the codebase (App.tsx nesting logic). Imported proxmox nodes leave
  the flag unset and render like a manually-created node instead of an empty
  group container.

Cluster edges:
- Hosts from one import are chained with 'cluster' edges via left/right handles,
  distinct from the vertical host->guest 'virtual' edges. Wired for both the
  direct "Add to Canvas" path and the pending -> approve path (host<->host
  proxmox_cluster links, resolved to cluster edges on approve; cluster hosts
  get left/right handles).

Tests added on both sides.

ha-relevant: maybe
This commit is contained in:
Pouzor
2026-07-06 10:26:52 +02:00
parent abefc42fdf
commit 9670d0a86a
12 changed files with 475 additions and 41 deletions
+42 -5
View File
@@ -356,6 +356,7 @@ async def bulk_approve_devices(
device.status = "approved"
node_type = device.suggested_type or "generic"
is_wireless = _is_wireless(node_type)
cluster_host = await _is_proxmox_cluster_member(db, device.ieee_address)
node = Node(
label=device.hostname or device.friendly_name or device.ip or "device",
type=node_type,
@@ -371,6 +372,9 @@ async def bulk_approve_devices(
# Default to ping so the status checker actually polls the new node.
# Without this the scheduler skips it (check_method NULL → no check).
check_method="none" if is_wireless else ("ping" if device.ip else None),
# Cluster hosts get side handles for their host↔host cluster edge.
left_handles=1 if cluster_host else 0,
right_handles=1 if cluster_host else 0,
design_id=default_design_id,
)
db.add(node)
@@ -508,6 +512,7 @@ async def approve_device(
# Prefer the MAC discovered during the scan (stored on the pending device);
# fall back to whatever the approve payload carried.
_mac = device.mac or node_data.mac
cluster_host = await _is_proxmox_cluster_member(db, device.ieee_address)
node = Node(
label=node_data.label,
type=node_data.type,
@@ -525,6 +530,9 @@ async def approve_device(
),
check_method="none" if wireless else (node_data.check_method or ("ping" if node_data.ip else None)),
check_target=None if wireless else node_data.check_target,
# Cluster hosts get side handles for their host↔host cluster edge.
left_handles=1 if cluster_host else 0,
right_handles=1 if cluster_host else 0,
design_id=node_design_id,
)
db.add(node)
@@ -542,6 +550,28 @@ async def approve_device(
}
async def _is_proxmox_cluster_member(db: AsyncSession, ieee: str | None) -> bool:
"""True if ``ieee`` participates in a proxmox_cluster link (host↔host).
Such a host needs one left + one right handle for the cluster edge endpoints
(both default to 0). Checked at approve time, before the link is consumed by
``_resolve_pending_links_for_ieee``.
"""
if not ieee:
return False
found = (
await db.execute(
select(PendingDeviceLink.id)
.where(
PendingDeviceLink.discovery_source == "proxmox_cluster",
(PendingDeviceLink.source_ieee == ieee) | (PendingDeviceLink.target_ieee == ieee),
)
.limit(1)
)
).scalar()
return found is not None
async def _resolve_pending_links_for_ieee(
db: AsyncSession, ieee: str | None
) -> list[dict[str, str]]:
@@ -612,15 +642,22 @@ async def _resolve_pending_links_for_ieee(
if edge_design_id is None:
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
edge_design_id = first.id if first else None
# Proxmox host→guest links render as 'virtual' (VM/LXC ↔ host); mesh
# links stay 'iot'. Default to iot for any legacy/other source.
edge_type = "virtual" if link.discovery_source == "proxmox" else "iot"
# Edge shape by link source:
# proxmox → 'virtual' host→guest, vertical (bottom → top)
# proxmox_cluster → 'cluster' host↔host, horizontal (right → left)
# anything else → 'iot' mesh link, vertical
if link.discovery_source == "proxmox":
edge_type, src_handle, tgt_handle = "virtual", "bottom", "top-t"
elif link.discovery_source == "proxmox_cluster":
edge_type, src_handle, tgt_handle = "cluster", "right", "left-t"
else:
edge_type, src_handle, tgt_handle = "iot", "bottom", "top-t"
edge = Edge(
source=src_id,
target=tgt_id,
type=edge_type,
source_handle="bottom",
target_handle="top-t",
source_handle=src_handle,
target_handle=tgt_handle,
design_id=edge_design_id,
)
db.add(edge)