feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
360
frontend/src/components/config/SnmpPanel.tsx
Normal file
360
frontend/src/components/config/SnmpPanel.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* SnmpPanel -- SNMP configuration panel.
|
||||
*
|
||||
* Enable/disable SNMP (/snmp).
|
||||
* Community strings management (/snmp/community).
|
||||
* Trap target configuration.
|
||||
* Contact, location, engine-id settings.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Plus, Pencil, Trash2, Radio } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { SafetyToggle } from './SafetyToggle'
|
||||
import { ChangePreviewModal } from './ChangePreviewModal'
|
||||
import { useConfigBrowse, useConfigPanel } from '@/hooks/useConfigPanel'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
|
||||
|
||||
export function SnmpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
const snmp = useConfigBrowse(tenantId, deviceId, '/snmp', { enabled: active })
|
||||
const communities = useConfigBrowse(tenantId, deviceId, '/snmp/community', { enabled: active })
|
||||
|
||||
const panel = useConfigPanel(tenantId, deviceId, 'snmp')
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [communityOpen, setCommunityOpen] = useState(false)
|
||||
const [editEntry, setEditEntry] = useState<Record<string, string> | null>(null)
|
||||
const [settingsForm, setSettingsForm] = useState<Record<string, string>>({})
|
||||
const [communityForm, setCommunityForm] = useState<Record<string, string>>({})
|
||||
|
||||
const snmpData = snmp.entries[0] ?? {}
|
||||
const isEnabled = snmpData['enabled'] === 'true' || snmpData['enabled'] === 'yes'
|
||||
|
||||
const handleToggleSnmp = useCallback(() => {
|
||||
panel.addChange({
|
||||
operation: 'set',
|
||||
path: '/snmp',
|
||||
properties: { enabled: isEnabled ? 'no' : 'yes' },
|
||||
description: `${isEnabled ? 'Disable' : 'Enable'} SNMP`,
|
||||
})
|
||||
}, [isEnabled, panel])
|
||||
|
||||
const handleEditSettings = useCallback(() => {
|
||||
setSettingsForm({
|
||||
contact: snmpData['contact'] || '',
|
||||
location: snmpData['location'] || '',
|
||||
'engine-id': snmpData['engine-id'] || '',
|
||||
'trap-target': snmpData['trap-target'] || '',
|
||||
'trap-community': snmpData['trap-community'] || '',
|
||||
'trap-version': snmpData['trap-version'] || '1',
|
||||
})
|
||||
setSettingsOpen(true)
|
||||
}, [snmpData])
|
||||
|
||||
const handleSaveSettings = useCallback(() => {
|
||||
const props: Record<string, string> = {}
|
||||
Object.entries(settingsForm).forEach(([key, value]) => {
|
||||
if (value !== (snmpData[key] || '')) props[key] = value
|
||||
})
|
||||
if (Object.keys(props).length === 0) { setSettingsOpen(false); return }
|
||||
panel.addChange({
|
||||
operation: 'set',
|
||||
path: '/snmp',
|
||||
properties: props,
|
||||
description: `Update SNMP settings (${Object.keys(props).join(', ')})`,
|
||||
})
|
||||
setSettingsOpen(false)
|
||||
}, [settingsForm, snmpData, panel])
|
||||
|
||||
const handleAddCommunity = useCallback(() => {
|
||||
setEditEntry(null)
|
||||
setCommunityForm({
|
||||
name: '',
|
||||
addresses: '0.0.0.0/0',
|
||||
'read-access': 'yes',
|
||||
'write-access': 'no',
|
||||
security: 'none',
|
||||
'authentication-protocol': 'MD5',
|
||||
'encryption-protocol': 'DES',
|
||||
})
|
||||
setCommunityOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleEditCommunity = useCallback((entry: Record<string, string>) => {
|
||||
setEditEntry(entry)
|
||||
setCommunityForm({
|
||||
name: entry['name'] || '',
|
||||
addresses: entry['addresses'] || '',
|
||||
'read-access': entry['read-access'] || 'yes',
|
||||
'write-access': entry['write-access'] || 'no',
|
||||
security: entry['security'] || 'none',
|
||||
'authentication-protocol': entry['authentication-protocol'] || 'MD5',
|
||||
'encryption-protocol': entry['encryption-protocol'] || 'DES',
|
||||
})
|
||||
setCommunityOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleSaveCommunity = useCallback(() => {
|
||||
if (!communityForm['name']) return
|
||||
if (editEntry) {
|
||||
const props: Record<string, string> = {}
|
||||
Object.entries(communityForm).forEach(([key, value]) => {
|
||||
if (value !== editEntry[key]) props[key] = value
|
||||
})
|
||||
if (Object.keys(props).length === 0) { setCommunityOpen(false); return }
|
||||
panel.addChange({
|
||||
operation: 'set',
|
||||
path: '/snmp/community',
|
||||
entryId: editEntry['.id'],
|
||||
properties: props,
|
||||
description: `Update SNMP community "${communityForm['name']}"`,
|
||||
})
|
||||
} else {
|
||||
panel.addChange({
|
||||
operation: 'add',
|
||||
path: '/snmp/community',
|
||||
properties: communityForm,
|
||||
description: `Add SNMP community "${communityForm['name']}"`,
|
||||
})
|
||||
}
|
||||
setCommunityOpen(false)
|
||||
}, [communityForm, editEntry, panel])
|
||||
|
||||
const handleDeleteCommunity = useCallback((entry: Record<string, string>) => {
|
||||
panel.addChange({
|
||||
operation: 'remove',
|
||||
path: '/snmp/community',
|
||||
entryId: entry['.id'],
|
||||
properties: {},
|
||||
description: `Remove SNMP community "${entry['name']}"`,
|
||||
})
|
||||
}, [panel])
|
||||
|
||||
if (snmp.isLoading) {
|
||||
return <div className="flex items-center justify-center py-12 text-text-secondary text-sm">Loading SNMP settings...</div>
|
||||
}
|
||||
if (snmp.error) {
|
||||
return <div className="flex items-center justify-center py-12 text-error text-sm">Failed to load. <button className="underline ml-1" onClick={() => snmp.refetch()}>Retry</button></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<SafetyToggle mode={panel.applyMode} onModeChange={panel.setApplyMode} />
|
||||
<Button size="sm" disabled={panel.pendingChanges.length === 0 || panel.isApplying} onClick={() => setPreviewOpen(true)}>
|
||||
Review & Apply ({panel.pendingChanges.length})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* SNMP Status + Settings */}
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="h-4 w-4 text-accent" />
|
||||
<span className="text-sm font-medium text-text-secondary">SNMP Service</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleToggleSnmp}
|
||||
className={cn(
|
||||
'text-xs px-2.5 py-1 rounded border transition-colors',
|
||||
isEnabled
|
||||
? 'border-success/50 bg-success/10 text-success'
|
||||
: 'border-border bg-elevated text-text-muted',
|
||||
)}
|
||||
>
|
||||
{isEnabled ? 'Enabled' : 'Disabled'}
|
||||
</button>
|
||||
<Button size="sm" variant="outline" className="gap-1" onClick={handleEditSettings}>
|
||||
<Pencil className="h-3.5 w-3.5" /> Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-1.5">
|
||||
<InfoRow label="Contact" value={snmpData['contact']} />
|
||||
<InfoRow label="Location" value={snmpData['location']} />
|
||||
<InfoRow label="Engine ID" value={snmpData['engine-id']} />
|
||||
<InfoRow label="Trap Target" value={snmpData['trap-target']} />
|
||||
<InfoRow label="Trap Community" value={snmpData['trap-community']} />
|
||||
<InfoRow label="Trap Version" value={snmpData['trap-version']} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Communities */}
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
|
||||
<span className="text-sm font-medium text-text-secondary">SNMP Communities</span>
|
||||
<Button size="sm" variant="outline" className="gap-1" onClick={handleAddCommunity}>
|
||||
<Plus className="h-3.5 w-3.5" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
{communities.entries.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-text-muted">No SNMP communities configured.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50 text-text-muted">
|
||||
<th className="text-left px-3 py-2">Name</th>
|
||||
<th className="text-left px-3 py-2">Addresses</th>
|
||||
<th className="text-center px-3 py-2">Read</th>
|
||||
<th className="text-center px-3 py-2">Write</th>
|
||||
<th className="text-left px-3 py-2">Security</th>
|
||||
<th className="text-right px-3 py-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="font-mono">
|
||||
{communities.entries.map((entry) => (
|
||||
<tr key={entry['.id']} className="border-b border-border/20 last:border-0">
|
||||
<td className="px-3 py-1.5 text-text-primary font-medium">{entry['name']}</td>
|
||||
<td className="px-3 py-1.5 text-text-secondary">{entry['addresses'] || '0.0.0.0/0'}</td>
|
||||
<td className="px-3 py-1.5 text-center">
|
||||
<span className={entry['read-access'] === 'yes' ? 'text-success' : 'text-text-muted'}>
|
||||
{entry['read-access'] === 'yes' ? 'Y' : 'N'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-center">
|
||||
<span className={entry['write-access'] === 'yes' ? 'text-warning' : 'text-text-muted'}>
|
||||
{entry['write-access'] === 'yes' ? 'Y' : 'N'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-text-secondary">{entry['security'] || 'none'}</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
<div className="flex gap-1 justify-end">
|
||||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => handleEditCommunity(entry)}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0 text-error" onClick={() => handleDeleteCommunity(entry)}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SNMP Settings dialog */}
|
||||
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>SNMP Settings</DialogTitle>
|
||||
<DialogDescription>Configure SNMP service settings. Changes are staged.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 mt-2 grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-text-secondary">Contact</Label>
|
||||
<Input value={settingsForm['contact'] || ''} onChange={(e) => setSettingsForm((f) => ({ ...f, contact: e.target.value }))} className="h-8 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-text-secondary">Location</Label>
|
||||
<Input value={settingsForm['location'] || ''} onChange={(e) => setSettingsForm((f) => ({ ...f, location: e.target.value }))} className="h-8 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-text-secondary">Engine ID</Label>
|
||||
<Input value={settingsForm['engine-id'] || ''} onChange={(e) => setSettingsForm((f) => ({ ...f, 'engine-id': e.target.value }))} className="h-8 text-sm font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-text-secondary">Trap Target</Label>
|
||||
<Input value={settingsForm['trap-target'] || ''} onChange={(e) => setSettingsForm((f) => ({ ...f, 'trap-target': e.target.value }))} placeholder="192.168.1.100" className="h-8 text-sm font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-text-secondary">Trap Community</Label>
|
||||
<Input value={settingsForm['trap-community'] || ''} onChange={(e) => setSettingsForm((f) => ({ ...f, 'trap-community': e.target.value }))} className="h-8 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-text-secondary">Trap Version</Label>
|
||||
<select value={settingsForm['trap-version'] || '1'} onChange={(e) => setSettingsForm((f) => ({ ...f, 'trap-version': e.target.value }))}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
|
||||
<option value="1">v1</option>
|
||||
<option value="2">v2c</option>
|
||||
<option value="3">v3</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSettingsOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSaveSettings}>Stage Changes</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Community dialog */}
|
||||
<Dialog open={communityOpen} onOpenChange={setCommunityOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editEntry ? 'Edit Community' : 'Add Community'}</DialogTitle>
|
||||
<DialogDescription>Configure SNMP community string. Changes are staged.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 mt-2">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-text-secondary">Name</Label>
|
||||
<Input value={communityForm['name'] || ''} onChange={(e) => setCommunityForm((f) => ({ ...f, name: e.target.value }))} placeholder="public" className="h-8 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-text-secondary">Addresses</Label>
|
||||
<Input value={communityForm['addresses'] || ''} onChange={(e) => setCommunityForm((f) => ({ ...f, addresses: e.target.value }))} placeholder="0.0.0.0/0" className="h-8 text-sm font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-text-secondary">Read Access</Label>
|
||||
<select value={communityForm['read-access'] || 'yes'} onChange={(e) => setCommunityForm((f) => ({ ...f, 'read-access': e.target.value }))}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-text-secondary">Write Access</Label>
|
||||
<select value={communityForm['write-access'] || 'no'} onChange={(e) => setCommunityForm((f) => ({ ...f, 'write-access': e.target.value }))}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-text-secondary">Security</Label>
|
||||
<select value={communityForm['security'] || 'none'} onChange={(e) => setCommunityForm((f) => ({ ...f, security: e.target.value }))}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
|
||||
<option value="none">None</option>
|
||||
<option value="authorized">Authorized</option>
|
||||
<option value="private">Private</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCommunityOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSaveCommunity} disabled={!communityForm['name']}>Stage Changes</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ChangePreviewModal open={previewOpen} onOpenChange={setPreviewOpen} changes={panel.pendingChanges} applyMode={panel.applyMode}
|
||||
onConfirm={() => { panel.applyChanges(); setPreviewOpen(false) }} isApplying={panel.isApplying} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value?: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-4 py-1 border-b border-border/20 last:border-0">
|
||||
<span className="text-xs text-text-muted w-28 flex-shrink-0">{label}</span>
|
||||
<span className="text-sm text-text-primary font-mono">{value || '—'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user