From f7aa685a5d72fdced8b6db081a9dc07102ed1a9e Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Sun, 22 Mar 2026 00:41:16 -0500 Subject: [PATCH] refactor(ui): redesign SNMP profile editor for progressive disclosure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The profile editor was dumping everything on one page — MIB upload, OID tree, poll groups, test panel — overwhelming for anyone who doesn't live and breathe SNMP. Redesigned with tabbed layout: - Basics tab: name, category, description (the 95% case) - OIDs tab: flat table of what's collected, simple manual add - Advanced tab: MIB upload, OID tree browser, auto-detection - Test tab: test profile against live device (edit mode only) Also added Clone action on built-in profiles — the primary way to create a custom profile. Most users should never need the Advanced tab at all. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../settings/SNMPProfileEditorPage.tsx | 737 +++++++++--------- 1 file changed, 351 insertions(+), 386 deletions(-) diff --git a/frontend/src/components/settings/SNMPProfileEditorPage.tsx b/frontend/src/components/settings/SNMPProfileEditorPage.tsx index 130d175..ea30e82 100644 --- a/frontend/src/components/settings/SNMPProfileEditorPage.tsx +++ b/frontend/src/components/settings/SNMPProfileEditorPage.tsx @@ -9,6 +9,9 @@ import { Loader2, X, Network, + Copy, + ChevronDown, + ChevronRight, } from 'lucide-react' import { snmpProfilesApi, @@ -29,6 +32,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { EmptyState } from '@/components/ui/empty-state' import { OIDTreeBrowser } from '@/components/settings/OIDTreeBrowser' import { ProfileTestPanel } from '@/components/settings/ProfileTestPanel' @@ -67,9 +71,9 @@ const CATEGORIES = [ ] 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: [] }, + fast: { interval_multiplier: 1, label: 'Fast (every poll)', scalars: [], tables: [] }, + standard: { interval_multiplier: 5, label: 'Standard (5x interval)', scalars: [], tables: [] }, + slow: { interval_multiplier: 30, label: 'Slow (30x interval)', scalars: [], tables: [] }, } function buildEmptyPollGroups(): Record { @@ -85,188 +89,98 @@ function buildEmptyPollGroups(): Record { function ProfileCard({ profile, onEdit, + onClone, onDelete, canModify, }: { profile: SNMPProfileResponse onEdit: (profile: SNMPProfileResponse) => void + onClone: (profile: SNMPProfileResponse) => void onDelete: (profileId: string) => void canModify: boolean }) { return ( -
+
{profile.name} {profile.is_system && ( - system + built-in )} + {profile.category && profile.category !== 'generic' && ( + {profile.category} + )}
{profile.description && ( -
+
{profile.description}
)}
-
- - {profile.device_count} device{profile.device_count !== 1 ? 's' : ''} - - {canModify && !profile.is_system && ( - <> - - - +
+ {profile.device_count > 0 && ( + + {profile.device_count} device{profile.device_count !== 1 ? 's' : ''} + + )} + {canModify && ( +
+ {profile.is_system ? ( + + ) : ( + <> + + + + )} +
)}
) } -// ─── Manual OID Add Row ───────────────────────────────────────────────────── +// ─── OID Table Row ────────────────────────────────────────────────────────── -function ManualOIDAddRow({ - onAdd, +function OIDRow({ + oid, + groupLabel, + onRemove, }: { - 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 + oid: PollGroupOID + groupLabel: string + onRemove: () => 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)} /> +
+ {oid.name} + {oid.oid} + {oid.type} + {groupLabel} +
) } @@ -294,12 +208,20 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps) const [pollGroups, setPollGroups] = useState>(buildEmptyPollGroups) const [activePollGroup, setActivePollGroup] = useState('standard') const [selectedOids, setSelectedOids] = useState>(new Set()) + const [advancedOpen, setAdvancedOpen] = useState(false) // ─── MIB state ───────────────────────────────────────────────────────── const [parsedNodes, setParsedNodes] = useState([]) const [parsedModuleName, setParsedModuleName] = useState(null) + // ─── Manual OID add state ────────────────────────────────────────────── + + const [manualOid, setManualOid] = useState('') + const [manualName, setManualName] = useState('') + const [manualType, setManualType] = useState('gauge32') + const [manualGroup, setManualGroup] = useState('standard') + // ─── Data query ──────────────────────────────────────────────────────── const { data: profiles, isLoading } = useQuery({ @@ -323,7 +245,7 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps) onError: (err: unknown) => { const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? - 'Failed to parse MIB file' + 'Failed to parse MIB file. Make sure dependencies are available.' toast.error(detail) }, }) @@ -375,6 +297,9 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps) setSelectedOids(new Set()) setParsedNodes([]) setParsedModuleName(null) + setAdvancedOpen(false) + setManualOid('') + setManualName('') } function openCreate() { @@ -382,15 +307,13 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps) setView('edit') } - function openEdit(profile: SNMPProfileResponse) { - setEditingProfileId(profile.id) - setName(profile.name) + function openClone(profile: SNMPProfileResponse) { + resetAndGoToList() + setName(`${profile.name} (custom)`) setDescription(profile.description ?? '') - setVendor('') - setCategory('generic') - setSysObjectId('') + setCategory(profile.category ?? 'generic') - // Parse existing profile_data poll groups + // Copy OIDs from the system profile const pd = profile.profile_data as { poll_groups?: Record } | null @@ -407,8 +330,41 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps) } } setPollGroups(groups) + const oids = new Set() + for (const g of Object.values(groups)) { + for (const s of g.scalars) oids.add(s.oid) + } + setSelectedOids(oids) + } - // Rebuild selected OIDs set from existing poll groups + setView('edit') + toast.info(`Cloned from "${profile.name}" — customize and save as your own`) + } + + function openEdit(profile: SNMPProfileResponse) { + setEditingProfileId(profile.id) + setName(profile.name) + setDescription(profile.description ?? '') + setVendor('') + setCategory(profile.category ?? 'generic') + setSysObjectId(profile.sys_object_id ?? '') + + 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) const oids = new Set() for (const g of Object.values(groups)) { for (const s of g.scalars) oids.add(s.oid) @@ -422,6 +378,7 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps) setParsedNodes([]) setParsedModuleName(null) setActivePollGroup('standard') + setAdvancedOpen(false) setView('edit') } @@ -436,7 +393,6 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps) if (file) { parseMibMutation.mutate(file) } - // Reset input so re-uploading the same file works if (fileInputRef.current) fileInputRef.current.value = '' } @@ -446,28 +402,20 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps) 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), - } + 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' }, - ], + scalars: [...pg[activePollGroup].scalars, { oid, name: node.name, type: node.type ?? 'string' }], }, })) } @@ -482,12 +430,8 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps) const removed = pg[groupKey].scalars[index] const updated = { ...pg, - [groupKey]: { - ...pg[groupKey], - scalars: pg[groupKey].scalars.filter((_, i) => i !== index), - }, + [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) @@ -499,15 +443,17 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps) }) } - function handleManualAdd(groupKey: PollGroupKey, oid: PollGroupOID) { + function handleManualAdd() { + if (!manualOid.trim() || !manualName.trim()) return + const oid: PollGroupOID = { oid: manualOid.trim(), name: manualName.trim(), type: manualType } setPollGroups((pg) => ({ ...pg, - [groupKey]: { - ...pg[groupKey], - scalars: [...pg[groupKey].scalars, oid], - }, + [manualGroup]: { ...pg[manualGroup], scalars: [...pg[manualGroup].scalars, oid] }, })) setSelectedOids((prev) => new Set(prev).add(oid.oid)) + setManualOid('') + setManualName('') + setManualType('gauge32') } function handleSave() { @@ -519,28 +465,13 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps) 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, - }, + 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, - } + 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 @@ -549,6 +480,15 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps) saveMutation.mutate(payload) } + // ─── Computed: all OIDs across groups for the flat table ─────────────── + + const allOids: { oid: PollGroupOID; groupKey: PollGroupKey; groupLabel: string; index: number }[] = [] + for (const key of ['fast', 'standard', 'slow'] as PollGroupKey[]) { + pollGroups[key].scalars.forEach((oid, index) => { + allOids.push({ oid, groupKey: key, groupLabel: pollGroups[key].label, index }) + }) + } + // ─── Loading state ──────────────────────────────────────────────────── if (isLoading) { @@ -556,7 +496,6 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps)
-
) } @@ -566,12 +505,11 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps) if (view === 'list') { return (
- {/* Header */}

SNMP Profiles

- Manage SNMP polling profiles for device monitoring + Control what gets collected from each type of SNMP device

{userCanWrite && ( @@ -581,19 +519,18 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps) )}
- {/* System profiles */} + {/* Built-in profiles */} {systemProfiles.length > 0 && (
-

System Profiles

-
+

+ Built-in Profiles +

+

+ Ready to use. Clone one to customize it for your specific hardware. +

+
{systemProfiles.map((p) => ( - + ))}
@@ -601,24 +538,21 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps) {/* Custom profiles */}
-

Custom Profiles

-
+

+ Custom Profiles +

+
{customProfiles.length === 0 ? ( - +
+ +

No custom profiles yet

+

+ Clone a built-in profile above, or create one from scratch +

+
) : ( customProfiles.map((p) => ( - + )) )}
@@ -632,169 +566,200 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps) 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 -

+

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

+
+ + +
+
- {parsedNodes.length > 0 && ( - + + + Basics + + OIDs{allOids.length > 0 && ` (${allOids.length})`} + + Advanced + {editingProfileId && Test} + + + {/* ── Basics Tab ─────────────────────────────────────────────── */} + +
+
+
+ + setName(e.target.value)} placeholder="e.g. Ubiquiti EdgeSwitch" className="mt-1" /> +
+
+ + +
+
+
+ + setDescription(e.target.value)} placeholder="What this profile monitors" className="mt-1" /> +
+
+
+ + {/* ── OIDs Tab ───────────────────────────────────────────────── */} + + {/* OID table */} +
+ {allOids.length === 0 ? ( +
+

No OIDs configured

+

Add OIDs manually below, or use the Advanced tab to upload a vendor MIB file

+
+ ) : ( +
+
+ Name + OID + Type + Interval + +
+
+ {allOids.map(({ oid, groupKey, groupLabel, index }) => ( + handleRemoveOid(groupKey, index)} /> + ))} +
+
+ )} +
+ + {/* Quick add */} +
+

