import { useState, useRef, useCallback } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Plus, Pencil, Trash2, ArrowLeft, Upload, Loader2, X, Network, } from 'lucide-react' import { snmpProfilesApi, type SNMPProfileResponse, type SNMPProfileCreate, type OIDNode, } from '@/lib/api' import { useAuth, canWrite } from '@/lib/auth' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { EmptyState } from '@/components/ui/empty-state' import { OIDTreeBrowser } from '@/components/settings/OIDTreeBrowser' import { ProfileTestPanel } from '@/components/settings/ProfileTestPanel' // ─── Types ────────────────────────────────────────────────────────────────── interface SNMPProfileEditorPageProps { tenantId: string } type ViewMode = 'list' | 'edit' interface PollGroupOID { oid: string name: string type: string } interface PollGroup { interval_multiplier: number label: string scalars: PollGroupOID[] tables: unknown[] } type PollGroupKey = 'fast' | 'standard' | 'slow' const CATEGORIES = [ { value: 'generic', label: 'Generic' }, { value: 'switch', label: 'Switch' }, { value: 'router', label: 'Router' }, { value: 'access_point', label: 'Access Point' }, { value: 'ups', label: 'UPS' }, { value: 'printer', label: 'Printer' }, { value: 'server', label: 'Server' }, ] as const const DEFAULT_POLL_GROUPS: Record = { fast: { interval_multiplier: 1, label: 'Fast (60s)', scalars: [], tables: [] }, standard: { interval_multiplier: 5, label: 'Standard (5m)', scalars: [], tables: [] }, slow: { interval_multiplier: 30, label: 'Slow (30m)', scalars: [], tables: [] }, } function buildEmptyPollGroups(): Record { return { fast: { ...DEFAULT_POLL_GROUPS.fast, scalars: [], tables: [] }, standard: { ...DEFAULT_POLL_GROUPS.standard, scalars: [], tables: [] }, slow: { ...DEFAULT_POLL_GROUPS.slow, scalars: [], tables: [] }, } } // ─── Profile Card ─────────────────────────────────────────────────────────── function ProfileCard({ profile, onEdit, onDelete, canModify, }: { profile: SNMPProfileResponse onEdit: (profile: SNMPProfileResponse) => void onDelete: (profileId: string) => void canModify: boolean }) { return (
{profile.name} {profile.is_system && ( system )}
{profile.description && (
{profile.description}
)}
{profile.device_count} device{profile.device_count !== 1 ? 's' : ''} {canModify && !profile.is_system && ( <> )}
) } // ─── Manual OID Add Row ───────────────────────────────────────────────────── function ManualOIDAddRow({ onAdd, }: { onAdd: (oid: PollGroupOID) => void }) { const [oid, setOid] = useState('') const [name, setName] = useState('') const [type, setType] = useState('gauge32') function handleAdd() { if (!oid.trim() || !name.trim()) return onAdd({ oid: oid.trim(), name: name.trim(), type }) setOid('') setName('') setType('gauge32') } return (
setOid(e.target.value)} placeholder="1.3.6.1.2.1..." className="h-7 text-xs" />
setName(e.target.value)} placeholder="metric_name" className="h-7 text-xs" />
) } // ─── Poll Group Section ───────────────────────────────────────────────────── function PollGroupSection({ groupKey, group, isActive, onSetActive, onRemoveOid, onAddOid, }: { groupKey: PollGroupKey group: PollGroup isActive: boolean onSetActive: () => void onRemoveOid: (groupKey: PollGroupKey, index: number) => void onAddOid: (groupKey: PollGroupKey, oid: PollGroupOID) => void }) { return (
{group.scalars.length} OID{group.scalars.length !== 1 ? 's' : ''}
{group.scalars.length > 0 && (
{group.scalars.map((s, i) => (
{s.name} {s.oid} {s.type}
))}
)} onAddOid(groupKey, oid)} />
) } // ─── Main Component ───────────────────────────────────────────────────────── export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps) { const { user } = useAuth() const queryClient = useQueryClient() const userCanWrite = canWrite(user) const fileInputRef = useRef(null) // ─── View state ──────────────────────────────────────────────────────── const [view, setView] = useState('list') const [editingProfileId, setEditingProfileId] = useState(null) // ─── Form state ──────────────────────────────────────────────────────── const [name, setName] = useState('') const [description, setDescription] = useState('') const [vendor, setVendor] = useState('') const [category, setCategory] = useState('generic') const [sysObjectId, setSysObjectId] = useState('') const [pollGroups, setPollGroups] = useState>(buildEmptyPollGroups) const [activePollGroup, setActivePollGroup] = useState('standard') const [selectedOids, setSelectedOids] = useState>(new Set()) // ─── MIB state ───────────────────────────────────────────────────────── const [parsedNodes, setParsedNodes] = useState([]) const [parsedModuleName, setParsedModuleName] = useState(null) // ─── Data query ──────────────────────────────────────────────────────── const { data: profiles, isLoading } = useQuery({ queryKey: ['snmp-profiles', tenantId], queryFn: () => snmpProfilesApi.list(tenantId), enabled: !!tenantId, }) const systemProfiles = profiles?.filter((p) => p.is_system) ?? [] const customProfiles = profiles?.filter((p) => !p.is_system) ?? [] // ─── Mutations ───────────────────────────────────────────────────────── const parseMibMutation = useMutation({ mutationFn: (file: File) => snmpProfilesApi.parseMib(tenantId, file), onSuccess: (data) => { setParsedNodes(data.nodes) setParsedModuleName(data.module_name) toast.success(`Parsed ${data.node_count} OIDs from ${data.module_name}`) }, onError: (err: unknown) => { const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? 'Failed to parse MIB file' toast.error(detail) }, }) const saveMutation = useMutation({ mutationFn: (data: SNMPProfileCreate) => editingProfileId ? snmpProfilesApi.update(tenantId, editingProfileId, data) : snmpProfilesApi.create(tenantId, data), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['snmp-profiles', tenantId] }) toast.success(editingProfileId ? 'Profile updated' : 'Profile created') resetAndGoToList() }, onError: (err: unknown) => { const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? 'Failed to save profile' toast.error(detail) }, }) const deleteMutation = useMutation({ mutationFn: (profileId: string) => snmpProfilesApi.delete(tenantId, profileId), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['snmp-profiles', tenantId] }) toast.success('Profile deleted') }, onError: (err: unknown) => { const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? 'Cannot delete profile' toast.error(detail) }, }) // ─── Helpers ─────────────────────────────────────────────────────────── function resetAndGoToList() { setView('list') setEditingProfileId(null) setName('') setDescription('') setVendor('') setCategory('generic') setSysObjectId('') setPollGroups(buildEmptyPollGroups()) setActivePollGroup('standard') setSelectedOids(new Set()) setParsedNodes([]) setParsedModuleName(null) } function openCreate() { resetAndGoToList() setView('edit') } function openEdit(profile: SNMPProfileResponse) { setEditingProfileId(profile.id) setName(profile.name) setDescription(profile.description ?? '') setVendor('') setCategory('generic') setSysObjectId('') // Parse existing profile_data poll groups const pd = profile.profile_data as { poll_groups?: Record } | null if (pd?.poll_groups) { const groups = buildEmptyPollGroups() for (const key of ['fast', 'standard', 'slow'] as PollGroupKey[]) { if (pd.poll_groups[key]) { groups[key] = { ...groups[key], interval_multiplier: pd.poll_groups[key].interval_multiplier, scalars: pd.poll_groups[key].scalars ?? [], tables: pd.poll_groups[key].tables ?? [], } } } setPollGroups(groups) // Rebuild selected OIDs set from existing poll groups const oids = new Set() for (const g of Object.values(groups)) { for (const s of g.scalars) oids.add(s.oid) } setSelectedOids(oids) } else { setPollGroups(buildEmptyPollGroups()) setSelectedOids(new Set()) } setParsedNodes([]) setParsedModuleName(null) setActivePollGroup('standard') setView('edit') } function handleDelete(profileId: string) { if (confirm('Delete this SNMP profile? Devices using it will fall back to the default profile.')) { deleteMutation.mutate(profileId) } } function handleFileUpload(e: React.ChangeEvent) { const file = e.target.files?.[0] if (file) { parseMibMutation.mutate(file) } // Reset input so re-uploading the same file works if (fileInputRef.current) fileInputRef.current.value = '' } const handleToggleOid = useCallback( (oid: string, node: OIDNode) => { setSelectedOids((prev) => { const next = new Set(prev) if (next.has(oid)) { next.delete(oid) // Remove from whichever poll group contains it setPollGroups((pg) => { const updated = { ...pg } for (const key of ['fast', 'standard', 'slow'] as PollGroupKey[]) { updated[key] = { ...updated[key], scalars: updated[key].scalars.filter((s) => s.oid !== oid), } } return updated }) } else { next.add(oid) // Add to the active poll group setPollGroups((pg) => ({ ...pg, [activePollGroup]: { ...pg[activePollGroup], scalars: [ ...pg[activePollGroup].scalars, { oid, name: node.name, type: node.type ?? 'string' }, ], }, })) } return next }) }, [activePollGroup], ) function handleRemoveOid(groupKey: PollGroupKey, index: number) { setPollGroups((pg) => { const removed = pg[groupKey].scalars[index] const updated = { ...pg, [groupKey]: { ...pg[groupKey], scalars: pg[groupKey].scalars.filter((_, i) => i !== index), }, } // Also remove from selected set if (removed) { setSelectedOids((prev) => { const next = new Set(prev) next.delete(removed.oid) return next }) } return updated }) } function handleManualAdd(groupKey: PollGroupKey, oid: PollGroupOID) { setPollGroups((pg) => ({ ...pg, [groupKey]: { ...pg[groupKey], scalars: [...pg[groupKey].scalars, oid], }, })) setSelectedOids((prev) => new Set(prev).add(oid.oid)) } function handleSave() { if (!name.trim()) { toast.error('Profile name is required') return } const profileData: Record = { version: 1, poll_groups: { fast: { interval_multiplier: pollGroups.fast.interval_multiplier, scalars: pollGroups.fast.scalars, tables: pollGroups.fast.tables, }, standard: { interval_multiplier: pollGroups.standard.interval_multiplier, scalars: pollGroups.standard.scalars, tables: pollGroups.standard.tables, }, slow: { interval_multiplier: pollGroups.slow.interval_multiplier, scalars: pollGroups.slow.scalars, tables: pollGroups.slow.tables, }, }, } const payload: SNMPProfileCreate = { name: name.trim(), profile_data: profileData, } if (description.trim()) payload.description = description.trim() if (vendor.trim()) payload.vendor = vendor.trim() if (category !== 'generic') payload.category = category if (sysObjectId.trim()) payload.sys_object_id = sysObjectId.trim() saveMutation.mutate(payload) } // ─── Loading state ──────────────────────────────────────────────────── if (isLoading) { return (
) } // ─── List View ──────────────────────────────────────────────────────── if (view === 'list') { return (
{/* Header */}

