feat(20-03): add SNMP profile editor page, route, and API client extensions
- Extend snmpProfilesApi with create, update, delete, parseMib, testProfile - Add OIDNode, MIBParseResponse, ProfileTestRequest/Response, SNMPProfileCreate types - Create settings.snmp-profiles route with RBAC and tenant resolution - Build SNMPProfileEditorPage with list/edit views, MIB upload, poll groups - Remove pre-existing duplicate credentialProfilesApi declaration Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
800
frontend/src/components/settings/SNMPProfileEditorPage.tsx
Normal file
800
frontend/src/components/settings/SNMPProfileEditorPage.tsx
Normal file
@@ -0,0 +1,800 @@
|
|||||||
|
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<PollGroupKey, PollGroup> = {
|
||||||
|
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<PollGroupKey, PollGroup> {
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-between py-2 px-3 rounded-sm border border-border bg-panel">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-text-primary">{profile.name}</span>
|
||||||
|
{profile.is_system && (
|
||||||
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||||
|
system
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{profile.description && (
|
||||||
|
<div className="text-xs text-text-muted truncate max-w-[400px]">
|
||||||
|
{profile.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
|
<span className="text-xs text-text-muted">
|
||||||
|
{profile.device_count} device{profile.device_count !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
{canModify && !profile.is_system && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => onEdit(profile)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-error"
|
||||||
|
onClick={() => onDelete(profile.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
{group.scalars.length > 0 && (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{group.scalars.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={`${s.oid}-${i}`}
|
||||||
|
className="flex items-center justify-between gap-2 text-xs py-0.5"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-text-primary truncate">{s.name}</span>
|
||||||
|
<span className="font-mono text-text-muted text-[10px] truncate flex-shrink-0">
|
||||||
|
{s.oid}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] bg-surface-raised px-1 py-0.5 rounded flex-shrink-0">
|
||||||
|
{s.type}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-text-muted hover:text-error flex-shrink-0"
|
||||||
|
onClick={() => onRemoveOid(groupKey, i)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ManualOIDAddRow onAdd={(oid) => onAddOid(groupKey, oid)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Component ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function SNMPProfileEditorPage({ tenantId }: SNMPProfileEditorPageProps) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const userCanWrite = canWrite(user)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// ─── View state ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const [view, setView] = useState<ViewMode>('list')
|
||||||
|
const [editingProfileId, setEditingProfileId] = useState<string | null>(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<Record<PollGroupKey, PollGroup>>(buildEmptyPollGroups)
|
||||||
|
const [activePollGroup, setActivePollGroup] = useState<PollGroupKey>('standard')
|
||||||
|
const [selectedOids, setSelectedOids] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// ─── MIB state ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const [parsedNodes, setParsedNodes] = useState<OIDNode[]>([])
|
||||||
|
const [parsedModuleName, setParsedModuleName] = useState<string | null>(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<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)
|
||||||
|
|
||||||
|
// Rebuild selected OIDs set from existing poll 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)
|
||||||
|
} 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<HTMLInputElement>) {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6 max-w-4xl">
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── List View ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (view === 'list') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-4xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold">SNMP Profiles</h1>
|
||||||
|
<p className="text-sm text-text-muted mt-0.5">
|
||||||
|
Manage SNMP polling profiles for device monitoring
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{userCanWrite && (
|
||||||
|
<Button size="sm" onClick={openCreate}>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> New Profile
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System profiles */}
|
||||||
|
{systemProfiles.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium text-text-secondary mb-2">System Profiles</h2>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{systemProfiles.map((p) => (
|
||||||
|
<ProfileCard
|
||||||
|
key={p.id}
|
||||||
|
profile={p}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
canModify={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom profiles */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium text-text-secondary mb-2">Custom Profiles</h2>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{customProfiles.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Network}
|
||||||
|
title="No custom profiles"
|
||||||
|
description="Create a custom SNMP profile to monitor vendor-specific OIDs"
|
||||||
|
action={userCanWrite ? { label: 'New Profile', onClick: openCreate } : undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
customProfiles.map((p) => (
|
||||||
|
<ProfileCard
|
||||||
|
key={p.id}
|
||||||
|
profile={p}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
canModify={userCanWrite}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Edit View ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 max-w-4xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={resetAndGoToList}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-lg font-semibold">
|
||||||
|
{editingProfileId ? 'Edit SNMP Profile' : 'New SNMP Profile'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile Fields */}
|
||||||
|
<div className="rounded-sm border border-border bg-panel px-3 py-3 space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">
|
||||||
|
Profile Name <span className="text-error">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g. Ubiquiti EdgeSwitch"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">
|
||||||
|
Category
|
||||||
|
</Label>
|
||||||
|
<Select value={category} onValueChange={setCategory}>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<SelectItem key={c.value} value={c.value}>
|
||||||
|
{c.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">
|
||||||
|
Description <span className="text-text-muted font-normal">(optional)</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Brief description of this profile"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">
|
||||||
|
Vendor <span className="text-text-muted font-normal">(optional)</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={vendor}
|
||||||
|
onChange={(e) => setVendor(e.target.value)}
|
||||||
|
placeholder="e.g. Ubiquiti"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">
|
||||||
|
sysObjectID <span className="text-text-muted font-normal">(optional)</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={sysObjectId}
|
||||||
|
onChange={(e) => setSysObjectId(e.target.value)}
|
||||||
|
placeholder="1.3.6.1.4.1...."
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MIB Upload */}
|
||||||
|
<div className="rounded-sm border border-border bg-panel px-3 py-3 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-medium text-text-secondary">MIB Upload</h2>
|
||||||
|
{parsedModuleName && (
|
||||||
|
<span className="text-xs text-text-muted">
|
||||||
|
Module: {parsedModuleName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
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'}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
Upload a vendor MIB file (.mib, .txt, .my) to browse and select OIDs
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{parsedNodes.length > 0 && (
|
||||||
|
<OIDTreeBrowser
|
||||||
|
nodes={parsedNodes}
|
||||||
|
selectedOids={selectedOids}
|
||||||
|
onToggleOid={handleToggleOid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Poll Groups */}
|
||||||
|
<div className="rounded-sm border border-border bg-panel px-3 py-3 space-y-3">
|
||||||
|
<h2 className="text-sm font-medium text-text-secondary">Poll Groups</h2>
|
||||||
|
<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} />
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-3 pt-2 pb-4">
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -556,6 +556,50 @@ export interface SNMPProfileResponse {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OIDNode {
|
||||||
|
oid: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
type?: string
|
||||||
|
access?: string
|
||||||
|
status?: string
|
||||||
|
children?: OIDNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MIBParseResponse {
|
||||||
|
module_name: string
|
||||||
|
nodes: OIDNode[]
|
||||||
|
node_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileTestRequest {
|
||||||
|
ip_address: string
|
||||||
|
snmp_port?: number
|
||||||
|
snmp_version: string
|
||||||
|
community?: string
|
||||||
|
security_level?: string
|
||||||
|
username?: string
|
||||||
|
auth_protocol?: string
|
||||||
|
auth_passphrase?: string
|
||||||
|
priv_protocol?: string
|
||||||
|
priv_passphrase?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileTestResponse {
|
||||||
|
success: boolean
|
||||||
|
device_info?: { sys_object_id?: string; sys_descr?: string; sys_name?: string }
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SNMPProfileCreate {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
sys_object_id?: string
|
||||||
|
vendor?: string
|
||||||
|
category?: string
|
||||||
|
profile_data: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
export const snmpProfilesApi = {
|
export const snmpProfilesApi = {
|
||||||
list: (tenantId: string) =>
|
list: (tenantId: string) =>
|
||||||
api
|
api
|
||||||
@@ -568,6 +612,46 @@ export const snmpProfilesApi = {
|
|||||||
`/api/tenants/${tenantId}/snmp-profiles/${profileId}`,
|
`/api/tenants/${tenantId}/snmp-profiles/${profileId}`,
|
||||||
)
|
)
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
create: (tenantId: string, data: SNMPProfileCreate) =>
|
||||||
|
api
|
||||||
|
.post<SNMPProfileResponse>(
|
||||||
|
`/api/tenants/${tenantId}/snmp-profiles`,
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
update: (tenantId: string, profileId: string, data: SNMPProfileCreate) =>
|
||||||
|
api
|
||||||
|
.put<SNMPProfileResponse>(
|
||||||
|
`/api/tenants/${tenantId}/snmp-profiles/${profileId}`,
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
delete: (tenantId: string, profileId: string) =>
|
||||||
|
api
|
||||||
|
.delete(`/api/tenants/${tenantId}/snmp-profiles/${profileId}`)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
parseMib: (tenantId: string, file: File) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
return api
|
||||||
|
.post<MIBParseResponse>(
|
||||||
|
`/api/tenants/${tenantId}/snmp-profiles/parse-mib`,
|
||||||
|
formData,
|
||||||
|
)
|
||||||
|
.then((r) => r.data)
|
||||||
|
},
|
||||||
|
|
||||||
|
testProfile: (tenantId: string, profileId: string, data: ProfileTestRequest) =>
|
||||||
|
api
|
||||||
|
.post<ProfileTestResponse>(
|
||||||
|
`/api/tenants/${tenantId}/snmp-profiles/${profileId}/test`,
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Bulk Add (credential profile) ──────────────────────────────────────────
|
// ─── Bulk Add (credential profile) ──────────────────────────────────────────
|
||||||
@@ -1789,72 +1873,3 @@ export const alertEventsApi = {
|
|||||||
return data.count
|
return data.count
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Credential Profiles ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface CredentialProfileResponse {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
description: string | null
|
|
||||||
credential_type: string // "routeros" | "snmp_v2c" | "snmp_v3"
|
|
||||||
device_count: number
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CredentialProfileListResponse {
|
|
||||||
profiles: CredentialProfileResponse[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CredentialProfileCreate {
|
|
||||||
name: string
|
|
||||||
description?: string
|
|
||||||
credential_type: string
|
|
||||||
username?: string
|
|
||||||
password?: string
|
|
||||||
community?: string
|
|
||||||
security_level?: string
|
|
||||||
auth_protocol?: string
|
|
||||||
auth_passphrase?: string
|
|
||||||
privacy_protocol?: string
|
|
||||||
privacy_passphrase?: string
|
|
||||||
security_name?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const credentialProfilesApi = {
|
|
||||||
list: (tenantId: string, credentialType?: string) =>
|
|
||||||
api
|
|
||||||
.get<CredentialProfileListResponse>(
|
|
||||||
`/api/tenants/${tenantId}/credential-profiles`,
|
|
||||||
{ params: credentialType ? { credential_type: credentialType } : undefined },
|
|
||||||
)
|
|
||||||
.then((r) => r.data),
|
|
||||||
|
|
||||||
get: (tenantId: string, profileId: string) =>
|
|
||||||
api
|
|
||||||
.get<CredentialProfileResponse>(
|
|
||||||
`/api/tenants/${tenantId}/credential-profiles/${profileId}`,
|
|
||||||
)
|
|
||||||
.then((r) => r.data),
|
|
||||||
|
|
||||||
create: (tenantId: string, data: CredentialProfileCreate) =>
|
|
||||||
api
|
|
||||||
.post<CredentialProfileResponse>(
|
|
||||||
`/api/tenants/${tenantId}/credential-profiles`,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
.then((r) => r.data),
|
|
||||||
|
|
||||||
update: (tenantId: string, profileId: string, data: Partial<CredentialProfileCreate>) =>
|
|
||||||
api
|
|
||||||
.put<CredentialProfileResponse>(
|
|
||||||
`/api/tenants/${tenantId}/credential-profiles/${profileId}`,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
.then((r) => r.data),
|
|
||||||
|
|
||||||
delete: (tenantId: string, profileId: string) =>
|
|
||||||
api
|
|
||||||
.delete(`/api/tenants/${tenantId}/credential-profiles/${profileId}`)
|
|
||||||
.then((r) => r.data),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import { Route as AuthenticatedAlertsRouteImport } from './routes/_authenticated
|
|||||||
import { Route as AuthenticatedAlertRulesRouteImport } from './routes/_authenticated/alert-rules'
|
import { Route as AuthenticatedAlertRulesRouteImport } from './routes/_authenticated/alert-rules'
|
||||||
import { Route as AuthenticatedAboutRouteImport } from './routes/_authenticated/about'
|
import { Route as AuthenticatedAboutRouteImport } from './routes/_authenticated/about'
|
||||||
import { Route as AuthenticatedTenantsIndexRouteImport } from './routes/_authenticated/tenants/index'
|
import { Route as AuthenticatedTenantsIndexRouteImport } from './routes/_authenticated/tenants/index'
|
||||||
|
import { Route as AuthenticatedSettingsSnmpProfilesRouteImport } from './routes/_authenticated/settings.snmp-profiles'
|
||||||
|
import { Route as AuthenticatedSettingsCredentialsRouteImport } from './routes/_authenticated/settings.credentials'
|
||||||
import { Route as AuthenticatedSettingsApiKeysRouteImport } from './routes/_authenticated/settings.api-keys'
|
import { Route as AuthenticatedSettingsApiKeysRouteImport } from './routes/_authenticated/settings.api-keys'
|
||||||
import { Route as AuthenticatedTenantsTenantIdIndexRouteImport } from './routes/_authenticated/tenants/$tenantId/index'
|
import { Route as AuthenticatedTenantsTenantIdIndexRouteImport } from './routes/_authenticated/tenants/$tenantId/index'
|
||||||
import { Route as AuthenticatedTenantsTenantIdWirelessLinksRouteImport } from './routes/_authenticated/tenants/$tenantId/wireless-links'
|
import { Route as AuthenticatedTenantsTenantIdWirelessLinksRouteImport } from './routes/_authenticated/tenants/$tenantId/wireless-links'
|
||||||
@@ -195,6 +197,18 @@ const AuthenticatedTenantsIndexRoute =
|
|||||||
path: '/tenants/',
|
path: '/tenants/',
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthenticatedSettingsSnmpProfilesRoute =
|
||||||
|
AuthenticatedSettingsSnmpProfilesRouteImport.update({
|
||||||
|
id: '/snmp-profiles',
|
||||||
|
path: '/snmp-profiles',
|
||||||
|
getParentRoute: () => AuthenticatedSettingsRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthenticatedSettingsCredentialsRoute =
|
||||||
|
AuthenticatedSettingsCredentialsRouteImport.update({
|
||||||
|
id: '/credentials',
|
||||||
|
path: '/credentials',
|
||||||
|
getParentRoute: () => AuthenticatedSettingsRoute,
|
||||||
|
} as any)
|
||||||
const AuthenticatedSettingsApiKeysRoute =
|
const AuthenticatedSettingsApiKeysRoute =
|
||||||
AuthenticatedSettingsApiKeysRouteImport.update({
|
AuthenticatedSettingsApiKeysRouteImport.update({
|
||||||
id: '/api-keys',
|
id: '/api-keys',
|
||||||
@@ -290,6 +304,8 @@ export interface FileRoutesByFullPath {
|
|||||||
'/vpn': typeof AuthenticatedVpnRoute
|
'/vpn': typeof AuthenticatedVpnRoute
|
||||||
'/wireless': typeof AuthenticatedWirelessRoute
|
'/wireless': typeof AuthenticatedWirelessRoute
|
||||||
'/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute
|
'/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute
|
||||||
|
'/settings/credentials': typeof AuthenticatedSettingsCredentialsRoute
|
||||||
|
'/settings/snmp-profiles': typeof AuthenticatedSettingsSnmpProfilesRoute
|
||||||
'/tenants/': typeof AuthenticatedTenantsIndexRoute
|
'/tenants/': typeof AuthenticatedTenantsIndexRoute
|
||||||
'/tenants/$tenantId/users': typeof AuthenticatedTenantsTenantIdUsersRoute
|
'/tenants/$tenantId/users': typeof AuthenticatedTenantsTenantIdUsersRoute
|
||||||
'/tenants/$tenantId/wireless-links': typeof AuthenticatedTenantsTenantIdWirelessLinksRoute
|
'/tenants/$tenantId/wireless-links': typeof AuthenticatedTenantsTenantIdWirelessLinksRoute
|
||||||
@@ -330,6 +346,8 @@ export interface FileRoutesByTo {
|
|||||||
'/wireless': typeof AuthenticatedWirelessRoute
|
'/wireless': typeof AuthenticatedWirelessRoute
|
||||||
'/': typeof AuthenticatedIndexRoute
|
'/': typeof AuthenticatedIndexRoute
|
||||||
'/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute
|
'/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute
|
||||||
|
'/settings/credentials': typeof AuthenticatedSettingsCredentialsRoute
|
||||||
|
'/settings/snmp-profiles': typeof AuthenticatedSettingsSnmpProfilesRoute
|
||||||
'/tenants': typeof AuthenticatedTenantsIndexRoute
|
'/tenants': typeof AuthenticatedTenantsIndexRoute
|
||||||
'/tenants/$tenantId/users': typeof AuthenticatedTenantsTenantIdUsersRoute
|
'/tenants/$tenantId/users': typeof AuthenticatedTenantsTenantIdUsersRoute
|
||||||
'/tenants/$tenantId/wireless-links': typeof AuthenticatedTenantsTenantIdWirelessLinksRoute
|
'/tenants/$tenantId/wireless-links': typeof AuthenticatedTenantsTenantIdWirelessLinksRoute
|
||||||
@@ -372,6 +390,8 @@ export interface FileRoutesById {
|
|||||||
'/_authenticated/wireless': typeof AuthenticatedWirelessRoute
|
'/_authenticated/wireless': typeof AuthenticatedWirelessRoute
|
||||||
'/_authenticated/': typeof AuthenticatedIndexRoute
|
'/_authenticated/': typeof AuthenticatedIndexRoute
|
||||||
'/_authenticated/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute
|
'/_authenticated/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute
|
||||||
|
'/_authenticated/settings/credentials': typeof AuthenticatedSettingsCredentialsRoute
|
||||||
|
'/_authenticated/settings/snmp-profiles': typeof AuthenticatedSettingsSnmpProfilesRoute
|
||||||
'/_authenticated/tenants/': typeof AuthenticatedTenantsIndexRoute
|
'/_authenticated/tenants/': typeof AuthenticatedTenantsIndexRoute
|
||||||
'/_authenticated/tenants/$tenantId/users': typeof AuthenticatedTenantsTenantIdUsersRoute
|
'/_authenticated/tenants/$tenantId/users': typeof AuthenticatedTenantsTenantIdUsersRoute
|
||||||
'/_authenticated/tenants/$tenantId/wireless-links': typeof AuthenticatedTenantsTenantIdWirelessLinksRoute
|
'/_authenticated/tenants/$tenantId/wireless-links': typeof AuthenticatedTenantsTenantIdWirelessLinksRoute
|
||||||
@@ -414,6 +434,8 @@ export interface FileRouteTypes {
|
|||||||
| '/vpn'
|
| '/vpn'
|
||||||
| '/wireless'
|
| '/wireless'
|
||||||
| '/settings/api-keys'
|
| '/settings/api-keys'
|
||||||
|
| '/settings/credentials'
|
||||||
|
| '/settings/snmp-profiles'
|
||||||
| '/tenants/'
|
| '/tenants/'
|
||||||
| '/tenants/$tenantId/users'
|
| '/tenants/$tenantId/users'
|
||||||
| '/tenants/$tenantId/wireless-links'
|
| '/tenants/$tenantId/wireless-links'
|
||||||
@@ -454,6 +476,8 @@ export interface FileRouteTypes {
|
|||||||
| '/wireless'
|
| '/wireless'
|
||||||
| '/'
|
| '/'
|
||||||
| '/settings/api-keys'
|
| '/settings/api-keys'
|
||||||
|
| '/settings/credentials'
|
||||||
|
| '/settings/snmp-profiles'
|
||||||
| '/tenants'
|
| '/tenants'
|
||||||
| '/tenants/$tenantId/users'
|
| '/tenants/$tenantId/users'
|
||||||
| '/tenants/$tenantId/wireless-links'
|
| '/tenants/$tenantId/wireless-links'
|
||||||
@@ -495,6 +519,8 @@ export interface FileRouteTypes {
|
|||||||
| '/_authenticated/wireless'
|
| '/_authenticated/wireless'
|
||||||
| '/_authenticated/'
|
| '/_authenticated/'
|
||||||
| '/_authenticated/settings/api-keys'
|
| '/_authenticated/settings/api-keys'
|
||||||
|
| '/_authenticated/settings/credentials'
|
||||||
|
| '/_authenticated/settings/snmp-profiles'
|
||||||
| '/_authenticated/tenants/'
|
| '/_authenticated/tenants/'
|
||||||
| '/_authenticated/tenants/$tenantId/users'
|
| '/_authenticated/tenants/$tenantId/users'
|
||||||
| '/_authenticated/tenants/$tenantId/wireless-links'
|
| '/_authenticated/tenants/$tenantId/wireless-links'
|
||||||
@@ -715,6 +741,20 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthenticatedTenantsIndexRouteImport
|
preLoaderRoute: typeof AuthenticatedTenantsIndexRouteImport
|
||||||
parentRoute: typeof AuthenticatedRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
|
'/_authenticated/settings/snmp-profiles': {
|
||||||
|
id: '/_authenticated/settings/snmp-profiles'
|
||||||
|
path: '/snmp-profiles'
|
||||||
|
fullPath: '/settings/snmp-profiles'
|
||||||
|
preLoaderRoute: typeof AuthenticatedSettingsSnmpProfilesRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedSettingsRoute
|
||||||
|
}
|
||||||
|
'/_authenticated/settings/credentials': {
|
||||||
|
id: '/_authenticated/settings/credentials'
|
||||||
|
path: '/credentials'
|
||||||
|
fullPath: '/settings/credentials'
|
||||||
|
preLoaderRoute: typeof AuthenticatedSettingsCredentialsRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedSettingsRoute
|
||||||
|
}
|
||||||
'/_authenticated/settings/api-keys': {
|
'/_authenticated/settings/api-keys': {
|
||||||
id: '/_authenticated/settings/api-keys'
|
id: '/_authenticated/settings/api-keys'
|
||||||
path: '/api-keys'
|
path: '/api-keys'
|
||||||
@@ -797,10 +837,15 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
interface AuthenticatedSettingsRouteChildren {
|
interface AuthenticatedSettingsRouteChildren {
|
||||||
AuthenticatedSettingsApiKeysRoute: typeof AuthenticatedSettingsApiKeysRoute
|
AuthenticatedSettingsApiKeysRoute: typeof AuthenticatedSettingsApiKeysRoute
|
||||||
|
AuthenticatedSettingsCredentialsRoute: typeof AuthenticatedSettingsCredentialsRoute
|
||||||
|
AuthenticatedSettingsSnmpProfilesRoute: typeof AuthenticatedSettingsSnmpProfilesRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthenticatedSettingsRouteChildren: AuthenticatedSettingsRouteChildren = {
|
const AuthenticatedSettingsRouteChildren: AuthenticatedSettingsRouteChildren = {
|
||||||
AuthenticatedSettingsApiKeysRoute: AuthenticatedSettingsApiKeysRoute,
|
AuthenticatedSettingsApiKeysRoute: AuthenticatedSettingsApiKeysRoute,
|
||||||
|
AuthenticatedSettingsCredentialsRoute: AuthenticatedSettingsCredentialsRoute,
|
||||||
|
AuthenticatedSettingsSnmpProfilesRoute:
|
||||||
|
AuthenticatedSettingsSnmpProfilesRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthenticatedSettingsRouteWithChildren =
|
const AuthenticatedSettingsRouteWithChildren =
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { ShieldAlert, Building2 } from 'lucide-react'
|
||||||
|
import { useAuth, isSuperAdmin, isTenantAdmin } from '@/lib/auth'
|
||||||
|
import { useUIStore } from '@/lib/store'
|
||||||
|
import { SNMPProfileEditorPage } from '@/components/settings/SNMPProfileEditorPage'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/settings/snmp-profiles')({
|
||||||
|
component: SNMPProfilesRoute,
|
||||||
|
})
|
||||||
|
|
||||||
|
function SNMPProfilesRoute() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const { selectedTenantId } = useUIStore()
|
||||||
|
|
||||||
|
const tenantId = isSuperAdmin(user) ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
|
||||||
|
|
||||||
|
// RBAC: only tenant_admin+ can manage SNMP profiles
|
||||||
|
if (!isTenantAdmin(user)) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-4xl">
|
||||||
|
<div className="rounded-lg border border-border bg-panel px-6 py-12 text-center">
|
||||||
|
<ShieldAlert className="h-10 w-10 text-text-muted mx-auto mb-3" />
|
||||||
|
<h2 className="text-sm font-medium mb-1">Access Denied</h2>
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
You need tenant admin or higher permissions to manage SNMP profiles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 max-w-4xl">
|
||||||
|
{!tenantId ? (
|
||||||
|
<div className="rounded-lg border border-border bg-panel p-8 text-center space-y-2">
|
||||||
|
<Building2 className="h-6 w-6 mx-auto text-text-muted" />
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
Select an organization from the sidebar to manage SNMP profiles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SNMPProfileEditorPage tenantId={tenantId} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user