From 74ddaad551b3438c1c9eb3ab4bed0b658d3b42bb Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Sat, 21 Mar 2026 19:59:24 -0500 Subject: [PATCH] feat(19-02): redesign Add Device dialog with RouterOS, SNMP, and VPN tabs - Always show three-tab layout (RouterOS, SNMP, VPN) instead of conditional two-tab - RouterOS tab: credential profile toggle (profile mode vs manual credentials) - SNMP tab: version selector (v2c/v3), credential profile, device profile, port - Both tabs have "Add Multiple" toggle to switch to BulkAddForm - VPN tab renders existing VpnOnboardingWizard unchanged - All form state resets on dialog close Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/fleet/AddDeviceForm.tsx | 559 ++++++++++++++---- 1 file changed, 459 insertions(+), 100 deletions(-) diff --git a/frontend/src/components/fleet/AddDeviceForm.tsx b/frontend/src/components/fleet/AddDeviceForm.tsx index 8c20eb0..3d98104 100644 --- a/frontend/src/components/fleet/AddDeviceForm.tsx +++ b/frontend/src/components/fleet/AddDeviceForm.tsx @@ -1,7 +1,14 @@ import { useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { CheckCircle2, XCircle } from 'lucide-react' -import { devicesApi, vpnApi } from '@/lib/api' +import { CheckCircle2, XCircle, List } from 'lucide-react' +import { + devicesApi, + vpnApi, + credentialProfilesApi, + snmpProfilesApi, + type CredentialProfileResponse, + type SNMPProfileResponse, +} from '@/lib/api' import { toast } from '@/components/ui/toast' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -13,8 +20,16 @@ import { DialogTitle, DialogFooter, } from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { VpnOnboardingWizard } from '@/components/vpn/VpnOnboardingWizard' +import { BulkAddForm } from '@/components/fleet/BulkAddForm' interface Props { tenantId: string @@ -26,6 +41,11 @@ type ConnectionStatus = 'idle' | 'success' | 'error' export function AddDeviceForm({ tenantId, open, onClose }: Props) { const queryClient = useQueryClient() + + // RouterOS state + const [useProfile, setUseProfile] = useState(false) + const [showBulk, setShowBulk] = useState(false) + const [rosProfileId, setRosProfileId] = useState('') const [form, setForm] = useState({ hostname: '', ip_address: '', @@ -34,6 +54,19 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) { username: '', password: '', }) + + // SNMP state + const [snmpVersion, setSnmpVersion] = useState<'v2c' | 'v3'>('v2c') + const [showSnmpBulk, setShowSnmpBulk] = useState(false) + const [snmpForm, setSnmpForm] = useState({ + ip_address: '', + hostname: '', + snmp_port: '161', + credential_profile_id: '', + snmp_profile_id: '', + }) + + // Shared state const [error, setError] = useState(null) const [connectionStatus, setConnectionStatus] = useState('idle') @@ -46,16 +79,51 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) { const vpnEnabled = vpnConfig?.is_enabled ?? false - const mutation = useMutation({ - mutationFn: () => - devicesApi.create(tenantId, { + // RouterOS credential profiles + const { data: rosProfiles } = useQuery({ + queryKey: ['credential-profiles', tenantId, 'routeros'], + queryFn: () => credentialProfilesApi.list(tenantId, 'routeros'), + enabled: open && !!tenantId, + }) + + // SNMP credential profiles (filtered by version) + const snmpCredType = snmpVersion === 'v2c' ? 'snmp_v2c' : 'snmp_v3' + const { data: snmpCredProfiles } = useQuery({ + queryKey: ['credential-profiles', tenantId, snmpCredType], + queryFn: () => credentialProfilesApi.list(tenantId, snmpCredType), + enabled: open && !!tenantId, + }) + + // SNMP device profiles + const { data: snmpDeviceProfiles } = useQuery({ + queryKey: ['snmp-profiles', tenantId], + queryFn: () => snmpProfilesApi.list(tenantId), + enabled: open && !!tenantId, + }) + + // RouterOS single-add mutation + const rosMutation = useMutation({ + mutationFn: () => { + if (useProfile) { + return devicesApi.create(tenantId, { + hostname: form.hostname || form.ip_address, + ip_address: form.ip_address, + device_type: 'routeros', + credential_profile_id: rosProfileId, + api_port: parseInt(form.api_port) || 8728, + api_ssl_port: parseInt(form.api_ssl_port) || 8729, + }) + } + return devicesApi.create(tenantId, { hostname: form.hostname || form.ip_address, ip_address: form.ip_address, + device_type: 'routeros', api_port: parseInt(form.api_port) || 8728, api_ssl_port: parseInt(form.api_ssl_port) || 8729, username: form.username, password: form.password, - }), + }) + }, onSuccess: (device) => { setConnectionStatus('success') void queryClient.invalidateQueries({ queryKey: ['devices', tenantId] }) @@ -66,8 +134,36 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) { onError: (err: unknown) => { setConnectionStatus('error') const detail = - (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail - ?? 'Connection failed. Check the IP address, port, and credentials.' + (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? + 'Connection failed. Check the IP address, port, and credentials.' + setError(detail) + }, + }) + + // SNMP single-add mutation + const snmpMutation = useMutation({ + mutationFn: () => + devicesApi.create(tenantId, { + hostname: snmpForm.hostname || snmpForm.ip_address, + ip_address: snmpForm.ip_address, + device_type: 'snmp', + snmp_port: parseInt(snmpForm.snmp_port) || 161, + snmp_version: snmpVersion, + credential_profile_id: snmpForm.credential_profile_id || undefined, + snmp_profile_id: snmpForm.snmp_profile_id || undefined, + }), + onSuccess: (device) => { + setConnectionStatus('success') + void queryClient.invalidateQueries({ queryKey: ['devices', tenantId] }) + void queryClient.invalidateQueries({ queryKey: ['tenants'] }) + toast({ title: `SNMP device "${device.hostname}" added successfully` }) + setTimeout(() => handleClose(), 1000) + }, + onError: (err: unknown) => { + setConnectionStatus('error') + const detail = + (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? + 'Failed to add SNMP device. Check IP and credentials.' setError(detail) }, }) @@ -81,6 +177,18 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) { username: '', password: '', }) + setSnmpForm({ + ip_address: '', + hostname: '', + snmp_port: '161', + credential_profile_id: '', + snmp_profile_id: '', + }) + setRosProfileId('') + setUseProfile(false) + setShowBulk(false) + setShowSnmpBulk(false) + setSnmpVersion('v2c') setError(null) setConnectionStatus('idle') onClose() @@ -92,92 +200,60 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) { handleClose() } - const handleSubmit = (e: React.FormEvent) => { + const handleRosSubmit = (e: React.FormEvent) => { e.preventDefault() - if (!form.ip_address.trim() || !form.username.trim() || !form.password.trim()) { - setError('IP address, username, and password are required') + if (!form.ip_address.trim()) { + setError('IP address is required') + return + } + if (useProfile && !rosProfileId) { + setError('Select a credential profile') + return + } + if (!useProfile && (!form.username.trim() || !form.password.trim())) { + setError('Username and password are required') return } setError(null) setConnectionStatus('idle') - mutation.mutate() + rosMutation.mutate() } - const update = (field: keyof typeof form) => (e: React.ChangeEvent) => { - setForm((f) => ({ ...f, [field]: e.target.value })) - if (error) setError(null) + const handleSnmpSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!snmpForm.ip_address.trim()) { + setError('IP address is required') + return + } + if (!snmpForm.credential_profile_id) { + setError('Select a credential profile') + return + } + setError(null) setConnectionStatus('idle') + snmpMutation.mutate() } - const directConnectionForm = ( -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
+ const updateRos = + (field: keyof typeof form) => (e: React.ChangeEvent) => { + setForm((f) => ({ ...f, [field]: e.target.value })) + if (error) setError(null) + setConnectionStatus('idle') + } + const updateSnmp = + (field: keyof typeof snmpForm) => (e: React.ChangeEvent) => { + setSnmpForm((f) => ({ ...f, [field]: e.target.value })) + if (error) setError(null) + setConnectionStatus('idle') + } + + const statusBanner = ( + <> {connectionStatus === 'success' && (
-

Device connected and added successfully

+

Device added successfully

)} {connectionStatus === 'error' && error && ( @@ -186,14 +262,287 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) {

{error}

)} + + ) - - - + // Helper to get profile list as array + const rosProfileList: CredentialProfileResponse[] = + rosProfiles?.profiles ?? [] + const snmpCredProfileList: CredentialProfileResponse[] = + snmpCredProfiles?.profiles ?? [] + const snmpDeviceProfileList: SNMPProfileResponse[] = Array.isArray(snmpDeviceProfiles) + ? snmpDeviceProfiles + : snmpDeviceProfiles?.profiles ?? [] + + // ─── RouterOS Tab ─────────────────────────────────────────────────────────── + + const routerosTab = showBulk ? ( + setShowBulk(false)} + /> + ) : ( + +
+ +
+ +
+ {useProfile && ( +
+ + +
+ )} + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {!useProfile && ( + <> +
+ + +
+ +
+ + +
+ + )} +
+ + {statusBanner} + + + +
+ + +
+
+ + ) + + // ─── SNMP Tab ───────────────────────────────────────────────────────────── + + const snmpTab = showSnmpBulk ? ( + setShowSnmpBulk(false)} + /> + ) : ( +
+
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {statusBanner} + + + +
+ + +
) @@ -204,12 +553,27 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) { Add Device - {vpnEnabled ? ( - - - VPN Onboarding - Direct Connection - + + + + RouterOS + + + SNMP + + {vpnEnabled && ( + + VPN + + )} + + + {routerosTab} + + + {snmpTab} + + {vpnEnabled && ( - - {directConnectionForm} - - - ) : ( - directConnectionForm - )} + )} + )