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:
Pouzor
2026-07-05 18:58:12 +02:00
parent 1d40d70150
commit ab36ba6f81
29 changed files with 2349 additions and 29 deletions
@@ -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>