refactor(ui): redesign SNMP profile editor for progressive disclosure
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) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,9 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
X,
|
X,
|
||||||
Network,
|
Network,
|
||||||
|
Copy,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
snmpProfilesApi,
|
snmpProfilesApi,
|
||||||
@@ -29,6 +32,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||||
import { EmptyState } from '@/components/ui/empty-state'
|
import { EmptyState } from '@/components/ui/empty-state'
|
||||||
import { OIDTreeBrowser } from '@/components/settings/OIDTreeBrowser'
|
import { OIDTreeBrowser } from '@/components/settings/OIDTreeBrowser'
|
||||||
import { ProfileTestPanel } from '@/components/settings/ProfileTestPanel'
|
import { ProfileTestPanel } from '@/components/settings/ProfileTestPanel'
|
||||||
@@ -67,9 +71,9 @@ const CATEGORIES = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
const DEFAULT_POLL_GROUPS: Record<PollGroupKey, PollGroup> = {
|
const DEFAULT_POLL_GROUPS: Record<PollGroupKey, PollGroup> = {
|
||||||
fast: { interval_multiplier: 1, label: 'Fast (60s)', scalars: [], tables: [] },
|
fast: { interval_multiplier: 1, label: 'Fast (every poll)', scalars: [], tables: [] },
|
||||||
standard: { interval_multiplier: 5, label: 'Standard (5m)', scalars: [], tables: [] },
|
standard: { interval_multiplier: 5, label: 'Standard (5x interval)', scalars: [], tables: [] },
|
||||||
slow: { interval_multiplier: 30, label: 'Slow (30m)', scalars: [], tables: [] },
|
slow: { interval_multiplier: 30, label: 'Slow (30x interval)', scalars: [], tables: [] },
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEmptyPollGroups(): Record<PollGroupKey, PollGroup> {
|
function buildEmptyPollGroups(): Record<PollGroupKey, PollGroup> {
|
||||||
@@ -85,189 +89,99 @@ function buildEmptyPollGroups(): Record<PollGroupKey, PollGroup> {
|
|||||||
function ProfileCard({
|
function ProfileCard({
|
||||||
profile,
|
profile,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onClone,
|
||||||
onDelete,
|
onDelete,
|
||||||
canModify,
|
canModify,
|
||||||
}: {
|
}: {
|
||||||
profile: SNMPProfileResponse
|
profile: SNMPProfileResponse
|
||||||
onEdit: (profile: SNMPProfileResponse) => void
|
onEdit: (profile: SNMPProfileResponse) => void
|
||||||
|
onClone: (profile: SNMPProfileResponse) => void
|
||||||
onDelete: (profileId: string) => void
|
onDelete: (profileId: string) => void
|
||||||
canModify: boolean
|
canModify: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between py-2 px-3 rounded-sm border border-border bg-panel">
|
<div className="flex items-center justify-between py-2.5 px-3 rounded-sm border border-border bg-panel group hover:border-border-hover transition-colors">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium text-text-primary">{profile.name}</span>
|
<span className="text-sm font-medium text-text-primary">{profile.name}</span>
|
||||||
{profile.is_system && (
|
{profile.is_system && (
|
||||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||||
system
|
built-in
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{profile.category && profile.category !== 'generic' && (
|
||||||
|
<span className="text-[10px] text-text-muted capitalize">{profile.category}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{profile.description && (
|
{profile.description && (
|
||||||
<div className="text-xs text-text-muted truncate max-w-[400px]">
|
<div className="text-xs text-text-muted truncate max-w-[450px] mt-0.5">
|
||||||
{profile.description}
|
{profile.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
<span className="text-xs text-text-muted">
|
{profile.device_count > 0 && (
|
||||||
|
<span className="text-[10px] text-text-muted tabular-nums">
|
||||||
{profile.device_count} device{profile.device_count !== 1 ? 's' : ''}
|
{profile.device_count} device{profile.device_count !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
{canModify && !profile.is_system && (
|
)}
|
||||||
<>
|
{canModify && (
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{profile.is_system ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="sm"
|
||||||
className="h-7 w-7"
|
className="h-6 text-[10px] gap-1 px-2"
|
||||||
onClick={() => onEdit(profile)}
|
onClick={() => onClone(profile)}
|
||||||
|
title="Clone this profile to customize it"
|
||||||
>
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Copy className="h-3 w-3" />
|
||||||
|
Clone
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
) : (
|
||||||
variant="ghost"
|
<>
|
||||||
size="icon"
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => onEdit(profile)}>
|
||||||
className="h-7 w-7 text-error"
|
<Pencil className="h-3 w-3" />
|
||||||
onClick={() => onDelete(profile.id)}
|
</Button>
|
||||||
>
|
<Button variant="ghost" size="icon" className="h-6 w-6 text-error" onClick={() => onDelete(profile.id)}>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 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 (
|
|
||||||
<div className="flex items-end gap-2 mt-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
<Label className="text-[10px] text-text-muted">OID</Label>
|
|
||||||
<Input
|
|
||||||
value={oid}
|
|
||||||
onChange={(e) => setOid(e.target.value)}
|
|
||||||
placeholder="1.3.6.1.2.1..."
|
|
||||||
className="h-7 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<Label className="text-[10px] text-text-muted">Name</Label>
|
|
||||||
<Input
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="metric_name"
|
|
||||||
className="h-7 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-28">
|
|
||||||
<Label className="text-[10px] text-text-muted">Type</Label>
|
|
||||||
<Select value={type} onValueChange={setType}>
|
|
||||||
<SelectTrigger className="h-7 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="gauge32">Gauge32</SelectItem>
|
|
||||||
<SelectItem value="counter32">Counter32</SelectItem>
|
|
||||||
<SelectItem value="counter64">Counter64</SelectItem>
|
|
||||||
<SelectItem value="integer">Integer</SelectItem>
|
|
||||||
<SelectItem value="string">String</SelectItem>
|
|
||||||
<SelectItem value="timeticks">TimeTicks</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={handleAdd}>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 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 (
|
|
||||||
<div
|
|
||||||
className={`rounded-sm border px-3 py-2 ${isActive ? 'border-accent bg-accent/5' : 'border-border bg-panel'}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-sm font-medium text-text-secondary hover:text-text-primary"
|
|
||||||
onClick={onSetActive}
|
|
||||||
>
|
|
||||||
{group.label}
|
|
||||||
{isActive && (
|
|
||||||
<span className="ml-2 text-[10px] text-accent font-normal">
|
|
||||||
(active -- tree selections go here)
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
|
||||||
<span className="text-[10px] text-text-muted">
|
|
||||||
{group.scalars.length} OID{group.scalars.length !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{group.scalars.length > 0 && (
|
</div>
|
||||||
<div className="space-y-0.5">
|
)
|
||||||
{group.scalars.map((s, i) => (
|
}
|
||||||
<div
|
|
||||||
key={`${s.oid}-${i}`}
|
// ─── OID Table Row ──────────────────────────────────────────────────────────
|
||||||
className="flex items-center justify-between gap-2 text-xs py-0.5"
|
|
||||||
>
|
function OIDRow({
|
||||||
<span className="font-mono text-text-primary truncate">{s.name}</span>
|
oid,
|
||||||
<span className="font-mono text-text-muted text-[10px] truncate flex-shrink-0">
|
groupLabel,
|
||||||
{s.oid}
|
onRemove,
|
||||||
</span>
|
}: {
|
||||||
<span className="text-[10px] bg-surface-raised px-1 py-0.5 rounded flex-shrink-0">
|
oid: PollGroupOID
|
||||||
{s.type}
|
groupLabel: string
|
||||||
</span>
|
onRemove: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 py-1 px-2 text-xs group/row hover:bg-elevated/30 rounded-sm">
|
||||||
|
<span className="font-mono text-text-primary w-40 truncate" title={oid.name}>{oid.name}</span>
|
||||||
|
<span className="font-mono text-text-muted text-[10px] flex-1 truncate" title={oid.oid}>{oid.oid}</span>
|
||||||
|
<span className="text-[10px] text-text-muted w-16 text-right">{oid.type}</span>
|
||||||
|
<span className="text-[10px] text-text-muted w-20 text-right">{groupLabel}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-text-muted hover:text-error flex-shrink-0"
|
className="text-text-muted hover:text-error opacity-0 group-hover/row:opacity-100 transition-opacity flex-shrink-0"
|
||||||
onClick={() => onRemoveOid(groupKey, i)}
|
onClick={onRemove}
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ManualOIDAddRow onAdd={(oid) => onAddOid(groupKey, oid)} />
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,12 +208,20 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps)
|
|||||||
const [pollGroups, setPollGroups] = useState<Record<PollGroupKey, PollGroup>>(buildEmptyPollGroups)
|
const [pollGroups, setPollGroups] = useState<Record<PollGroupKey, PollGroup>>(buildEmptyPollGroups)
|
||||||
const [activePollGroup, setActivePollGroup] = useState<PollGroupKey>('standard')
|
const [activePollGroup, setActivePollGroup] = useState<PollGroupKey>('standard')
|
||||||
const [selectedOids, setSelectedOids] = useState<Set<string>>(new Set())
|
const [selectedOids, setSelectedOids] = useState<Set<string>>(new Set())
|
||||||
|
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||||
|
|
||||||
// ─── MIB state ─────────────────────────────────────────────────────────
|
// ─── MIB state ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const [parsedNodes, setParsedNodes] = useState<OIDNode[]>([])
|
const [parsedNodes, setParsedNodes] = useState<OIDNode[]>([])
|
||||||
const [parsedModuleName, setParsedModuleName] = useState<string | null>(null)
|
const [parsedModuleName, setParsedModuleName] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// ─── Manual OID add state ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const [manualOid, setManualOid] = useState('')
|
||||||
|
const [manualName, setManualName] = useState('')
|
||||||
|
const [manualType, setManualType] = useState('gauge32')
|
||||||
|
const [manualGroup, setManualGroup] = useState<PollGroupKey>('standard')
|
||||||
|
|
||||||
// ─── Data query ────────────────────────────────────────────────────────
|
// ─── Data query ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const { data: profiles, isLoading } = useQuery({
|
const { data: profiles, isLoading } = useQuery({
|
||||||
@@ -323,7 +245,7 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps)
|
|||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const detail =
|
const detail =
|
||||||
(err as { response?: { data?: { detail?: string } } })?.response?.data?.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)
|
toast.error(detail)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -375,6 +297,9 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps)
|
|||||||
setSelectedOids(new Set())
|
setSelectedOids(new Set())
|
||||||
setParsedNodes([])
|
setParsedNodes([])
|
||||||
setParsedModuleName(null)
|
setParsedModuleName(null)
|
||||||
|
setAdvancedOpen(false)
|
||||||
|
setManualOid('')
|
||||||
|
setManualName('')
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
@@ -382,15 +307,13 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps)
|
|||||||
setView('edit')
|
setView('edit')
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(profile: SNMPProfileResponse) {
|
function openClone(profile: SNMPProfileResponse) {
|
||||||
setEditingProfileId(profile.id)
|
resetAndGoToList()
|
||||||
setName(profile.name)
|
setName(`${profile.name} (custom)`)
|
||||||
setDescription(profile.description ?? '')
|
setDescription(profile.description ?? '')
|
||||||
setVendor('')
|
setCategory(profile.category ?? 'generic')
|
||||||
setCategory('generic')
|
|
||||||
setSysObjectId('')
|
|
||||||
|
|
||||||
// Parse existing profile_data poll groups
|
// Copy OIDs from the system profile
|
||||||
const pd = profile.profile_data as {
|
const pd = profile.profile_data as {
|
||||||
poll_groups?: Record<string, { interval_multiplier: number; scalars: PollGroupOID[]; tables: unknown[] }>
|
poll_groups?: Record<string, { interval_multiplier: number; scalars: PollGroupOID[]; tables: unknown[] }>
|
||||||
} | null
|
} | null
|
||||||
@@ -407,8 +330,41 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setPollGroups(groups)
|
setPollGroups(groups)
|
||||||
|
const oids = new Set<string>()
|
||||||
|
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<string, { interval_multiplier: number; scalars: PollGroupOID[]; tables: unknown[] }>
|
||||||
|
} | 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<string>()
|
const oids = new Set<string>()
|
||||||
for (const g of Object.values(groups)) {
|
for (const g of Object.values(groups)) {
|
||||||
for (const s of g.scalars) oids.add(s.oid)
|
for (const s of g.scalars) oids.add(s.oid)
|
||||||
@@ -422,6 +378,7 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps)
|
|||||||
setParsedNodes([])
|
setParsedNodes([])
|
||||||
setParsedModuleName(null)
|
setParsedModuleName(null)
|
||||||
setActivePollGroup('standard')
|
setActivePollGroup('standard')
|
||||||
|
setAdvancedOpen(false)
|
||||||
setView('edit')
|
setView('edit')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,7 +393,6 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps)
|
|||||||
if (file) {
|
if (file) {
|
||||||
parseMibMutation.mutate(file)
|
parseMibMutation.mutate(file)
|
||||||
}
|
}
|
||||||
// Reset input so re-uploading the same file works
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,28 +402,20 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps)
|
|||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (next.has(oid)) {
|
if (next.has(oid)) {
|
||||||
next.delete(oid)
|
next.delete(oid)
|
||||||
// Remove from whichever poll group contains it
|
|
||||||
setPollGroups((pg) => {
|
setPollGroups((pg) => {
|
||||||
const updated = { ...pg }
|
const updated = { ...pg }
|
||||||
for (const key of ['fast', 'standard', 'slow'] as PollGroupKey[]) {
|
for (const key of ['fast', 'standard', 'slow'] as PollGroupKey[]) {
|
||||||
updated[key] = {
|
updated[key] = { ...updated[key], scalars: updated[key].scalars.filter((s) => s.oid !== oid) }
|
||||||
...updated[key],
|
|
||||||
scalars: updated[key].scalars.filter((s) => s.oid !== oid),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return updated
|
return updated
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
next.add(oid)
|
next.add(oid)
|
||||||
// Add to the active poll group
|
|
||||||
setPollGroups((pg) => ({
|
setPollGroups((pg) => ({
|
||||||
...pg,
|
...pg,
|
||||||
[activePollGroup]: {
|
[activePollGroup]: {
|
||||||
...pg[activePollGroup],
|
...pg[activePollGroup],
|
||||||
scalars: [
|
scalars: [...pg[activePollGroup].scalars, { oid, name: node.name, type: node.type ?? 'string' }],
|
||||||
...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 removed = pg[groupKey].scalars[index]
|
||||||
const updated = {
|
const updated = {
|
||||||
...pg,
|
...pg,
|
||||||
[groupKey]: {
|
[groupKey]: { ...pg[groupKey], scalars: pg[groupKey].scalars.filter((_, i) => i !== index) },
|
||||||
...pg[groupKey],
|
|
||||||
scalars: pg[groupKey].scalars.filter((_, i) => i !== index),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
// Also remove from selected set
|
|
||||||
if (removed) {
|
if (removed) {
|
||||||
setSelectedOids((prev) => {
|
setSelectedOids((prev) => {
|
||||||
const next = new Set(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) => ({
|
setPollGroups((pg) => ({
|
||||||
...pg,
|
...pg,
|
||||||
[groupKey]: {
|
[manualGroup]: { ...pg[manualGroup], scalars: [...pg[manualGroup].scalars, oid] },
|
||||||
...pg[groupKey],
|
|
||||||
scalars: [...pg[groupKey].scalars, oid],
|
|
||||||
},
|
|
||||||
}))
|
}))
|
||||||
setSelectedOids((prev) => new Set(prev).add(oid.oid))
|
setSelectedOids((prev) => new Set(prev).add(oid.oid))
|
||||||
|
setManualOid('')
|
||||||
|
setManualName('')
|
||||||
|
setManualType('gauge32')
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
@@ -519,28 +465,13 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps)
|
|||||||
const profileData: Record<string, unknown> = {
|
const profileData: Record<string, unknown> = {
|
||||||
version: 1,
|
version: 1,
|
||||||
poll_groups: {
|
poll_groups: {
|
||||||
fast: {
|
fast: { interval_multiplier: pollGroups.fast.interval_multiplier, scalars: pollGroups.fast.scalars, tables: pollGroups.fast.tables },
|
||||||
interval_multiplier: pollGroups.fast.interval_multiplier,
|
standard: { interval_multiplier: pollGroups.standard.interval_multiplier, scalars: pollGroups.standard.scalars, tables: pollGroups.standard.tables },
|
||||||
scalars: pollGroups.fast.scalars,
|
slow: { interval_multiplier: pollGroups.slow.interval_multiplier, scalars: pollGroups.slow.scalars, tables: pollGroups.slow.tables },
|
||||||
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 = {
|
const payload: SNMPProfileCreate = { name: name.trim(), profile_data: profileData }
|
||||||
name: name.trim(),
|
|
||||||
profile_data: profileData,
|
|
||||||
}
|
|
||||||
if (description.trim()) payload.description = description.trim()
|
if (description.trim()) payload.description = description.trim()
|
||||||
if (vendor.trim()) payload.vendor = vendor.trim()
|
if (vendor.trim()) payload.vendor = vendor.trim()
|
||||||
if (category !== 'generic') payload.category = category
|
if (category !== 'generic') payload.category = category
|
||||||
@@ -549,6 +480,15 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps)
|
|||||||
saveMutation.mutate(payload)
|
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 ────────────────────────────────────────────────────
|
// ─── Loading state ────────────────────────────────────────────────────
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -556,7 +496,6 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps)
|
|||||||
<div className="space-y-6 max-w-4xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
<div className="h-8 w-48 bg-elevated/50 rounded animate-pulse" />
|
<div className="h-8 w-48 bg-elevated/50 rounded animate-pulse" />
|
||||||
<div className="h-24 bg-elevated/50 rounded animate-pulse" />
|
<div className="h-24 bg-elevated/50 rounded animate-pulse" />
|
||||||
<div className="h-24 bg-elevated/50 rounded animate-pulse" />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -566,12 +505,11 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps)
|
|||||||
if (view === 'list') {
|
if (view === 'list') {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-4xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold">SNMP Profiles</h1>
|
<h1 className="text-lg font-semibold">SNMP Profiles</h1>
|
||||||
<p className="text-sm text-text-muted mt-0.5">
|
<p className="text-sm text-text-muted mt-0.5">
|
||||||
Manage SNMP polling profiles for device monitoring
|
Control what gets collected from each type of SNMP device
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{userCanWrite && (
|
{userCanWrite && (
|
||||||
@@ -581,19 +519,18 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* System profiles */}
|
{/* Built-in profiles */}
|
||||||
{systemProfiles.length > 0 && (
|
{systemProfiles.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-medium text-text-secondary mb-2">System Profiles</h2>
|
<h2 className="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
|
||||||
<div className="space-y-1.5">
|
Built-in Profiles
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-text-muted mb-3">
|
||||||
|
Ready to use. Clone one to customize it for your specific hardware.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
{systemProfiles.map((p) => (
|
{systemProfiles.map((p) => (
|
||||||
<ProfileCard
|
<ProfileCard key={p.id} profile={p} onEdit={openEdit} onClone={openClone} onDelete={handleDelete} canModify={userCanWrite} />
|
||||||
key={p.id}
|
|
||||||
profile={p}
|
|
||||||
onEdit={openEdit}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
canModify={false}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -601,24 +538,21 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps)
|
|||||||
|
|
||||||
{/* Custom profiles */}
|
{/* Custom profiles */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-medium text-text-secondary mb-2">Custom Profiles</h2>
|
<h2 className="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
|
||||||
<div className="space-y-1.5">
|
Custom Profiles
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-1">
|
||||||
{customProfiles.length === 0 ? (
|
{customProfiles.length === 0 ? (
|
||||||
<EmptyState
|
<div className="rounded-sm border border-border border-dashed bg-panel/50 px-4 py-6 text-center">
|
||||||
icon={Network}
|
<Network className="h-5 w-5 text-text-muted mx-auto mb-2" />
|
||||||
title="No custom profiles"
|
<p className="text-sm text-text-muted">No custom profiles yet</p>
|
||||||
description="Create a custom SNMP profile to monitor vendor-specific OIDs"
|
<p className="text-xs text-text-muted mt-1">
|
||||||
action={userCanWrite ? { label: 'New Profile', onClick: openCreate } : undefined}
|
Clone a built-in profile above, or create one from scratch
|
||||||
/>
|
</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
customProfiles.map((p) => (
|
customProfiles.map((p) => (
|
||||||
<ProfileCard
|
<ProfileCard key={p.id} profile={p} onEdit={openEdit} onClone={openClone} onDelete={handleDelete} canModify={userCanWrite} />
|
||||||
key={p.id}
|
|
||||||
profile={p}
|
|
||||||
onEdit={openEdit}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
canModify={userCanWrite}
|
|
||||||
/>
|
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -632,169 +566,200 @@ export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps)
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4 max-w-4xl">
|
<div className="space-y-4 max-w-4xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={resetAndGoToList}>
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={resetAndGoToList}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-lg font-semibold">
|
<h1 className="text-lg font-semibold">
|
||||||
{editingProfileId ? 'Edit SNMP Profile' : 'New SNMP Profile'}
|
{editingProfileId ? 'Edit Profile' : 'New SNMP Profile'}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={resetAndGoToList}>Cancel</Button>
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={!name.trim() || saveMutation.isPending}>
|
||||||
|
{saveMutation.isPending ? 'Saving...' : editingProfileId ? 'Save Changes' : 'Create Profile'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Profile Fields */}
|
<Tabs defaultValue="basics" className="w-full">
|
||||||
<div className="rounded-sm border border-border bg-panel px-3 py-3 space-y-3">
|
<TabsList>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<TabsTrigger value="basics">Basics</TabsTrigger>
|
||||||
|
<TabsTrigger value="oids">
|
||||||
|
OIDs{allOids.length > 0 && ` (${allOids.length})`}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
||||||
|
{editingProfileId && <TabsTrigger value="test">Test</TabsTrigger>}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* ── Basics Tab ─────────────────────────────────────────────── */}
|
||||||
|
<TabsContent value="basics" className="mt-4 space-y-4">
|
||||||
|
<div className="rounded-sm border border-border bg-panel px-4 py-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">
|
<Label className="text-xs">Profile Name <span className="text-error">*</span></Label>
|
||||||
Profile Name <span className="text-error">*</span>
|
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Ubiquiti EdgeSwitch" className="mt-1" />
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="e.g. Ubiquiti EdgeSwitch"
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">
|
<Label className="text-xs">Category</Label>
|
||||||
Category
|
|
||||||
</Label>
|
|
||||||
<Select value={category} onValueChange={setCategory}>
|
<Select value={category} onValueChange={setCategory}>
|
||||||
<SelectTrigger className="mt-1">
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{CATEGORIES.map((c) => (
|
{CATEGORIES.map((c) => <SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>)}
|
||||||
<SelectItem key={c.value} value={c.value}>
|
|
||||||
{c.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">
|
<Label className="text-xs">Description</Label>
|
||||||
Description <span className="text-text-muted font-normal">(optional)</span>
|
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="What this profile monitors" className="mt-1" />
|
||||||
</Label>
|
</div>
|
||||||
<Input
|
</div>
|
||||||
value={description}
|
</TabsContent>
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="Brief description of this profile"
|
{/* ── OIDs Tab ───────────────────────────────────────────────── */}
|
||||||
className="mt-1"
|
<TabsContent value="oids" className="mt-4 space-y-4">
|
||||||
/>
|
{/* OID table */}
|
||||||
|
<div className="rounded-sm border border-border bg-panel">
|
||||||
|
{allOids.length === 0 ? (
|
||||||
|
<div className="px-4 py-8 text-center">
|
||||||
|
<p className="text-sm text-text-muted">No OIDs configured</p>
|
||||||
|
<p className="text-xs text-text-muted mt-1">Add OIDs manually below, or use the Advanced tab to upload a vendor MIB file</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 px-3 py-1.5 text-[10px] uppercase tracking-wider text-text-muted border-b border-border">
|
||||||
|
<span className="w-40">Name</span>
|
||||||
|
<span className="flex-1">OID</span>
|
||||||
|
<span className="w-16 text-right">Type</span>
|
||||||
|
<span className="w-20 text-right">Interval</span>
|
||||||
|
<span className="w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[400px] overflow-y-auto">
|
||||||
|
{allOids.map(({ oid, groupKey, groupLabel, index }) => (
|
||||||
|
<OIDRow key={`${groupKey}-${oid.oid}-${index}`} oid={oid} groupLabel={groupLabel} onRemove={() => handleRemoveOid(groupKey, index)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
{/* Quick add */}
|
||||||
|
<div className="rounded-sm border border-border bg-panel px-3 py-3">
|
||||||
|
<h3 className="text-xs font-medium text-text-secondary mb-2">Add OID</h3>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-[10px] text-text-muted">OID</Label>
|
||||||
|
<Input value={manualOid} onChange={(e) => setManualOid(e.target.value)} placeholder="1.3.6.1.2.1..." className="h-7 text-xs mt-0.5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-[10px] text-text-muted">Name</Label>
|
||||||
|
<Input value={manualName} onChange={(e) => setManualName(e.target.value)} placeholder="metric_name" className="h-7 text-xs mt-0.5" onKeyDown={(e) => e.key === 'Enter' && handleManualAdd()} />
|
||||||
|
</div>
|
||||||
|
<div className="w-24">
|
||||||
|
<Label className="text-[10px] text-text-muted">Type</Label>
|
||||||
|
<Select value={manualType} onValueChange={setManualType}>
|
||||||
|
<SelectTrigger className="h-7 text-xs mt-0.5"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="gauge32">Gauge</SelectItem>
|
||||||
|
<SelectItem value="counter32">Counter32</SelectItem>
|
||||||
|
<SelectItem value="counter64">Counter64</SelectItem>
|
||||||
|
<SelectItem value="integer">Integer</SelectItem>
|
||||||
|
<SelectItem value="string">String</SelectItem>
|
||||||
|
<SelectItem value="timeticks">TimeTicks</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="w-24">
|
||||||
|
<Label className="text-[10px] text-text-muted">Interval</Label>
|
||||||
|
<Select value={manualGroup} onValueChange={(v) => setManualGroup(v as PollGroupKey)}>
|
||||||
|
<SelectTrigger className="h-7 text-xs mt-0.5"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="fast">Fast</SelectItem>
|
||||||
|
<SelectItem value="standard">Standard</SelectItem>
|
||||||
|
<SelectItem value="slow">Slow</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={handleManualAdd} disabled={!manualOid.trim() || !manualName.trim()}>
|
||||||
|
<Plus className="h-3 w-3" /> Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── Advanced Tab ───────────────────────────────────────────── */}
|
||||||
|
<TabsContent value="advanced" className="mt-4 space-y-4">
|
||||||
|
{/* Auto-detection fields */}
|
||||||
|
<div className="rounded-sm border border-border bg-panel px-4 py-4 space-y-4">
|
||||||
|
<h3 className="text-xs font-medium text-text-secondary">Auto-Detection</h3>
|
||||||
|
<p className="text-xs text-text-muted -mt-2">
|
||||||
|
If set, TOD will automatically assign this profile when a device's sysObjectID matches the prefix below.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">
|
<Label className="text-xs">sysObjectID Prefix</Label>
|
||||||
Vendor <span className="text-text-muted font-normal">(optional)</span>
|
<Input value={sysObjectId} onChange={(e) => setSysObjectId(e.target.value)} placeholder="1.3.6.1.4.1.41112" className="mt-1 font-mono text-xs" />
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={vendor}
|
|
||||||
onChange={(e) => setVendor(e.target.value)}
|
|
||||||
placeholder="e.g. Ubiquiti"
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">
|
<Label className="text-xs">Vendor</Label>
|
||||||
sysObjectID <span className="text-text-muted font-normal">(optional)</span>
|
<Input value={vendor} onChange={(e) => setVendor(e.target.value)} placeholder="e.g. Ubiquiti" className="mt-1" />
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={sysObjectId}
|
|
||||||
onChange={(e) => setSysObjectId(e.target.value)}
|
|
||||||
placeholder="1.3.6.1.4.1...."
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* MIB Upload */}
|
{/* MIB Upload */}
|
||||||
<div className="rounded-sm border border-border bg-panel px-3 py-3 space-y-3">
|
<div className="rounded-sm border border-border bg-panel px-4 py-4 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-sm font-medium text-text-secondary">MIB Upload</h2>
|
<div>
|
||||||
|
<h3 className="text-xs font-medium text-text-secondary">MIB Browser</h3>
|
||||||
|
<p className="text-xs text-text-muted mt-0.5">
|
||||||
|
Upload a vendor MIB file to browse OIDs visually. Standard MIBs (IF-MIB, HOST-RESOURCES, etc.) are pre-loaded.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{parsedModuleName && (
|
{parsedModuleName && (
|
||||||
<span className="text-xs text-text-muted">
|
<Badge variant="outline" className="text-[10px]">
|
||||||
Module: {parsedModuleName}
|
{parsedModuleName}
|
||||||
</span>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<input ref={fileInputRef} type="file" accept=".mib,.txt,.my" className="hidden" onChange={handleFileUpload} />
|
||||||
ref={fileInputRef}
|
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()} disabled={parseMibMutation.isPending}>
|
||||||
type="file"
|
{parseMibMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
|
||||||
accept=".mib,.txt,.my"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleFileUpload}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={parseMibMutation.isPending}
|
|
||||||
>
|
|
||||||
{parseMibMutation.isPending ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Upload className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
{parseMibMutation.isPending ? 'Parsing...' : 'Upload MIB'}
|
{parseMibMutation.isPending ? 'Parsing...' : 'Upload MIB'}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-text-muted">
|
|
||||||
Upload a vendor MIB file (.mib, .txt, .my) to browse and select OIDs
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{parsedNodes.length > 0 && (
|
{parsedNodes.length > 0 && (
|
||||||
<OIDTreeBrowser
|
<div className="mt-2">
|
||||||
nodes={parsedNodes}
|
<div className="flex items-center gap-3 mb-2">
|
||||||
selectedOids={selectedOids}
|
<span className="text-xs text-text-muted">
|
||||||
onToggleOid={handleToggleOid}
|
Selecting OIDs adds them to:
|
||||||
/>
|
</span>
|
||||||
|
<Select value={activePollGroup} onValueChange={(v) => setActivePollGroup(v as PollGroupKey)}>
|
||||||
|
<SelectTrigger className="h-6 w-32 text-xs"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="fast">Fast interval</SelectItem>
|
||||||
|
<SelectItem value="standard">Standard interval</SelectItem>
|
||||||
|
<SelectItem value="slow">Slow interval</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<OIDTreeBrowser nodes={parsedNodes} selectedOids={selectedOids} onToggleOid={handleToggleOid} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{/* Poll Groups */}
|
{/* ── Test Tab ───────────────────────────────────────────────── */}
|
||||||
<div className="rounded-sm border border-border bg-panel px-3 py-3 space-y-3">
|
{editingProfileId && (
|
||||||
<h2 className="text-sm font-medium text-text-secondary">Poll Groups</h2>
|
<TabsContent value="test" className="mt-4">
|
||||||
<p className="text-xs text-text-muted">
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{(['fast', 'standard', 'slow'] as PollGroupKey[]).map((key) => (
|
|
||||||
<PollGroupSection
|
|
||||||
key={key}
|
|
||||||
groupKey={key}
|
|
||||||
group={pollGroups[key]}
|
|
||||||
isActive={activePollGroup === key}
|
|
||||||
onSetActive={() => setActivePollGroup(key)}
|
|
||||||
onRemoveOid={handleRemoveOid}
|
|
||||||
onAddOid={handleManualAdd}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Test Panel */}
|
|
||||||
<ProfileTestPanel tenantId={tenantId} profileId={editingProfileId} />
|
<ProfileTestPanel tenantId={tenantId} profileId={editingProfileId} />
|
||||||
|
</TabsContent>
|
||||||
{/* Actions */}
|
)}
|
||||||
<div className="flex items-center gap-3 pt-2 pb-4">
|
</Tabs>
|
||||||
<Button onClick={handleSave} disabled={!name.trim() || saveMutation.isPending}>
|
|
||||||
{saveMutation.isPending
|
|
||||||
? 'Saving...'
|
|
||||||
: editingProfileId
|
|
||||||
? 'Update Profile'
|
|
||||||
: 'Create Profile'}
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={resetAndGoToList}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user