feat: import hosts/VMs/LXC from Proxmox VE with optional auto-sync
Add a Proxmox VE importer that reads the /api2/json REST API with a read-only
API token and drops hosts (proxmox), VMs (vm) and LXC containers (lxc) onto the
canvas as typed nodes with run state and hardware specs (vCPU/RAM/disk).
- Backend: proxmox_service (httpx) + proxmox routes (test-connection, import,
import-pending, config). Two-tier dedupe — merge onto an existing scanned node
by IP, else synthetic pve-{host}-{vmid} identity. Update-in-place, never
deletes. Host->guest rendered as a 'virtual' edge via the pending-link flow.
- Security: token is env-only (PROXMOX_TOKEN_*), never written to disk by the
app, never returned by any endpoint; errors are credential-sanitized.
- Auto-sync: optional scheduled re-import into pending (APScheduler job).
- PendingDevice.properties carries specs through approve (+ migration).
- Frontend: ProxmoxImportModal, sidebar entry, pending inventory source filter,
Settings auto-sync section, proxmoxApi client.
- Docs: docs/proxmox-import.md, README + FEATURES sections, .env.example keys.
- Tests: backend service/router/scheduler, frontend modal/client/pending.
ha-relevant: maybe
This commit is contained in:
@@ -25,6 +25,7 @@ import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
||||
import { SettingsModal } from '@/components/modals/SettingsModal'
|
||||
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
|
||||
import { ZwaveImportModal } from '@/components/zwave/ZwaveImportModal'
|
||||
import { ProxmoxImportModal } from '@/components/proxmox/ProxmoxImportModal'
|
||||
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
||||
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||
@@ -45,6 +46,7 @@ import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||
import type { NodeData, EdgeData, CustomStyleDef, 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'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
|
||||
@@ -84,6 +86,7 @@ export default function App() {
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||||
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
|
||||
const [zwaveImportOpen, setZwaveImportOpen] = useState(false)
|
||||
const [proxmoxImportOpen, setProxmoxImportOpen] = useState(false)
|
||||
|
||||
// Declare handleSave before the Ctrl+S effect so it is in scope.
|
||||
// Returns true on success, false on failure — the design-switch effect relies
|
||||
@@ -639,6 +642,52 @@ export default function App() {
|
||||
markUnsaved()
|
||||
}, [addNode, onConnect, snapshotHistory, markUnsaved])
|
||||
|
||||
const handleProxmoxAddToCanvas = useCallback((pmNodes: ProxmoxNode[], pmEdges: ProxmoxEdge[]) => {
|
||||
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)
|
||||
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 newNode: import('@xyflow/react').Node<NodeData> = {
|
||||
id: pn.id,
|
||||
type: pn.type,
|
||||
position,
|
||||
data: {
|
||||
label: pn.label,
|
||||
type: pn.type as NodeData['type'],
|
||||
status: (pn.status === 'online' ? 'online' : 'unknown') as NodeData['status'],
|
||||
services: [],
|
||||
...(pn.ip ? { ip: pn.ip } : {}),
|
||||
...(pn.hostname ? { hostname: pn.hostname } : {}),
|
||||
},
|
||||
}
|
||||
addNode(newNode)
|
||||
})
|
||||
// Host → guest links render as 'virtual' edges (VM/LXC ↔ host).
|
||||
pmEdges.forEach((pe) => {
|
||||
onConnect({
|
||||
source: pe.source,
|
||||
sourceHandle: 'bottom',
|
||||
target: pe.target,
|
||||
targetHandle: 'top-t',
|
||||
type: 'virtual',
|
||||
} 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) })),
|
||||
selectedNodeIds: Array.from(importedIds),
|
||||
selectedNodeId: importedIds.size === 1 ? Array.from(importedIds)[0] : null,
|
||||
}))
|
||||
markUnsaved()
|
||||
}, [addNode, onConnect, snapshotHistory, markUnsaved])
|
||||
|
||||
const handleEdgeConnect = useCallback((connection: Connection) => {
|
||||
setPendingConnection(connection)
|
||||
}, [])
|
||||
@@ -714,6 +763,7 @@ export default function App() {
|
||||
onScan={() => setScanConfigOpen(true)}
|
||||
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
||||
onZwaveImport={() => setZwaveImportOpen(true)}
|
||||
onProxmoxImport={() => setProxmoxImportOpen(true)}
|
||||
onSave={handleSave}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onOpenHistory={() => setScanHistoryOpen(true)}
|
||||
@@ -840,6 +890,17 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!STANDALONE && (
|
||||
<ProxmoxImportModal
|
||||
open={proxmoxImportOpen}
|
||||
onClose={() => setProxmoxImportOpen(false)}
|
||||
onAddToCanvas={handleProxmoxAddToCanvas}
|
||||
onPendingImported={() => {
|
||||
toast.success('Proxmox import started — check Scan History for results')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!STANDALONE && (
|
||||
<ScanHistoryModal
|
||||
open={scanHistoryOpen}
|
||||
|
||||
@@ -229,4 +229,22 @@ describe('api/client', () => {
|
||||
mod.zwaveApi.importToPending(cfg)
|
||||
expect(api.post).toHaveBeenCalledWith('/zwave/import-pending', cfg)
|
||||
})
|
||||
|
||||
it('proxmoxApi.testConnection/importNetwork/importToPending', () => {
|
||||
const cfg = { host: 'pve', port: 8006, token_id: 'u@pam!t', token_secret: 's', verify_tls: true }
|
||||
mod.proxmoxApi.testConnection(cfg)
|
||||
expect(api.post).toHaveBeenCalledWith('/proxmox/test-connection', cfg)
|
||||
mod.proxmoxApi.importNetwork(cfg)
|
||||
expect(api.post).toHaveBeenCalledWith('/proxmox/import', cfg)
|
||||
mod.proxmoxApi.importToPending(cfg)
|
||||
expect(api.post).toHaveBeenCalledWith('/proxmox/import-pending', cfg)
|
||||
})
|
||||
|
||||
it('proxmoxApi.getConfig/saveConfig hit /proxmox/config', () => {
|
||||
mod.proxmoxApi.getConfig()
|
||||
expect(api.get).toHaveBeenCalledWith('/proxmox/config')
|
||||
const conf = { host: 'pve', port: 8006, verify_tls: true, sync_enabled: false, sync_interval: 3600 }
|
||||
mod.proxmoxApi.saveConfig(conf)
|
||||
expect(api.post).toHaveBeenCalledWith('/proxmox/config', conf)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -120,6 +120,51 @@ export const settingsApi = {
|
||||
save: (data: AppSettings) => api.post<AppSettings>('/settings', data),
|
||||
}
|
||||
|
||||
export interface ProxmoxConnection {
|
||||
host: string
|
||||
port: number
|
||||
token_id?: string
|
||||
token_secret?: string
|
||||
verify_tls?: boolean
|
||||
}
|
||||
|
||||
export interface ProxmoxConfigData {
|
||||
host: string
|
||||
port: number
|
||||
verify_tls: boolean
|
||||
sync_enabled: boolean
|
||||
sync_interval: number
|
||||
token_configured: boolean
|
||||
}
|
||||
|
||||
export const proxmoxApi = {
|
||||
testConnection: (data: ProxmoxConnection) =>
|
||||
api.post<{ connected: boolean; message: string }>('/proxmox/test-connection', data),
|
||||
|
||||
importNetwork: (data: ProxmoxConnection) =>
|
||||
api.post<{
|
||||
nodes: import('@/components/proxmox/types').ProxmoxNode[]
|
||||
edges: import('@/components/proxmox/types').ProxmoxEdge[]
|
||||
device_count: number
|
||||
}>('/proxmox/import', data),
|
||||
|
||||
importToPending: (data: ProxmoxConnection) =>
|
||||
api.post<{
|
||||
id: string
|
||||
status: string
|
||||
kind: string
|
||||
ranges: string[]
|
||||
devices_found: number
|
||||
started_at: string
|
||||
finished_at: string | null
|
||||
error: string | null
|
||||
}>('/proxmox/import-pending', data),
|
||||
|
||||
getConfig: () => api.get<ProxmoxConfigData>('/proxmox/config'),
|
||||
saveConfig: (data: Omit<ProxmoxConfigData, 'token_configured'>) =>
|
||||
api.post<ProxmoxConfigData>('/proxmox/config', data),
|
||||
}
|
||||
|
||||
export const designsApi = {
|
||||
list: () => api.get<import('@/types').Design[]>('/designs'),
|
||||
create: (data: { name: string; icon?: string; design_type?: string }) =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { NodeProperty } from '@/types'
|
||||
|
||||
interface Service {
|
||||
port: number
|
||||
@@ -26,6 +27,8 @@ export interface PendingDevice {
|
||||
model?: string | null
|
||||
vendor?: string | null
|
||||
lqi?: number | null
|
||||
// Display properties carried from discovery (e.g. Proxmox specs).
|
||||
properties?: NodeProperty[]
|
||||
discovered_at: string
|
||||
// How many canvases (designs) this device already appears on. Computed server-side.
|
||||
canvas_count?: number
|
||||
|
||||
@@ -73,13 +73,15 @@ const TYPE_ICONS: Record<string, React.ElementType> = {
|
||||
generic: Circle,
|
||||
}
|
||||
|
||||
type SourceFilter = 'all' | 'ip' | 'zigbee' | 'zwave'
|
||||
type SourceFilter = 'all' | 'ip' | 'zigbee' | 'zwave' | 'proxmox'
|
||||
type StatusFilter = 'pending' | 'hidden'
|
||||
|
||||
function inferSource(d: PendingDevice): 'zigbee' | 'zwave' | 'ip' {
|
||||
function inferSource(d: PendingDevice): 'zigbee' | 'zwave' | 'proxmox' | 'ip' {
|
||||
if (d.discovery_source === 'zwave') return 'zwave'
|
||||
if (d.discovery_source === 'zigbee') return 'zigbee'
|
||||
if (d.ieee_address) return 'zigbee'
|
||||
if (d.discovery_source === 'proxmox') return 'proxmox'
|
||||
// Proxmox devices carry a synthetic 'pve-' ieee but are IP hosts, not mesh.
|
||||
if (d.ieee_address && !d.ieee_address.startsWith('pve-')) return 'zigbee'
|
||||
return 'ip'
|
||||
}
|
||||
|
||||
@@ -279,7 +281,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
? buildZwaveProperties(device)
|
||||
: isZigbeeType(type)
|
||||
? buildZigbeeProperties(device)
|
||||
: buildMacProperty(device.mac)
|
||||
: [...(device.properties ?? []), ...buildMacProperty(device.mac)]
|
||||
const nodeData = {
|
||||
label: fallbackLabel,
|
||||
type,
|
||||
@@ -364,7 +366,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
? buildZwaveProperties(d)
|
||||
: isZigbeeType(type)
|
||||
? buildZigbeeProperties(d)
|
||||
: buildMacProperty(d.mac),
|
||||
: [...(d.properties ?? []), ...buildMacProperty(d.mac)],
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -503,6 +505,12 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
>
|
||||
Z-Wave
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSourceFilter('proxmox')}
|
||||
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'proxmox' ? 'bg-[#e57000]/20 text-[#e57000]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Proxmox
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
@@ -666,10 +674,15 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
|
||||
// (from the active theme / style section), instead of a flat grey.
|
||||
const roleColor = resolveNodeColors({ type: roleType, custom_colors: undefined }, activeTheme).border
|
||||
const label = deviceLabel(device)
|
||||
const sourceColor = source === 'zigbee' ? '#00d4ff' : source === 'zwave' ? '#ff6e00' : '#a855f7'
|
||||
const sourceColor =
|
||||
source === 'zigbee' ? '#00d4ff'
|
||||
: source === 'zwave' ? '#ff6e00'
|
||||
: source === 'proxmox' ? '#e57000'
|
||||
: '#a855f7'
|
||||
const sourceLabel =
|
||||
source === 'zigbee' ? 'ZIGBEE'
|
||||
: source === 'zwave' ? 'Z-WAVE'
|
||||
: source === 'proxmox' ? 'PROXMOX'
|
||||
: (device.discovery_source ?? 'IP').toUpperCase()
|
||||
const services = device.services ?? []
|
||||
const visibleServices = services.slice(0, 4)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { settingsApi } from '@/api/client'
|
||||
import { settingsApi, proxmoxApi, type ProxmoxConfigData } from '@/api/client'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -23,6 +23,9 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
const [serviceCheckEnabled, setServiceCheckEnabled] = useState(false)
|
||||
const [serviceInterval, setServiceInterval] = useState(300)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [pmConfig, setPmConfig] = useState<ProxmoxConfigData | null>(null)
|
||||
const [pmSyncEnabled, setPmSyncEnabled] = useState(false)
|
||||
const [pmInterval, setPmInterval] = useState(3600)
|
||||
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||
const setHideIp = useCanvasStore((s) => s.setHideIp)
|
||||
@@ -36,6 +39,13 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
setServiceInterval(res.data.service_check_interval)
|
||||
})
|
||||
.catch(() => {/* use default */})
|
||||
proxmoxApi.getConfig()
|
||||
.then((res) => {
|
||||
setPmConfig(res.data)
|
||||
setPmSyncEnabled(res.data.sync_enabled)
|
||||
setPmInterval(res.data.sync_interval)
|
||||
})
|
||||
.catch(() => {/* proxmox not configured */})
|
||||
}, [open])
|
||||
|
||||
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
|
||||
@@ -60,6 +70,15 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
service_check_enabled: serviceCheckEnabled,
|
||||
service_check_interval: serviceInterval,
|
||||
})
|
||||
if (pmConfig) {
|
||||
await proxmoxApi.saveConfig({
|
||||
host: pmConfig.host,
|
||||
port: pmConfig.port,
|
||||
verify_tls: pmConfig.verify_tls,
|
||||
sync_enabled: pmSyncEnabled,
|
||||
sync_interval: pmInterval,
|
||||
})
|
||||
}
|
||||
toast.success('Settings saved')
|
||||
onClose()
|
||||
} catch {
|
||||
@@ -128,6 +147,50 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Proxmox auto-sync */}
|
||||
{!STANDALONE && pmConfig && (
|
||||
<div className="pt-3 border-t border-border space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Proxmox auto-sync</span>
|
||||
{!pmConfig.token_configured ? (
|
||||
<p className="text-[10px] text-[#e3b341] leading-tight">
|
||||
No API token configured. Set <span className="font-mono">PROXMOX_TOKEN_ID</span> and{' '}
|
||||
<span className="font-mono">PROXMOX_TOKEN_SECRET</span> in the server .env to enable auto-sync.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
||||
<span className="text-xs text-foreground">Auto-sync Proxmox inventory</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pmSyncEnabled}
|
||||
onChange={(e) => setPmSyncEnabled(e.target.checked)}
|
||||
className="cursor-pointer accent-[#e57000]"
|
||||
aria-label="Toggle Proxmox auto-sync"
|
||||
/>
|
||||
</label>
|
||||
<div className={pmSyncEnabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
||||
<label className="text-xs text-muted-foreground">Sync interval (s)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={300}
|
||||
max={86400}
|
||||
value={pmInterval}
|
||||
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setPmInterval(v) }}
|
||||
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#e57000]"
|
||||
aria-label="Proxmox sync interval"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">seconds</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||
Re-imports hosts/VMs/LXC into the pending inventory. Min 300s (5 min).
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Canvas */}
|
||||
<div className="pt-3 border-t border-border space-y-3">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Canvas</span>
|
||||
|
||||
@@ -84,6 +84,24 @@ const DEVICE_ZWAVE = {
|
||||
discovered_at: '2026-01-03T00:00:00Z',
|
||||
}
|
||||
|
||||
const DEVICE_PROXMOX = {
|
||||
id: 'dev-d',
|
||||
ip: '10.0.0.5',
|
||||
hostname: 'web',
|
||||
mac: null,
|
||||
os: null,
|
||||
services: [],
|
||||
suggested_type: 'vm',
|
||||
status: 'pending',
|
||||
discovery_source: 'proxmox',
|
||||
ieee_address: 'pve-pve1-101',
|
||||
friendly_name: 'web',
|
||||
vendor: 'Proxmox VE',
|
||||
model: 'QEMU',
|
||||
properties: [{ key: 'CPU Cores', value: '2', icon: 'Cpu', visible: false }],
|
||||
discovered_at: '2026-01-04T00:00:00Z',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useCanvasStore).mockReturnValue({
|
||||
@@ -174,6 +192,23 @@ describe('PendingDevicesModal', () => {
|
||||
expect(screen.getByTestId('pending-card-dev-c')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows source chip PROXMOX for a proxmox device (not zigbee despite ieee)', async () => {
|
||||
mockPending.mockResolvedValue({ data: [DEVICE_PROXMOX] })
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-d')).toBeInTheDocument())
|
||||
expect(screen.getByText('PROXMOX')).toBeInTheDocument()
|
||||
expect(screen.queryByText('ZIGBEE')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by source (proxmox only)', async () => {
|
||||
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_PROXMOX] })
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Proxmox' }))
|
||||
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('pending-card-dev-d')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by suggested type', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
|
||||
@@ -8,9 +8,13 @@ vi.mock('@/api/client', () => ({
|
||||
get: vi.fn(),
|
||||
save: vi.fn(),
|
||||
},
|
||||
proxmoxApi: {
|
||||
getConfig: vi.fn(),
|
||||
saveConfig: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { settingsApi } from '@/api/client'
|
||||
import { settingsApi, proxmoxApi } from '@/api/client'
|
||||
import { toast } from 'sonner'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
|
||||
@@ -19,6 +23,8 @@ describe('SettingsModal', () => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: false, service_check_interval: 300 } } as never)
|
||||
vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: false, service_check_interval: 300 } } as never)
|
||||
vi.mocked(proxmoxApi.getConfig).mockRejectedValue(new Error('not configured'))
|
||||
vi.mocked(proxmoxApi.saveConfig).mockResolvedValue({ data: {} } as never)
|
||||
vi.mocked(toast.success).mockReset()
|
||||
vi.mocked(toast.error).mockReset()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Server, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Logo } from '@/components/ui/Logo'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
@@ -27,13 +27,14 @@ interface SidebarProps {
|
||||
onScan: () => void
|
||||
onZigbeeImport: () => void
|
||||
onZwaveImport: () => void
|
||||
onProxmoxImport: () => void
|
||||
onSave: () => void
|
||||
onOpenSettings: () => void
|
||||
onOpenHistory: () => void
|
||||
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
||||
}
|
||||
|
||||
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onZwaveImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
|
||||
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onZwaveImport, onProxmoxImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
|
||||
@@ -265,6 +266,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
||||
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
||||
{!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} />}
|
||||
{!STANDALONE && <SidebarItem icon={Server} label="Proxmox Import" collapsed={collapsed} onClick={onProxmoxImport} />}
|
||||
<SidebarItem
|
||||
icon={Save}
|
||||
label="Save Canvas"
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
import { useState } from 'react'
|
||||
import { Server, Box, Container, CheckCircle2, XCircle, Loader2, Plus } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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'
|
||||
|
||||
interface ProxmoxImportModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onAddToCanvas: (nodes: ProxmoxNode[], edges: ProxmoxEdge[]) => void
|
||||
onPendingImported?: () => void
|
||||
}
|
||||
|
||||
type ImportMode = 'pending' | 'canvas'
|
||||
|
||||
const ACCENT = '#e57000'
|
||||
|
||||
interface ConnectionForm {
|
||||
host: string
|
||||
port: string
|
||||
token_id: string
|
||||
token_secret: string
|
||||
verify_tls: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_FORM: ConnectionForm = {
|
||||
host: '',
|
||||
port: '8006',
|
||||
token_id: '',
|
||||
token_secret: '',
|
||||
verify_tls: true,
|
||||
}
|
||||
|
||||
const DEVICE_TYPE_ICON: Record<ProxmoxNodeType, typeof Server> = {
|
||||
proxmox: Server,
|
||||
vm: Box,
|
||||
lxc: Container,
|
||||
}
|
||||
|
||||
const DEVICE_TYPE_LABEL: Record<ProxmoxNodeType, string> = {
|
||||
proxmox: 'Hosts',
|
||||
vm: 'Virtual Machines',
|
||||
lxc: 'LXC Containers',
|
||||
}
|
||||
|
||||
const DEVICE_TYPE_COLOR: Record<ProxmoxNodeType, string> = {
|
||||
proxmox: '#e57000',
|
||||
vm: '#00d4ff',
|
||||
lxc: '#39d353',
|
||||
}
|
||||
|
||||
export function ProxmoxImportModal({ open, onClose, onAddToCanvas, onPendingImported }: ProxmoxImportModalProps) {
|
||||
const [form, setForm] = useState<ConnectionForm>(DEFAULT_FORM)
|
||||
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle')
|
||||
const [connectionMsg, setConnectionMsg] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [devices, setDevices] = useState<ProxmoxNode[]>([])
|
||||
const [edges, setEdges] = useState<ProxmoxEdge[]>([])
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set())
|
||||
const [importMode, setImportMode] = useState<ImportMode>('pending')
|
||||
|
||||
const updateField = (field: keyof ConnectionForm, value: string) =>
|
||||
setForm((f) => ({ ...f, [field]: value }))
|
||||
|
||||
const buildPayload = (): ProxmoxConnection => ({
|
||||
host: form.host.trim(),
|
||||
port: Number(form.port) || 8006,
|
||||
token_id: form.token_id.trim() || undefined,
|
||||
token_secret: form.token_secret || undefined,
|
||||
verify_tls: form.verify_tls,
|
||||
})
|
||||
|
||||
const extractError = (err: unknown): string | undefined => {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
return (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!form.host.trim()) { toast.error('Enter a Proxmox host'); return }
|
||||
setConnectionStatus('testing')
|
||||
try {
|
||||
const res = await proxmoxApi.testConnection(buildPayload())
|
||||
setConnectionStatus(res.data.connected ? 'ok' : 'fail')
|
||||
setConnectionMsg(res.data.message)
|
||||
} catch (err) {
|
||||
setConnectionStatus('fail')
|
||||
setConnectionMsg(extractError(err) ?? 'Request failed — check host address')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFetchDevices = async () => {
|
||||
if (!form.host.trim()) { toast.error('Enter a Proxmox host'); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
if (importMode === 'pending') {
|
||||
await proxmoxApi.importToPending(buildPayload())
|
||||
toast.success('Proxmox import started — track progress in Scan History')
|
||||
onPendingImported?.()
|
||||
handleClose()
|
||||
} else {
|
||||
const res = await proxmoxApi.importNetwork(buildPayload())
|
||||
setDevices(res.data.nodes)
|
||||
setEdges(res.data.edges)
|
||||
setChecked(new Set(res.data.nodes.map((n) => n.id)))
|
||||
if (res.data.device_count === 0) {
|
||||
toast.info('No Proxmox guests found')
|
||||
} else {
|
||||
toast.success(`Found ${res.data.device_count} device${res.data.device_count !== 1 ? 's' : ''}`)
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
toast.error(extractError(err) ?? 'Failed to fetch Proxmox inventory')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCheck = (id: string) =>
|
||||
setChecked((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
|
||||
const toggleAll = () => {
|
||||
setChecked(checked.size === devices.length ? new Set() : new Set(devices.map((d) => d.id)))
|
||||
}
|
||||
|
||||
const handleAddToCanvas = () => {
|
||||
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)
|
||||
toast.success(`Added ${selectedDevices.length} device${selectedDevices.length !== 1 ? 's' : ''} to canvas`)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setDevices([])
|
||||
setEdges([])
|
||||
setChecked(new Set())
|
||||
setConnectionStatus('idle')
|
||||
setConnectionMsg('')
|
||||
setImportMode('pending')
|
||||
onClose()
|
||||
}
|
||||
|
||||
const groupedDevices: Record<ProxmoxNodeType, ProxmoxNode[]> = {
|
||||
proxmox: devices.filter((d) => d.type === 'proxmox'),
|
||||
vm: devices.filter((d) => d.type === 'vm'),
|
||||
lxc: devices.filter((d) => d.type === 'lxc'),
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||
<DialogContent className="bg-[#161b22] border-border max-w-xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-foreground flex items-center gap-2">
|
||||
<Server size={16} style={{ color: ACCENT }} />
|
||||
Proxmox VE Import
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-4 py-2 min-h-0">
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Proxmox Host</Label>
|
||||
<Input
|
||||
value={form.host}
|
||||
onChange={(e) => updateField('host', e.target.value)}
|
||||
placeholder="192.168.1.x or pve.local"
|
||||
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Port</Label>
|
||||
<Input
|
||||
value={form.port}
|
||||
onChange={(e) => updateField('port', e.target.value)}
|
||||
placeholder="8006"
|
||||
type="number"
|
||||
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Token ID</Label>
|
||||
<Input
|
||||
value={form.token_id}
|
||||
onChange={(e) => updateField('token_id', e.target.value)}
|
||||
placeholder="user@pam!tokenname"
|
||||
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Token Secret</Label>
|
||||
<Input
|
||||
value={form.token_secret}
|
||||
onChange={(e) => updateField('token_secret', e.target.value)}
|
||||
placeholder="••••••••-••••-••••-••••-••••••••••••"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className="text-sm bg-[#0d1117] border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center gap-4 pt-1">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.verify_tls}
|
||||
onChange={(e) => setForm((f) => ({ ...f, verify_tls: e.target.checked }))}
|
||||
className="w-3 h-3 cursor-pointer"
|
||||
style={{ accentColor: ACCENT }}
|
||||
/>
|
||||
Verify TLS certificate
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connectionStatus !== 'idle' && (
|
||||
<div className={`flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-md border ${
|
||||
connectionStatus === 'ok'
|
||||
? 'bg-[#39d353]/10 border-[#39d353]/30 text-[#39d353]'
|
||||
: connectionStatus === 'fail'
|
||||
? 'bg-[#f85149]/10 border-[#f85149]/30 text-[#f85149]'
|
||||
: 'bg-[#e3b341]/10 border-[#e3b341]/30 text-[#e3b341]'
|
||||
}`}>
|
||||
{connectionStatus === 'testing' && <Loader2 size={12} className="animate-spin" />}
|
||||
{connectionStatus === 'ok' && <CheckCircle2 size={12} />}
|
||||
{connectionStatus === 'fail' && <XCircle size={12} />}
|
||||
<span>{connectionStatus === 'testing' ? 'Testing…' : connectionMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-muted-foreground">Send devices to:</span>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name="proxmox-import-mode"
|
||||
checked={importMode === 'pending'}
|
||||
onChange={() => setImportMode('pending')}
|
||||
className="cursor-pointer"
|
||||
style={{ accentColor: ACCENT }}
|
||||
/>
|
||||
Pending section
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name="proxmox-import-mode"
|
||||
checked={importMode === 'canvas'}
|
||||
onChange={() => setImportMode('canvas')}
|
||||
className="cursor-pointer"
|
||||
style={{ accentColor: ACCENT }}
|
||||
/>
|
||||
Canvas directly
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground border border-border hover:bg-[#21262d]"
|
||||
onClick={handleTestConnection}
|
||||
disabled={connectionStatus === 'testing' || loading}
|
||||
>
|
||||
{connectionStatus === 'testing'
|
||||
? <Loader2 size={13} className="animate-spin" />
|
||||
: <CheckCircle2 size={13} />}
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
style={{ background: ACCENT, color: '#0d1117' }}
|
||||
className="gap-1.5"
|
||||
onClick={handleFetchDevices}
|
||||
disabled={loading || connectionStatus === 'testing'}
|
||||
>
|
||||
{loading ? <Loader2 size={13} className="animate-spin" /> : <Server size={13} />}
|
||||
{importMode === 'pending' ? 'Import to Pending' : 'Fetch Inventory'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground italic">
|
||||
Leave the token blank to use the token configured on the server (.env).
|
||||
A read-only <span className="font-mono">PVEAuditor</span> role is enough.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{devices.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked.size === devices.length}
|
||||
ref={(el) => { if (el) el.indeterminate = checked.size > 0 && checked.size < devices.length }}
|
||||
onChange={toggleAll}
|
||||
className="w-3 h-3 cursor-pointer"
|
||||
style={{ accentColor: ACCENT }}
|
||||
title="Select all"
|
||||
/>
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Devices ({checked.size}/{devices.length} selected)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(Object.entries(groupedDevices) as [ProxmoxNodeType, ProxmoxNode[]][])
|
||||
.filter(([, group]) => group.length > 0)
|
||||
.map(([type, group]) => {
|
||||
const Icon = DEVICE_TYPE_ICON[type]
|
||||
const color = DEVICE_TYPE_COLOR[type]
|
||||
return (
|
||||
<div key={type}>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Icon size={11} style={{ color }} />
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider" style={{ color }}>
|
||||
{DEVICE_TYPE_LABEL[type]} ({group.length})
|
||||
</span>
|
||||
</div>
|
||||
{group.map((device) => (
|
||||
<div
|
||||
key={device.id}
|
||||
className={`flex items-start gap-2 p-2 mb-1 rounded-md text-xs cursor-pointer transition-colors border ${
|
||||
checked.has(device.id)
|
||||
? 'bg-[#21262d] border-[#e57000]/40'
|
||||
: 'bg-[#21262d] border-transparent hover:bg-[#30363d]'
|
||||
}`}
|
||||
onClick={() => toggleCheck(device.id)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked.has(device.id)}
|
||||
onChange={() => toggleCheck(device.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-3 h-3 mt-0.5 cursor-pointer shrink-0"
|
||||
style={{ accentColor: ACCENT }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-foreground font-medium truncate">{device.label}</div>
|
||||
{device.ip && (
|
||||
<div className="font-mono text-[10px] text-muted-foreground truncate">{device.ip}</div>
|
||||
)}
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{[
|
||||
device.cpu_count ? `${device.cpu_count} vCPU` : null,
|
||||
device.ram_gb ? `${device.ram_gb} GB RAM` : null,
|
||||
device.status,
|
||||
].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 shrink-0 pt-2 border-t border-border">
|
||||
<Button variant="ghost" onClick={handleClose}>Cancel</Button>
|
||||
{devices.length > 0 && (
|
||||
<Button
|
||||
onClick={handleAddToCanvas}
|
||||
disabled={checked.size === 0}
|
||||
style={{ background: ACCENT, color: '#0d1117' }}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus size={13} />
|
||||
Add {checked.size} to Canvas
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { ProxmoxImportModal } from '../ProxmoxImportModal'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
proxmoxApi: {
|
||||
testConnection: vi.fn(),
|
||||
importNetwork: vi.fn(),
|
||||
importToPending: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
||||
|
||||
import { proxmoxApi } from '@/api/client'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onAddToCanvas: vi.fn(),
|
||||
onPendingImported: vi.fn(),
|
||||
}
|
||||
|
||||
const sampleNodes = [
|
||||
{
|
||||
id: 'pve-node-pve1', label: 'pve1', type: 'proxmox' as const,
|
||||
ieee_address: 'pve-node-pve1', hostname: 'pve1', ip: null, status: 'online',
|
||||
cpu_count: 8, ram_gb: 16, disk_gb: 500, vendor: 'Proxmox VE', model: null, parent_ieee: null,
|
||||
},
|
||||
{
|
||||
id: 'pve-pve1-101', label: 'web', type: 'vm' as const,
|
||||
ieee_address: 'pve-pve1-101', hostname: 'web', ip: '10.0.0.5', status: 'online',
|
||||
cpu_count: 2, ram_gb: 4, disk_gb: 32, vendor: 'Proxmox VE', model: 'QEMU', parent_ieee: 'pve-node-pve1',
|
||||
},
|
||||
]
|
||||
|
||||
describe('ProxmoxImportModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(proxmoxApi.testConnection).mockReset()
|
||||
vi.mocked(proxmoxApi.importNetwork).mockReset()
|
||||
vi.mocked(proxmoxApi.importToPending).mockReset()
|
||||
vi.mocked(toast.success).mockReset()
|
||||
vi.mocked(toast.error).mockReset()
|
||||
vi.mocked(toast.info).mockReset()
|
||||
defaultProps.onClose.mockReset()
|
||||
defaultProps.onAddToCanvas.mockReset()
|
||||
defaultProps.onPendingImported.mockReset()
|
||||
})
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(<ProxmoxImportModal {...defaultProps} open={false} />)
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders token fields with a masked secret input', () => {
|
||||
render(<ProxmoxImportModal {...defaultProps} />)
|
||||
expect(screen.getByText('Proxmox VE Import')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('user@pam!tokenname')).toBeDefined()
|
||||
const secret = document.querySelector('input[type="password"]')
|
||||
expect(secret).not.toBeNull()
|
||||
})
|
||||
|
||||
it('errors when testing without a host', async () => {
|
||||
render(<ProxmoxImportModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Enter a Proxmox host'))
|
||||
expect(proxmoxApi.testConnection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows connection message on successful test', async () => {
|
||||
vi.mocked(proxmoxApi.testConnection).mockResolvedValue({
|
||||
data: { connected: true, message: 'Connected to Proxmox VE 8.2.2' },
|
||||
} as never)
|
||||
render(<ProxmoxImportModal {...defaultProps} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or pve.local'), { target: { value: 'pve' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||
await waitFor(() => expect(screen.getByText('Connected to Proxmox VE 8.2.2')).toBeDefined())
|
||||
})
|
||||
|
||||
it('imports to pending by default and notifies parent', async () => {
|
||||
vi.mocked(proxmoxApi.importToPending).mockResolvedValue({
|
||||
data: { id: 'run-1', status: 'running', kind: 'proxmox', ranges: [], devices_found: 0, started_at: '', finished_at: null, error: null },
|
||||
} as never)
|
||||
render(<ProxmoxImportModal {...defaultProps} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or pve.local'), { target: { value: 'pve' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /import to pending/i }))
|
||||
await waitFor(() => expect(proxmoxApi.importToPending).toHaveBeenCalled())
|
||||
expect(defaultProps.onPendingImported).toHaveBeenCalled()
|
||||
expect(proxmoxApi.importNetwork).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches inventory in canvas mode and groups by type', 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: /canvas directly/i }))
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or pve.local'), { target: { value: 'pve' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch inventory/i }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('pve1')).toBeDefined()
|
||||
expect(screen.getByText('web')).toBeDefined()
|
||||
})
|
||||
expect(toast.success).toHaveBeenCalledWith('Found 2 devices')
|
||||
})
|
||||
|
||||
it('sends the token from the form in the payload', async () => {
|
||||
vi.mocked(proxmoxApi.importNetwork).mockResolvedValue({
|
||||
data: { nodes: [], edges: [], device_count: 0 },
|
||||
} as never)
|
||||
render(<ProxmoxImportModal {...defaultProps} />)
|
||||
fireEvent.click(screen.getByRole('radio', { name: /canvas directly/i }))
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.x or pve.local'), { target: { value: 'pve' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('user@pam!tokenname'), { target: { value: 'root@pam!hl' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /fetch inventory/i }))
|
||||
await waitFor(() => expect(proxmoxApi.importNetwork).toHaveBeenCalled())
|
||||
const payload = vi.mocked(proxmoxApi.importNetwork).mock.calls[0][0]
|
||||
expect(payload.token_id).toBe('root@pam!hl')
|
||||
expect(payload.port).toBe(8006)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Shared Proxmox VE import type definitions for the frontend. */
|
||||
|
||||
export type ProxmoxNodeType = 'proxmox' | 'vm' | 'lxc'
|
||||
|
||||
export interface ProxmoxNode {
|
||||
id: string
|
||||
label: string
|
||||
type: ProxmoxNodeType
|
||||
ieee_address: string
|
||||
hostname?: string | null
|
||||
ip?: string | null
|
||||
status: string
|
||||
cpu_count?: number | null
|
||||
ram_gb?: number | null
|
||||
disk_gb?: number | null
|
||||
vendor?: string | null
|
||||
model?: string | null
|
||||
parent_ieee?: string | null
|
||||
}
|
||||
|
||||
export interface ProxmoxEdge {
|
||||
source: string
|
||||
target: string
|
||||
}
|
||||
|
||||
export interface ProxmoxImportResponse {
|
||||
nodes: ProxmoxNode[]
|
||||
edges: ProxmoxEdge[]
|
||||
device_count: number
|
||||
}
|
||||
Reference in New Issue
Block a user