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

SNMP Profiles

+

+ Manage SNMP polling profiles for device monitoring +

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

System Profiles

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

Custom Profiles

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

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

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

MIB Upload

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

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

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

Poll Groups

+

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

+
+ {(['fast', 'standard', 'slow'] as PollGroupKey[]).map((key) => ( + setActivePollGroup(key)} + onRemoveOid={handleRemoveOid} + onAddOid={handleManualAdd} + /> + ))} +
+
+ + {/* Test Panel */} + + + {/* Actions */} +
+ + +
+
+ ) +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fa710d2..e5b43c7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -556,6 +556,50 @@ export interface SNMPProfileResponse { 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 +} + export const snmpProfilesApi = { list: (tenantId: string) => api @@ -568,6 +612,46 @@ export const snmpProfilesApi = { `/api/tenants/${tenantId}/snmp-profiles/${profileId}`, ) .then((r) => r.data), + + create: (tenantId: string, data: SNMPProfileCreate) => + api + .post( + `/api/tenants/${tenantId}/snmp-profiles`, + data, + ) + .then((r) => r.data), + + update: (tenantId: string, profileId: string, data: SNMPProfileCreate) => + api + .put( + `/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( + `/api/tenants/${tenantId}/snmp-profiles/parse-mib`, + formData, + ) + .then((r) => r.data) + }, + + testProfile: (tenantId: string, profileId: string, data: ProfileTestRequest) => + api + .post( + `/api/tenants/${tenantId}/snmp-profiles/${profileId}/test`, + data, + ) + .then((r) => r.data), } // ─── Bulk Add (credential profile) ────────────────────────────────────────── @@ -1789,72 +1873,3 @@ export const alertEventsApi = { 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( - `/api/tenants/${tenantId}/credential-profiles`, - { params: credentialType ? { credential_type: credentialType } : undefined }, - ) - .then((r) => r.data), - - get: (tenantId: string, profileId: string) => - api - .get( - `/api/tenants/${tenantId}/credential-profiles/${profileId}`, - ) - .then((r) => r.data), - - create: (tenantId: string, data: CredentialProfileCreate) => - api - .post( - `/api/tenants/${tenantId}/credential-profiles`, - data, - ) - .then((r) => r.data), - - update: (tenantId: string, profileId: string, data: Partial) => - api - .put( - `/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), -} diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 02804e8..f7246db 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -37,6 +37,8 @@ import { Route as AuthenticatedAlertsRouteImport } from './routes/_authenticated import { Route as AuthenticatedAlertRulesRouteImport } from './routes/_authenticated/alert-rules' import { Route as AuthenticatedAboutRouteImport } from './routes/_authenticated/about' 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 AuthenticatedTenantsTenantIdIndexRouteImport } from './routes/_authenticated/tenants/$tenantId/index' import { Route as AuthenticatedTenantsTenantIdWirelessLinksRouteImport } from './routes/_authenticated/tenants/$tenantId/wireless-links' @@ -195,6 +197,18 @@ const AuthenticatedTenantsIndexRoute = path: '/tenants/', getParentRoute: () => AuthenticatedRoute, } 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 = AuthenticatedSettingsApiKeysRouteImport.update({ id: '/api-keys', @@ -290,6 +304,8 @@ export interface FileRoutesByFullPath { '/vpn': typeof AuthenticatedVpnRoute '/wireless': typeof AuthenticatedWirelessRoute '/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute + '/settings/credentials': typeof AuthenticatedSettingsCredentialsRoute + '/settings/snmp-profiles': typeof AuthenticatedSettingsSnmpProfilesRoute '/tenants/': typeof AuthenticatedTenantsIndexRoute '/tenants/$tenantId/users': typeof AuthenticatedTenantsTenantIdUsersRoute '/tenants/$tenantId/wireless-links': typeof AuthenticatedTenantsTenantIdWirelessLinksRoute @@ -330,6 +346,8 @@ export interface FileRoutesByTo { '/wireless': typeof AuthenticatedWirelessRoute '/': typeof AuthenticatedIndexRoute '/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute + '/settings/credentials': typeof AuthenticatedSettingsCredentialsRoute + '/settings/snmp-profiles': typeof AuthenticatedSettingsSnmpProfilesRoute '/tenants': typeof AuthenticatedTenantsIndexRoute '/tenants/$tenantId/users': typeof AuthenticatedTenantsTenantIdUsersRoute '/tenants/$tenantId/wireless-links': typeof AuthenticatedTenantsTenantIdWirelessLinksRoute @@ -372,6 +390,8 @@ export interface FileRoutesById { '/_authenticated/wireless': typeof AuthenticatedWirelessRoute '/_authenticated/': typeof AuthenticatedIndexRoute '/_authenticated/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute + '/_authenticated/settings/credentials': typeof AuthenticatedSettingsCredentialsRoute + '/_authenticated/settings/snmp-profiles': typeof AuthenticatedSettingsSnmpProfilesRoute '/_authenticated/tenants/': typeof AuthenticatedTenantsIndexRoute '/_authenticated/tenants/$tenantId/users': typeof AuthenticatedTenantsTenantIdUsersRoute '/_authenticated/tenants/$tenantId/wireless-links': typeof AuthenticatedTenantsTenantIdWirelessLinksRoute @@ -414,6 +434,8 @@ export interface FileRouteTypes { | '/vpn' | '/wireless' | '/settings/api-keys' + | '/settings/credentials' + | '/settings/snmp-profiles' | '/tenants/' | '/tenants/$tenantId/users' | '/tenants/$tenantId/wireless-links' @@ -454,6 +476,8 @@ export interface FileRouteTypes { | '/wireless' | '/' | '/settings/api-keys' + | '/settings/credentials' + | '/settings/snmp-profiles' | '/tenants' | '/tenants/$tenantId/users' | '/tenants/$tenantId/wireless-links' @@ -495,6 +519,8 @@ export interface FileRouteTypes { | '/_authenticated/wireless' | '/_authenticated/' | '/_authenticated/settings/api-keys' + | '/_authenticated/settings/credentials' + | '/_authenticated/settings/snmp-profiles' | '/_authenticated/tenants/' | '/_authenticated/tenants/$tenantId/users' | '/_authenticated/tenants/$tenantId/wireless-links' @@ -715,6 +741,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedTenantsIndexRouteImport 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': { id: '/_authenticated/settings/api-keys' path: '/api-keys' @@ -797,10 +837,15 @@ declare module '@tanstack/react-router' { interface AuthenticatedSettingsRouteChildren { AuthenticatedSettingsApiKeysRoute: typeof AuthenticatedSettingsApiKeysRoute + AuthenticatedSettingsCredentialsRoute: typeof AuthenticatedSettingsCredentialsRoute + AuthenticatedSettingsSnmpProfilesRoute: typeof AuthenticatedSettingsSnmpProfilesRoute } const AuthenticatedSettingsRouteChildren: AuthenticatedSettingsRouteChildren = { AuthenticatedSettingsApiKeysRoute: AuthenticatedSettingsApiKeysRoute, + AuthenticatedSettingsCredentialsRoute: AuthenticatedSettingsCredentialsRoute, + AuthenticatedSettingsSnmpProfilesRoute: + AuthenticatedSettingsSnmpProfilesRoute, } const AuthenticatedSettingsRouteWithChildren = diff --git a/frontend/src/routes/_authenticated/settings.snmp-profiles.tsx b/frontend/src/routes/_authenticated/settings.snmp-profiles.tsx new file mode 100644 index 0000000..81a54c7 --- /dev/null +++ b/frontend/src/routes/_authenticated/settings.snmp-profiles.tsx @@ -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 ( +
+
+ +

Access Denied

+

+ You need tenant admin or higher permissions to manage SNMP profiles. +

+
+
+ ) + } + + return ( +
+ {!tenantId ? ( +
+ +

+ Select an organization from the sidebar to manage SNMP profiles. +

+
+ ) : ( + + )} +
+ ) +}