Add OID

+
+
+ + setManualOid(e.target.value)} placeholder="1.3.6.1.2.1..." className="h-7 text-xs mt-0.5" /> +
+
+ + setManualName(e.target.value)} placeholder="metric_name" className="h-7 text-xs mt-0.5" onKeyDown={(e) => e.key === 'Enter' && handleManualAdd()} /> +
+
+ + +
+
+ + +
+ +
+
+
+ + {/* ── Advanced Tab ───────────────────────────────────────────── */} + + {/* Auto-detection fields */} +
+

Auto-Detection

+

+ If set, TOD will automatically assign this profile when a device's sysObjectID matches the prefix below. +

+
+
+ + setSysObjectId(e.target.value)} placeholder="1.3.6.1.4.1.41112" className="mt-1 font-mono text-xs" /> +
+
+ + setVendor(e.target.value)} placeholder="e.g. Ubiquiti" className="mt-1" /> +
+
+
+ + {/* MIB Upload */} +
+
+
+

MIB Browser

+

+ Upload a vendor MIB file to browse OIDs visually. Standard MIBs (IF-MIB, HOST-RESOURCES, etc.) are pre-loaded. +

+
+ {parsedModuleName && ( + + {parsedModuleName} + + )} +
+
+ + +
+ + {parsedNodes.length > 0 && ( +
+
+ + Selecting OIDs adds them to: + + +
+ +
+ )} +
+
+ + {/* ── Test Tab ───────────────────────────────────────────────── */} + {editingProfileId && ( + + + )} -
- - {/* 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 */} -
- - -
+
) }