SNMP Profiles

Manage SNMP polling profiles for device monitoring

{userCanWrite && ( )}
{/* System profiles */} {systemProfiles.length > 0 && (

System Profiles

{systemProfiles.map((p) => ( ))}
)} {/* Custom profiles */}

Custom Profiles

{customProfiles.length === 0 ? ( ) : ( customProfiles.map((p) => ( )) )}
) } // ─── Edit View ──────────────────────────────────────────────────────── return (
{/* Header */}

{editingProfileId ? 'Edit SNMP Profile' : 'New SNMP Profile'}

{/* Profile Fields */}
setName(e.target.value)} placeholder="e.g. Ubiquiti EdgeSwitch" className="mt-1" />
setDescription(e.target.value)} placeholder="Brief description of this profile" className="mt-1" />
setVendor(e.target.value)} placeholder="e.g. Ubiquiti" className="mt-1" />
setSysObjectId(e.target.value)} placeholder="1.3.6.1.4.1...." className="mt-1" />
{/* MIB Upload */}

MIB Upload

{parsedModuleName && ( Module: {parsedModuleName} )}

Upload a vendor MIB file (.mib, .txt, .my) to browse and select OIDs

{parsedNodes.length > 0 && ( )}
{/* Poll Groups */}

Poll Groups

Assign OIDs to poll groups with different collection intervals. Click a group header to make it active -- OIDs selected in the tree above will be added to the active group.

{(['fast', 'standard', 'slow'] as PollGroupKey[]).map((key) => ( setActivePollGroup(key)} onRemoveOid={handleRemoveOid} onAddOid={handleManualAdd} /> ))}
{/* Test Panel */} {/* Actions */}
) }