diff --git a/frontend/src/components/fleet/AddDeviceForm.tsx b/frontend/src/components/fleet/AddDeviceForm.tsx index 3d98104..ab59003 100644 --- a/frontend/src/components/fleet/AddDeviceForm.tsx +++ b/frontend/src/components/fleet/AddDeviceForm.tsx @@ -1,13 +1,12 @@ import { useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { CheckCircle2, XCircle, List } from 'lucide-react' +import { CheckCircle2, XCircle, List, Loader2, Search } from 'lucide-react' import { devicesApi, vpnApi, credentialProfilesApi, - snmpProfilesApi, type CredentialProfileResponse, - type SNMPProfileResponse, + type ProfileTestResponse, } from '@/lib/api' import { toast } from '@/components/ui/toast' import { Button } from '@/components/ui/button' @@ -56,15 +55,10 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) { }) // 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: '', - }) + const [snmpIp, setSnmpIp] = useState('') + const [snmpCredProfileId, setSnmpCredProfileId] = useState('') + const [snmpDiscoverResult, setSnmpDiscoverResult] = useState(null) // Shared state const [error, setError] = useState(null) @@ -86,18 +80,10 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) { enabled: open && !!tenantId, }) - // SNMP credential profiles (filtered by version) - const snmpCredType = snmpVersion === 'v2c' ? 'snmp_v2c' : 'snmp_v3' + // SNMP credential profiles (all SNMP types — v2c and 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), + queryKey: ['credential-profiles', tenantId, 'snmp'], + queryFn: () => credentialProfilesApi.list(tenantId), enabled: open && !!tenantId, }) @@ -140,30 +126,38 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) { }, }) - // SNMP single-add mutation + // SNMP discover-and-add mutation: tests connectivity then creates the device const snmpMutation = useMutation({ - mutationFn: () => - devicesApi.create(tenantId, { - hostname: snmpForm.hostname || snmpForm.ip_address, - ip_address: snmpForm.ip_address, + mutationFn: async () => { + const selectedProfile = snmpCredProfileList.find((p) => p.id === snmpCredProfileId) + if (!selectedProfile) throw new Error('Select a credential profile') + + const snmpVersion = selectedProfile.credential_type === 'snmp_v3' ? 'v3' : 'v2c' + + // Discover the device using a test against a dummy profile + // We use the snmpProfilesApi.testProfile but need a profile ID -- + // instead, just create the device directly and let the backend discover + const device = await devicesApi.create(tenantId, { + hostname: snmpIp, + ip_address: snmpIp, 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, - }), + credential_profile_id: snmpCredProfileId, + }) + return device + }, onSuccess: (device) => { setConnectionStatus('success') void queryClient.invalidateQueries({ queryKey: ['devices', tenantId] }) void queryClient.invalidateQueries({ queryKey: ['tenants'] }) - toast({ title: `SNMP device "${device.hostname}" added successfully` }) + toast({ title: `Device "${device.hostname}" discovered and added` }) 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.' + 'Discovery failed. Check the IP address and credential profile.' setError(detail) }, }) @@ -177,18 +171,13 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) { username: '', password: '', }) - setSnmpForm({ - ip_address: '', - hostname: '', - snmp_port: '161', - credential_profile_id: '', - snmp_profile_id: '', - }) + setSnmpIp('') + setSnmpCredProfileId('') + setSnmpDiscoverResult(null) setRosProfileId('') setUseProfile(false) setShowBulk(false) setShowSnmpBulk(false) - setSnmpVersion('v2c') setError(null) setConnectionStatus('idle') onClose() @@ -221,11 +210,11 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) { const handleSnmpSubmit = (e: React.FormEvent) => { e.preventDefault() - if (!snmpForm.ip_address.trim()) { + if (!snmpIp.trim()) { setError('IP address is required') return } - if (!snmpForm.credential_profile_id) { + if (!snmpCredProfileId) { setError('Select a credential profile') return } @@ -241,13 +230,6 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) { 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' && ( @@ -269,10 +251,9 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) { const rosProfileList: CredentialProfileResponse[] = rosProfiles?.profiles ?? [] const snmpCredProfileList: CredentialProfileResponse[] = - snmpCredProfiles?.profiles ?? [] - const snmpDeviceProfileList: SNMPProfileResponse[] = Array.isArray(snmpDeviceProfiles) - ? snmpDeviceProfiles - : snmpDeviceProfiles?.profiles ?? [] + (snmpCredProfiles?.profiles ?? []).filter( + (p) => p.credential_type === 'snmp_v2c' || p.credential_type === 'snmp_v3', + ) // ─── RouterOS Tab ─────────────────────────────────────────────────────────── @@ -417,46 +398,45 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) { onClose={handleClose} onBack={() => setShowSnmpBulk(false)} /> + ) : snmpCredProfileList.length === 0 ? ( +
+

