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:
@@ -47,6 +47,7 @@ import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig, NodeType } fro
|
||||
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 { buildProxmoxClusterEdges } from '@/components/proxmox/clusterEdges'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
|
||||
@@ -650,10 +651,16 @@ export default function App() {
|
||||
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 }
|
||||
const isClusterHost = cluster && pn.type === 'proxmox'
|
||||
const newNode: import('@xyflow/react').Node<NodeData> = {
|
||||
id: pn.id,
|
||||
type: pn.type,
|
||||
@@ -665,6 +672,7 @@ export default function App() {
|
||||
services: [],
|
||||
...(pn.ip ? { ip: pn.ip } : {}),
|
||||
...(pn.hostname ? { hostname: pn.hostname } : {}),
|
||||
...(isClusterHost ? { left_handles: 1, right_handles: 1 } : {}),
|
||||
},
|
||||
}
|
||||
addNode(newNode)
|
||||
@@ -679,6 +687,16 @@ export default function App() {
|
||||
type: 'virtual',
|
||||
} as unknown as import('@xyflow/react').Connection)
|
||||
})
|
||||
// Host ↔ host links render as 'cluster' edges (left → right chain).
|
||||
clusterEdges.forEach((ce) => {
|
||||
onConnect({
|
||||
source: ce.source,
|
||||
sourceHandle: ce.sourceHandle,
|
||||
target: ce.target,
|
||||
targetHandle: ce.targetHandle,
|
||||
type: 'cluster',
|
||||
} as unknown as import('@xyflow/react').Connection)
|
||||
})
|
||||
const importedIds = new Set(pmNodes.map((pn) => pn.id))
|
||||
useCanvasStore.setState((state) => ({
|
||||
nodes: state.nodes.map((n) => ({ ...n, selected: importedIds.has(n.id) })),
|
||||
|
||||
@@ -23,9 +23,12 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
const theme = THEMES[activeTheme]
|
||||
const colors = resolveNodeColors(data, activeTheme)
|
||||
|
||||
// Render as a regular node when container mode is disabled. Cluster links now
|
||||
// Container mode is opt-in — a proxmox node renders as a regular card unless
|
||||
// it is explicitly a container (matches the rest of the codebase, which gates
|
||||
// nesting on `container_mode === true`; see App.tsx). Imported nodes leave the
|
||||
// flag unset and so render like a manually-created proxmox node. Cluster links
|
||||
// use the configurable per-side connection points (see BaseNode / SideHandles).
|
||||
if (data.container_mode === false) {
|
||||
if (data.container_mode !== true) {
|
||||
return <BaseNode {...props} icon={Layers} />
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ function renderNode(data: Partial<NodeData> = {}, selected = false) {
|
||||
type: 'proxmox',
|
||||
status: 'online',
|
||||
services: [],
|
||||
// Default tests to the container/group path (the branch this file covers);
|
||||
// individual tests override with container_mode: false / unset as needed.
|
||||
container_mode: true,
|
||||
...data,
|
||||
}
|
||||
const props = {
|
||||
@@ -51,7 +54,7 @@ describe('ProxmoxGroupNode', () => {
|
||||
})
|
||||
|
||||
it('renders the node label', () => {
|
||||
const { getByText } = renderNode({ label: 'My Proxmox' })
|
||||
const { getByText } = renderNode({ label: 'My Proxmox', container_mode: true })
|
||||
expect(getByText('My Proxmox')).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -90,14 +93,21 @@ describe('ProxmoxGroupNode', () => {
|
||||
expect(dot).not.toBeNull()
|
||||
})
|
||||
|
||||
it('container_mode === false renders as BaseNode (no resizer group border)', () => {
|
||||
it('container_mode === false renders as BaseNode (no group border)', () => {
|
||||
const { container } = renderNode({ container_mode: false })
|
||||
// NodeResizer should not be present when not group-rendered
|
||||
expect(container.querySelector('.react-flow__resize-control')).toBeNull()
|
||||
// The group container uses rounded-xl border-2; BaseNode does not.
|
||||
expect(container.querySelector('.rounded-xl.border-2')).toBeNull()
|
||||
})
|
||||
|
||||
it('container_mode default renders the group border container', () => {
|
||||
const { container } = renderNode({})
|
||||
it('container mode is opt-in: default (unset) renders as a regular BaseNode', () => {
|
||||
// Imported proxmox nodes leave container_mode unset and must look like a
|
||||
// manually-created node (BaseNode), not an empty group container.
|
||||
const { container } = renderNode({ container_mode: undefined })
|
||||
expect(container.querySelector('.rounded-xl.border-2')).toBeNull()
|
||||
})
|
||||
|
||||
it('container_mode === true renders the group border container', () => {
|
||||
const { container } = renderNode({ container_mode: true })
|
||||
// Group border div has rounded-xl border-2 classes
|
||||
expect(container.querySelector('.rounded-xl.border-2')).not.toBeNull()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { RefreshCw, X, Loader2, StopCircle, Clock, ScanLine, Network, RadioTower, Inbox } from 'lucide-react'
|
||||
import { RefreshCw, X, Loader2, StopCircle, Clock, ScanLine, Network, RadioTower, Server, Inbox } from 'lucide-react'
|
||||
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { scanApi } from '@/api/client'
|
||||
@@ -22,17 +22,21 @@ interface ScanHistoryModalProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type KindFilter = 'all' | 'ip' | 'zigbee' | 'zwave'
|
||||
type KindFilter = 'all' | 'ip' | 'zigbee' | 'zwave' | 'proxmox'
|
||||
|
||||
/** Normalise a ScanRun.kind into one of the known display kinds. */
|
||||
function runKind(kind: string | undefined): 'ip' | 'zigbee' | 'zwave' {
|
||||
return kind === 'zigbee' ? 'zigbee' : kind === 'zwave' ? 'zwave' : 'ip'
|
||||
function runKind(kind: string | undefined): 'ip' | 'zigbee' | 'zwave' | 'proxmox' {
|
||||
return kind === 'zigbee' ? 'zigbee'
|
||||
: kind === 'zwave' ? 'zwave'
|
||||
: kind === 'proxmox' ? 'proxmox'
|
||||
: 'ip'
|
||||
}
|
||||
|
||||
const KIND_META = {
|
||||
ip: { label: 'IP', color: '#a855f7' },
|
||||
zigbee: { label: 'Zigbee', color: '#00d4ff' },
|
||||
zwave: { label: 'Z-Wave', color: '#ff6e00' },
|
||||
proxmox: { label: 'Proxmox', color: '#e57000' },
|
||||
} as const
|
||||
type StatusFilter = 'all' | 'running' | 'done' | 'error' | 'cancelled'
|
||||
|
||||
@@ -49,6 +53,7 @@ const KIND_FILTERS: { key: KindFilter; label: string }[] = [
|
||||
{ key: 'ip', label: 'IP' },
|
||||
{ key: 'zigbee', label: 'Zigbee' },
|
||||
{ key: 'zwave', label: 'Z-Wave' },
|
||||
{ key: 'proxmox', label: 'Proxmox' },
|
||||
]
|
||||
|
||||
function statusColor(s: string): string {
|
||||
@@ -102,9 +107,15 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
||||
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
|
||||
}
|
||||
if (prev?.status === 'running' && run.status === 'done') {
|
||||
if (run.kind === 'zigbee' || run.kind === 'zwave') {
|
||||
const label = run.kind === 'zwave' ? 'Z-Wave' : 'Zigbee'
|
||||
toast.success(`${label} import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
|
||||
if (run.kind === 'zigbee' || run.kind === 'zwave' || run.kind === 'proxmox') {
|
||||
const label = run.kind === 'zwave' ? 'Z-Wave' : run.kind === 'proxmox' ? 'Proxmox' : 'Zigbee'
|
||||
// A done run can still carry a non-fatal advisory (e.g. Proxmox
|
||||
// imported hosts but the token couldn't see any VMs/LXC).
|
||||
if (run.error) {
|
||||
toast.warning(`${label} import: ${run.error}`)
|
||||
} else {
|
||||
toast.success(`${label} import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
|
||||
}
|
||||
}
|
||||
useCanvasStore.getState().notifyScanDeviceFound()
|
||||
}
|
||||
@@ -231,7 +242,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
||||
{filtered.map((r) => {
|
||||
const kind = runKind(r.kind)
|
||||
const meta = KIND_META[kind]
|
||||
const KindIcon = kind === 'zigbee' ? Network : kind === 'zwave' ? RadioTower : ScanLine
|
||||
const KindIcon = kind === 'zigbee' ? Network : kind === 'zwave' ? RadioTower : kind === 'proxmox' ? Server : ScanLine
|
||||
return (
|
||||
<div key={r.id} className="rounded-lg border border-border bg-[#161b22] p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -286,7 +297,15 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
||||
)}
|
||||
|
||||
{r.error && (
|
||||
<div className="mt-2 text-[11px] text-[#f85149] leading-tight whitespace-pre-wrap break-words rounded bg-[#f85149]/10 px-2 py-1.5">
|
||||
// A 'done' run with a message is a non-fatal advisory → amber,
|
||||
// not the red used for a genuine failure.
|
||||
<div
|
||||
className={`mt-2 text-[11px] leading-tight whitespace-pre-wrap break-words rounded px-2 py-1.5 ${
|
||||
r.status === 'done'
|
||||
? 'text-[#e3b341] bg-[#e3b341]/10'
|
||||
: 'text-[#f85149] bg-[#f85149]/10'
|
||||
}`}
|
||||
>
|
||||
{r.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { ScanHistoryModal } from '../ScanHistoryModal'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() } }))
|
||||
vi.mock('@/stores/canvasStore', () => ({
|
||||
useCanvasStore: { getState: () => ({ notifyScanDeviceFound: vi.fn() }) },
|
||||
}))
|
||||
@@ -72,6 +72,17 @@ const ZWAVE_RUN = {
|
||||
error: null,
|
||||
}
|
||||
|
||||
const PROXMOX_RUN = {
|
||||
id: 'run-6',
|
||||
status: 'done',
|
||||
kind: 'proxmox',
|
||||
ranges: ['pve:8006'],
|
||||
devices_found: 9,
|
||||
started_at: new Date().toISOString(),
|
||||
finished_at: new Date().toISOString(),
|
||||
error: null,
|
||||
}
|
||||
|
||||
function renderModal() {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
@@ -84,6 +95,7 @@ describe('ScanHistoryModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(toast.success).mockReset()
|
||||
vi.mocked(toast.error).mockReset()
|
||||
vi.mocked(toast.warning).mockReset()
|
||||
vi.mocked(scanApi.stop).mockReset()
|
||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [] } as never)
|
||||
})
|
||||
@@ -176,4 +188,31 @@ describe('ScanHistoryModal', () => {
|
||||
expect(screen.getByText('5 found')).toBeDefined()
|
||||
expect(screen.queryByText('3 found')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders a done proxmox run with an advisory as info, not a failure', async () => {
|
||||
const ADVISORY_RUN = {
|
||||
...PROXMOX_RUN,
|
||||
id: 'run-7',
|
||||
devices_found: 3,
|
||||
error: 'Imported 3 host(s) but no VMs or LXC were visible to the API token. Grant PVEAuditor…',
|
||||
}
|
||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [ADVISORY_RUN] } as never)
|
||||
renderModal()
|
||||
// Status stays "done" (success), yet the advisory text is surfaced.
|
||||
await waitFor(() => expect(screen.getByText('done')).toBeDefined())
|
||||
expect(screen.getByText(/no VMs or LXC were visible/)).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows a proxmox run under its own kind, not IP', async () => {
|
||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN, PROXMOX_RUN] } as never)
|
||||
renderModal()
|
||||
await waitFor(() => expect(screen.getAllByText('done').length).toBe(2))
|
||||
// A dedicated Proxmox badge is rendered on the run (would be mislabeled "IP"
|
||||
// before the fix). Both the filter chip and the run badge carry the label.
|
||||
expect(screen.getAllByText('Proxmox').length).toBeGreaterThanOrEqual(2)
|
||||
// Filtering to Proxmox keeps only the proxmox run (9 found), drops the IP run.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Proxmox' }))
|
||||
expect(screen.getByText('9 found')).toBeDefined()
|
||||
expect(screen.queryByText('3 found')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
buildProxmoxClusterEdges,
|
||||
isProxmoxCluster,
|
||||
CLUSTER_SOURCE_HANDLE,
|
||||
CLUSTER_TARGET_HANDLE,
|
||||
} from '../clusterEdges'
|
||||
import type { ProxmoxNode } from '../types'
|
||||
|
||||
function host(id: string): ProxmoxNode {
|
||||
return { id, label: id, type: 'proxmox', ieee_address: id, status: 'online' }
|
||||
}
|
||||
function guest(id: string, type: 'vm' | 'lxc' = 'vm'): ProxmoxNode {
|
||||
return { id, label: id, type, ieee_address: id, status: 'online' }
|
||||
}
|
||||
|
||||
describe('buildProxmoxClusterEdges', () => {
|
||||
it('chains multiple hosts left→right, ignoring guests', () => {
|
||||
const nodes = [host('pve-a'), guest('vm-1'), host('pve-b'), host('pve-c'), guest('ct-1', 'lxc')]
|
||||
const edges = buildProxmoxClusterEdges(nodes)
|
||||
expect(edges.map((e) => [e.source, e.target])).toEqual([
|
||||
['pve-a', 'pve-b'],
|
||||
['pve-b', 'pve-c'],
|
||||
])
|
||||
// Endpoints use the left/right handles.
|
||||
for (const e of edges) {
|
||||
expect(e.sourceHandle).toBe(CLUSTER_SOURCE_HANDLE)
|
||||
expect(e.targetHandle).toBe(CLUSTER_TARGET_HANDLE)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns no edges for a single host', () => {
|
||||
expect(buildProxmoxClusterEdges([host('pve-a'), guest('vm-1')])).toEqual([])
|
||||
})
|
||||
|
||||
it('returns no edges when there are no hosts', () => {
|
||||
expect(buildProxmoxClusterEdges([guest('vm-1'), guest('ct-1', 'lxc')])).toEqual([])
|
||||
})
|
||||
|
||||
it('isProxmoxCluster is true only with 2+ hosts', () => {
|
||||
expect(isProxmoxCluster([host('a')])).toBe(false)
|
||||
expect(isProxmoxCluster([host('a'), host('b')])).toBe(true)
|
||||
expect(isProxmoxCluster([host('a'), guest('vm-1')])).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
/** Cluster-edge wiring for a Proxmox import.
|
||||
*
|
||||
* Proxmox host nodes discovered in the same import belong to one cluster, so we
|
||||
* chain them together with `cluster` edges. The chain uses the left/right
|
||||
* connection points (right source → left target) to keep them visually distinct
|
||||
* from the vertical host→guest `virtual` edges (bottom → top). Left/right
|
||||
* handles default to 0, so the hosts must opt into one handle per side for the
|
||||
* edge endpoints to exist (see `sideDefault` in handleUtils).
|
||||
*/
|
||||
import type { ProxmoxNode } from './types'
|
||||
|
||||
/** Source/target handle IDs for a cluster link (see handleUtils.handleId). */
|
||||
export const CLUSTER_SOURCE_HANDLE = 'right'
|
||||
export const CLUSTER_TARGET_HANDLE = 'left-t'
|
||||
|
||||
export interface ClusterEdgeSpec {
|
||||
source: string
|
||||
target: string
|
||||
sourceHandle: typeof CLUSTER_SOURCE_HANDLE
|
||||
targetHandle: typeof CLUSTER_TARGET_HANDLE
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain all Proxmox host nodes (`type === 'proxmox'`) from one import into a
|
||||
* left→right cluster line. Returns `[]` when fewer than two hosts are present
|
||||
* (a single host is not a cluster). Guests (vm/lxc) are ignored.
|
||||
*/
|
||||
export function buildProxmoxClusterEdges(nodes: ProxmoxNode[]): ClusterEdgeSpec[] {
|
||||
const hosts = nodes.filter((n) => n.type === 'proxmox')
|
||||
if (hosts.length < 2) return []
|
||||
const edges: ClusterEdgeSpec[] = []
|
||||
for (let i = 0; i < hosts.length - 1; i++) {
|
||||
edges.push({
|
||||
source: hosts[i].id,
|
||||
target: hosts[i + 1].id,
|
||||
sourceHandle: CLUSTER_SOURCE_HANDLE,
|
||||
targetHandle: CLUSTER_TARGET_HANDLE,
|
||||
})
|
||||
}
|
||||
return edges
|
||||
}
|
||||
|
||||
/** True when the import contains a Proxmox cluster (≥2 host nodes). */
|
||||
export function isProxmoxCluster(nodes: ProxmoxNode[]): boolean {
|
||||
return nodes.filter((n) => n.type === 'proxmox').length >= 2
|
||||
}
|
||||
Reference in New Issue
Block a user