From 7644e561d791a8bc70981df2b556436513d4e117 Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Sat, 21 Mar 2026 20:34:09 -0500 Subject: [PATCH] feat(20-03): add SNMP profile test panel component - Collapsible test-against-device panel with SNMP v1/v2c/v3 fields - Conditional v3 fields (security level, auth/priv protocols) - Test button calls snmpProfilesApi.testProfile - Success shows green device-info panel (sysName, sysDescr, sysObjectID) - Failure shows red error panel - Disabled when profile not yet saved Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/settings/ProfileTestPanel.tsx | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 frontend/src/components/settings/ProfileTestPanel.tsx diff --git a/frontend/src/components/settings/ProfileTestPanel.tsx b/frontend/src/components/settings/ProfileTestPanel.tsx new file mode 100644 index 0000000..9f4e3bf --- /dev/null +++ b/frontend/src/components/settings/ProfileTestPanel.tsx @@ -0,0 +1,330 @@ +import { useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import { + ChevronRight, + CheckCircle2, + XCircle, + Loader2, +} from 'lucide-react' +import { + snmpProfilesApi, + type ProfileTestRequest, + type ProfileTestResponse, +} from '@/lib/api' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface ProfileTestPanelProps { + tenantId: string + profileId: string | null +} + +type SNMPVersion = 'v1' | 'v2c' | 'v3' +type SecurityLevel = 'noAuthNoPriv' | 'authNoPriv' | 'authPriv' + +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' }, +] + +const AUTH_PROTOCOLS = ['MD5', 'SHA', 'SHA256'] as const +const PRIV_PROTOCOLS = ['DES', 'AES', 'AES256'] as const + +// ─── Component ────────────────────────────────────────────────────────────── + +export function ProfileTestPanel({ tenantId, profileId }: ProfileTestPanelProps) { + const [expanded, setExpanded] = useState(false) + + // ─── Form state ────────────────────────────────────────────────────── + + const [ipAddress, setIpAddress] = useState('') + const [snmpPort, setSnmpPort] = useState('161') + const [snmpVersion, setSnmpVersion] = useState('v2c') + const [community, setCommunity] = useState('public') + const [securityLevel, setSecurityLevel] = useState('authNoPriv') + const [username, setUsername] = useState('') + const [authProtocol, setAuthProtocol] = useState('SHA') + const [authPassphrase, setAuthPassphrase] = useState('') + const [privProtocol, setPrivProtocol] = useState('AES') + const [privPassphrase, setPrivPassphrase] = useState('') + + // ─── Test mutation ─────────────────────────────────────────────────── + + const testMutation = useMutation({ + mutationFn: (data: ProfileTestRequest) => + snmpProfilesApi.testProfile(tenantId, profileId!, data), + }) + + function handleTest() { + if (!profileId || !ipAddress.trim()) return + + const request: ProfileTestRequest = { + ip_address: ipAddress.trim(), + snmp_version: snmpVersion, + } + + const port = parseInt(snmpPort, 10) + if (!isNaN(port) && port !== 161) request.snmp_port = port + + if (snmpVersion === 'v1' || snmpVersion === 'v2c') { + if (community.trim()) request.community = community.trim() + } else { + request.security_level = securityLevel + if (username.trim()) request.username = username.trim() + if (securityLevel === 'authNoPriv' || securityLevel === 'authPriv') { + request.auth_protocol = authProtocol + if (authPassphrase) request.auth_passphrase = authPassphrase + } + if (securityLevel === 'authPriv') { + request.priv_protocol = privProtocol + if (privPassphrase) request.priv_passphrase = privPassphrase + } + } + + testMutation.mutate(request) + } + + const result = testMutation.data as ProfileTestResponse | undefined + + // ─── Render ────────────────────────────────────────────────────────── + + return ( +
+ {/* Header */} + + + {expanded && ( +
+ {/* Connection fields */} +
+
+ + setIpAddress(e.target.value)} + placeholder="192.168.1.1" + className="mt-1" + /> +
+
+ + setSnmpPort(e.target.value)} + placeholder="161" + className="mt-1" + /> +
+
+ +
+ + +
+ + {/* v1/v2c: community string */} + {(snmpVersion === 'v1' || snmpVersion === 'v2c') && ( +
+ + setCommunity(e.target.value)} + placeholder="public" + className="mt-1" + /> +
+ )} + + {/* v3 fields */} + {snmpVersion === 'v3' && ( + <> +
+ + +
+ +
+ + setUsername(e.target.value)} + placeholder="snmpuser" + className="mt-1" + /> +
+ + {/* Auth fields */} + {(securityLevel === 'authNoPriv' || securityLevel === 'authPriv') && ( +
+
+ + +
+
+ + setAuthPassphrase(e.target.value)} + className="mt-1" + /> +
+
+ )} + + {/* Privacy fields */} + {securityLevel === 'authPriv' && ( +
+
+ + +
+
+ + setPrivPassphrase(e.target.value)} + className="mt-1" + /> +
+
+ )} + + )} + + {/* Test button */} +
+ + {!profileId && ( +

+ Save the profile first to test it +

+ )} +
+ + {/* Results */} + {result && ( +
+
+ {result.success ? ( + <> + + Device reachable + + ) : ( + <> + + Device unreachable + + )} +
+ {result.success && result.device_info && ( +
+ {result.device_info.sys_name && ( + + )} + {result.device_info.sys_descr && ( + + )} + {result.device_info.sys_object_id && ( + + )} +
+ )} + {!result.success && result.error && ( +

{result.error}

+ )} +
+ )} +
+ )} +
+ ) +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ) +}