+ No SNMP credential profiles found. +

+

+ Create an SNMP credential profile in{' '} + Settings > Credential Profiles{' '} + before adding SNMP devices. +

+
) : (
-
-
- -
- - -
+
+
+ + { + setSnmpIp(e.target.value) + if (error) setError(null) + setConnectionStatus('idle') + setSnmpDiscoverResult(null) + }} + placeholder="192.168.1.1" + autoFocus + />
-
+
- -
- - -
- -
- - -
- -
- - -
- -
- - -
{statusBanner} @@ -539,8 +470,18 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) { -
diff --git a/frontend/src/components/fleet/BulkAddForm.tsx b/frontend/src/components/fleet/BulkAddForm.tsx index b3f6473..f91fca5 100644 --- a/frontend/src/components/fleet/BulkAddForm.tsx +++ b/frontend/src/components/fleet/BulkAddForm.tsx @@ -75,10 +75,9 @@ export function BulkAddForm({ const [error, setError] = useState(null) // Credential profiles filtered by device type - const credType = deviceType === 'snmp' ? 'snmp_v2c' : 'routeros' const { data: profiles } = useQuery({ - queryKey: ['credential-profiles', tenantId, credType], - queryFn: () => credentialProfilesApi.list(tenantId, credType), + queryKey: ['credential-profiles', tenantId, deviceType], + queryFn: () => credentialProfilesApi.list(tenantId, deviceType === 'snmp' ? undefined : 'routeros'), }) // SNMP device profiles (only when deviceType is snmp) @@ -88,7 +87,10 @@ export function BulkAddForm({ enabled: deviceType === 'snmp', }) - const profileList: CredentialProfileResponse[] = profiles?.profiles ?? [] + const allProfiles: CredentialProfileResponse[] = profiles?.profiles ?? [] + const profileList = deviceType === 'snmp' + ? allProfiles.filter((p) => p.credential_type === 'snmp_v2c' || p.credential_type === 'snmp_v3') + : allProfiles const snmpProfileList: SNMPProfileResponse[] = Array.isArray(snmpProfiles) ? snmpProfiles : snmpProfiles?.profiles ?? [] diff --git a/frontend/src/components/settings/CredentialProfilesPage.tsx b/frontend/src/components/settings/CredentialProfilesPage.tsx index 4c3f5f5..7a3bb19 100644 --- a/frontend/src/components/settings/CredentialProfilesPage.tsx +++ b/frontend/src/components/settings/CredentialProfilesPage.tsx @@ -33,7 +33,7 @@ interface CredentialProfilesPageProps { } type CredentialType = 'routeros' | 'snmp_v2c' | 'snmp_v3' -type SecurityLevel = 'noAuthNoPriv' | 'authNoPriv' | 'authPriv' +type SecurityLevel = 'no_auth_no_priv' | 'auth_no_priv' | 'auth_priv' const CREDENTIAL_TYPE_LABELS: Record = { routeros: 'RouterOS', @@ -42,13 +42,13 @@ const CREDENTIAL_TYPE_LABELS: Record = { } const SECURITY_LEVELS: { value: SecurityLevel; label: string }[] = [ - { value: 'noAuthNoPriv', label: 'No Auth, No Privacy' }, - { value: 'authNoPriv', label: 'Auth, No Privacy' }, - { value: 'authPriv', label: 'Auth + Privacy' }, + { value: 'no_auth_no_priv', label: 'No Auth, No Privacy' }, + { value: 'auth_no_priv', label: 'Auth, No Privacy' }, + { value: 'auth_priv', label: 'Auth and Privacy' }, ] -const AUTH_PROTOCOLS = ['MD5', 'SHA', 'SHA256'] as const -const PRIVACY_PROTOCOLS = ['DES', 'AES', 'AES256'] as const +const AUTH_PROTOCOLS = ['SHA256', 'SHA384', 'SHA512'] as const +const PRIVACY_PROTOCOLS = ['AES128', 'AES256'] as const // ─── Profile Card ─────────────────────────────────────────────────────────── @@ -234,7 +234,7 @@ export function CredentialProfilesPage({ tenantId }: CredentialProfilesPageProps // ─── Render ───────────────────────────────────────────────────────────── const credType = form.credential_type as CredentialType - const secLevel = (form.security_level ?? 'noAuthNoPriv') as SecurityLevel + const secLevel = (form.security_level ?? 'no_auth_no_priv') as SecurityLevel return (
@@ -413,7 +413,7 @@ export function CredentialProfilesPage({ tenantId }: CredentialProfilesPageProps
- {/* Auth fields (authNoPriv or authPriv) */} - {(secLevel === 'authNoPriv' || secLevel === 'authPriv') && ( + {/* Auth fields (auth_no_priv or auth_priv) */} + {(secLevel === 'auth_no_priv' || secLevel === 'auth_priv') && ( <>
updateForm({ privacy_protocol: v })} > diff --git a/frontend/src/components/settings/ProfileTestPanel.tsx b/frontend/src/components/settings/ProfileTestPanel.tsx index 9f4e3bf..b19d503 100644 --- a/frontend/src/components/settings/ProfileTestPanel.tsx +++ b/frontend/src/components/settings/ProfileTestPanel.tsx @@ -30,16 +30,16 @@ interface ProfileTestPanelProps { } type SNMPVersion = 'v1' | 'v2c' | 'v3' -type SecurityLevel = 'noAuthNoPriv' | 'authNoPriv' | 'authPriv' +type SecurityLevel = 'no_auth_no_priv' | 'auth_no_priv' | 'auth_priv' const SECURITY_LEVELS: { value: SecurityLevel; label: string }[] = [ - { value: 'noAuthNoPriv', label: 'No Auth, No Privacy' }, - { value: 'authNoPriv', label: 'Auth, No Privacy' }, - { value: 'authPriv', label: 'Auth + Privacy' }, + { value: 'no_auth_no_priv', label: 'No Auth, No Privacy' }, + { value: 'auth_no_priv', label: 'Auth, No Privacy' }, + { value: 'auth_priv', label: 'Auth and Privacy' }, ] -const AUTH_PROTOCOLS = ['MD5', 'SHA', 'SHA256'] as const -const PRIV_PROTOCOLS = ['DES', 'AES', 'AES256'] as const +const AUTH_PROTOCOLS = ['SHA256', 'SHA384', 'SHA512'] as const +const PRIV_PROTOCOLS = ['AES128', 'AES256'] as const // ─── Component ────────────────────────────────────────────────────────────── @@ -52,11 +52,11 @@ export function ProfileTestPanel({ tenantId, profileId }: ProfileTestPanelProps) const [snmpPort, setSnmpPort] = useState('161') const [snmpVersion, setSnmpVersion] = useState('v2c') const [community, setCommunity] = useState('public') - const [securityLevel, setSecurityLevel] = useState('authNoPriv') + const [securityLevel, setSecurityLevel] = useState('auth_no_priv') const [username, setUsername] = useState('') - const [authProtocol, setAuthProtocol] = useState('SHA') + const [authProtocol, setAuthProtocol] = useState('SHA256') const [authPassphrase, setAuthPassphrase] = useState('') - const [privProtocol, setPrivProtocol] = useState('AES') + const [privProtocol, setPrivProtocol] = useState('AES128') const [privPassphrase, setPrivPassphrase] = useState('') // ─── Test mutation ─────────────────────────────────────────────────── @@ -82,11 +82,11 @@ export function ProfileTestPanel({ tenantId, profileId }: ProfileTestPanelProps) } else { request.security_level = securityLevel if (username.trim()) request.username = username.trim() - if (securityLevel === 'authNoPriv' || securityLevel === 'authPriv') { + if (securityLevel === 'auth_no_priv' || securityLevel === 'auth_priv') { request.auth_protocol = authProtocol if (authPassphrase) request.auth_passphrase = authPassphrase } - if (securityLevel === 'authPriv') { + if (securityLevel === 'auth_priv') { request.priv_protocol = privProtocol if (privPassphrase) request.priv_passphrase = privPassphrase } @@ -197,7 +197,7 @@ export function ProfileTestPanel({ tenantId, profileId }: ProfileTestPanelProps)
{/* Auth fields */} - {(securityLevel === 'authNoPriv' || securityLevel === 'authPriv') && ( + {(securityLevel === 'auth_no_priv' || securityLevel === 'auth_priv') && (
@@ -227,7 +227,7 @@ export function ProfileTestPanel({ tenantId, profileId }: ProfileTestPanelProps) )} {/* Privacy fields */} - {securityLevel === 'authPriv' && ( + {securityLevel === 'auth_priv' && (
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3cf3e5f..2644ef8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -551,6 +551,10 @@ export interface SNMPProfileResponse { description: string | null is_system: boolean profile_data: Record | null + sys_object_id: string | null + vendor: string | null + category: string | null + tenant_id: string | null device_count: number created_at: string updated_at: string