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:
@@ -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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user