feat(19-02): redesign Add Device dialog with RouterOS, SNMP, and VPN tabs
- Always show three-tab layout (RouterOS, SNMP, VPN) instead of conditional two-tab - RouterOS tab: credential profile toggle (profile mode vs manual credentials) - SNMP tab: version selector (v2c/v3), credential profile, device profile, port - Both tabs have "Add Multiple" toggle to switch to BulkAddForm - VPN tab renders existing VpnOnboardingWizard unchanged - All form state resets on dialog close Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,14 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { CheckCircle2, XCircle } from 'lucide-react'
|
import { CheckCircle2, XCircle, List } from 'lucide-react'
|
||||||
import { devicesApi, vpnApi } from '@/lib/api'
|
import {
|
||||||
|
devicesApi,
|
||||||
|
vpnApi,
|
||||||
|
credentialProfilesApi,
|
||||||
|
snmpProfilesApi,
|
||||||
|
type CredentialProfileResponse,
|
||||||
|
type SNMPProfileResponse,
|
||||||
|
} from '@/lib/api'
|
||||||
import { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -13,8 +20,16 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||||
import { VpnOnboardingWizard } from '@/components/vpn/VpnOnboardingWizard'
|
import { VpnOnboardingWizard } from '@/components/vpn/VpnOnboardingWizard'
|
||||||
|
import { BulkAddForm } from '@/components/fleet/BulkAddForm'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tenantId: string
|
tenantId: string
|
||||||
@@ -26,6 +41,11 @@ type ConnectionStatus = 'idle' | 'success' | 'error'
|
|||||||
|
|
||||||
export function AddDeviceForm({ tenantId, open, onClose }: Props) {
|
export function AddDeviceForm({ tenantId, open, onClose }: Props) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// RouterOS state
|
||||||
|
const [useProfile, setUseProfile] = useState(false)
|
||||||
|
const [showBulk, setShowBulk] = useState(false)
|
||||||
|
const [rosProfileId, setRosProfileId] = useState('')
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
hostname: '',
|
hostname: '',
|
||||||
ip_address: '',
|
ip_address: '',
|
||||||
@@ -34,6 +54,19 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) {
|
|||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// SNMP state
|
||||||
|
const [snmpVersion, setSnmpVersion] = useState<'v2c' | 'v3'>('v2c')
|
||||||
|
const [showSnmpBulk, setShowSnmpBulk] = useState(false)
|
||||||
|
const [snmpForm, setSnmpForm] = useState({
|
||||||
|
ip_address: '',
|
||||||
|
hostname: '',
|
||||||
|
snmp_port: '161',
|
||||||
|
credential_profile_id: '',
|
||||||
|
snmp_profile_id: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Shared state
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('idle')
|
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('idle')
|
||||||
|
|
||||||
@@ -46,16 +79,51 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) {
|
|||||||
|
|
||||||
const vpnEnabled = vpnConfig?.is_enabled ?? false
|
const vpnEnabled = vpnConfig?.is_enabled ?? false
|
||||||
|
|
||||||
const mutation = useMutation({
|
// RouterOS credential profiles
|
||||||
mutationFn: () =>
|
const { data: rosProfiles } = useQuery({
|
||||||
devicesApi.create(tenantId, {
|
queryKey: ['credential-profiles', tenantId, 'routeros'],
|
||||||
|
queryFn: () => credentialProfilesApi.list(tenantId, 'routeros'),
|
||||||
|
enabled: open && !!tenantId,
|
||||||
|
})
|
||||||
|
|
||||||
|
// SNMP credential profiles (filtered by version)
|
||||||
|
const snmpCredType = snmpVersion === 'v2c' ? 'snmp_v2c' : 'snmp_v3'
|
||||||
|
const { data: snmpCredProfiles } = useQuery({
|
||||||
|
queryKey: ['credential-profiles', tenantId, snmpCredType],
|
||||||
|
queryFn: () => credentialProfilesApi.list(tenantId, snmpCredType),
|
||||||
|
enabled: open && !!tenantId,
|
||||||
|
})
|
||||||
|
|
||||||
|
// SNMP device profiles
|
||||||
|
const { data: snmpDeviceProfiles } = useQuery({
|
||||||
|
queryKey: ['snmp-profiles', tenantId],
|
||||||
|
queryFn: () => snmpProfilesApi.list(tenantId),
|
||||||
|
enabled: open && !!tenantId,
|
||||||
|
})
|
||||||
|
|
||||||
|
// RouterOS single-add mutation
|
||||||
|
const rosMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
if (useProfile) {
|
||||||
|
return devicesApi.create(tenantId, {
|
||||||
|
hostname: form.hostname || form.ip_address,
|
||||||
|
ip_address: form.ip_address,
|
||||||
|
device_type: 'routeros',
|
||||||
|
credential_profile_id: rosProfileId,
|
||||||
|
api_port: parseInt(form.api_port) || 8728,
|
||||||
|
api_ssl_port: parseInt(form.api_ssl_port) || 8729,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return devicesApi.create(tenantId, {
|
||||||
hostname: form.hostname || form.ip_address,
|
hostname: form.hostname || form.ip_address,
|
||||||
ip_address: form.ip_address,
|
ip_address: form.ip_address,
|
||||||
|
device_type: 'routeros',
|
||||||
api_port: parseInt(form.api_port) || 8728,
|
api_port: parseInt(form.api_port) || 8728,
|
||||||
api_ssl_port: parseInt(form.api_ssl_port) || 8729,
|
api_ssl_port: parseInt(form.api_ssl_port) || 8729,
|
||||||
username: form.username,
|
username: form.username,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
}),
|
})
|
||||||
|
},
|
||||||
onSuccess: (device) => {
|
onSuccess: (device) => {
|
||||||
setConnectionStatus('success')
|
setConnectionStatus('success')
|
||||||
void queryClient.invalidateQueries({ queryKey: ['devices', tenantId] })
|
void queryClient.invalidateQueries({ queryKey: ['devices', tenantId] })
|
||||||
@@ -66,8 +134,36 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) {
|
|||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
setConnectionStatus('error')
|
setConnectionStatus('error')
|
||||||
const detail =
|
const detail =
|
||||||
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ??
|
||||||
?? 'Connection failed. Check the IP address, port, and credentials.'
|
'Connection failed. Check the IP address, port, and credentials.'
|
||||||
|
setError(detail)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// SNMP single-add mutation
|
||||||
|
const snmpMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
devicesApi.create(tenantId, {
|
||||||
|
hostname: snmpForm.hostname || snmpForm.ip_address,
|
||||||
|
ip_address: snmpForm.ip_address,
|
||||||
|
device_type: 'snmp',
|
||||||
|
snmp_port: parseInt(snmpForm.snmp_port) || 161,
|
||||||
|
snmp_version: snmpVersion,
|
||||||
|
credential_profile_id: snmpForm.credential_profile_id || undefined,
|
||||||
|
snmp_profile_id: snmpForm.snmp_profile_id || undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: (device) => {
|
||||||
|
setConnectionStatus('success')
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['devices', tenantId] })
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['tenants'] })
|
||||||
|
toast({ title: `SNMP device "${device.hostname}" added successfully` })
|
||||||
|
setTimeout(() => handleClose(), 1000)
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
setConnectionStatus('error')
|
||||||
|
const detail =
|
||||||
|
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ??
|
||||||
|
'Failed to add SNMP device. Check IP and credentials.'
|
||||||
setError(detail)
|
setError(detail)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -81,6 +177,18 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) {
|
|||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
})
|
})
|
||||||
|
setSnmpForm({
|
||||||
|
ip_address: '',
|
||||||
|
hostname: '',
|
||||||
|
snmp_port: '161',
|
||||||
|
credential_profile_id: '',
|
||||||
|
snmp_profile_id: '',
|
||||||
|
})
|
||||||
|
setRosProfileId('')
|
||||||
|
setUseProfile(false)
|
||||||
|
setShowBulk(false)
|
||||||
|
setShowSnmpBulk(false)
|
||||||
|
setSnmpVersion('v2c')
|
||||||
setError(null)
|
setError(null)
|
||||||
setConnectionStatus('idle')
|
setConnectionStatus('idle')
|
||||||
onClose()
|
onClose()
|
||||||
@@ -92,92 +200,60 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) {
|
|||||||
handleClose()
|
handleClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleRosSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!form.ip_address.trim() || !form.username.trim() || !form.password.trim()) {
|
if (!form.ip_address.trim()) {
|
||||||
setError('IP address, username, and password are required')
|
setError('IP address is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (useProfile && !rosProfileId) {
|
||||||
|
setError('Select a credential profile')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!useProfile && (!form.username.trim() || !form.password.trim())) {
|
||||||
|
setError('Username and password are required')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setError(null)
|
setError(null)
|
||||||
setConnectionStatus('idle')
|
setConnectionStatus('idle')
|
||||||
mutation.mutate()
|
rosMutation.mutate()
|
||||||
}
|
}
|
||||||
|
|
||||||
const update = (field: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleSnmpSubmit = (e: React.FormEvent) => {
|
||||||
setForm((f) => ({ ...f, [field]: e.target.value }))
|
e.preventDefault()
|
||||||
if (error) setError(null)
|
if (!snmpForm.ip_address.trim()) {
|
||||||
|
setError('IP address is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!snmpForm.credential_profile_id) {
|
||||||
|
setError('Select a credential profile')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
setConnectionStatus('idle')
|
setConnectionStatus('idle')
|
||||||
|
snmpMutation.mutate()
|
||||||
}
|
}
|
||||||
|
|
||||||
const directConnectionForm = (
|
const updateRos =
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
(field: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
<div className="grid grid-cols-2 gap-3">
|
setForm((f) => ({ ...f, [field]: e.target.value }))
|
||||||
<div className="col-span-2 space-y-1.5">
|
if (error) setError(null)
|
||||||
<Label htmlFor="device-ip">IP Address / Hostname *</Label>
|
setConnectionStatus('idle')
|
||||||
<Input
|
}
|
||||||
id="device-ip"
|
|
||||||
value={form.ip_address}
|
|
||||||
onChange={update('ip_address')}
|
|
||||||
placeholder="192.168.1.1"
|
|
||||||
autoFocus={!vpnEnabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="device-hostname">Display Name</Label>
|
|
||||||
<Input
|
|
||||||
id="device-hostname"
|
|
||||||
value={form.hostname}
|
|
||||||
onChange={update('hostname')}
|
|
||||||
placeholder="router-01 (optional)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="device-api-port">API Port</Label>
|
|
||||||
<Input
|
|
||||||
id="device-api-port"
|
|
||||||
value={form.api_port}
|
|
||||||
onChange={update('api_port')}
|
|
||||||
placeholder="8728"
|
|
||||||
type="number"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="device-ssl-port">TLS API Port</Label>
|
|
||||||
<Input
|
|
||||||
id="device-ssl-port"
|
|
||||||
value={form.api_ssl_port}
|
|
||||||
onChange={update('api_ssl_port')}
|
|
||||||
placeholder="8729"
|
|
||||||
type="number"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="device-username">Username *</Label>
|
|
||||||
<Input
|
|
||||||
id="device-username"
|
|
||||||
value={form.username}
|
|
||||||
onChange={update('username')}
|
|
||||||
placeholder="admin"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2 space-y-1.5">
|
|
||||||
<Label htmlFor="device-password">Password *</Label>
|
|
||||||
<Input
|
|
||||||
id="device-password"
|
|
||||||
type="password"
|
|
||||||
value={form.password}
|
|
||||||
onChange={update('password')}
|
|
||||||
placeholder="••••••••"
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
const updateSnmp =
|
||||||
|
(field: keyof typeof snmpForm) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSnmpForm((f) => ({ ...f, [field]: e.target.value }))
|
||||||
|
if (error) setError(null)
|
||||||
|
setConnectionStatus('idle')
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBanner = (
|
||||||
|
<>
|
||||||
{connectionStatus === 'success' && (
|
{connectionStatus === 'success' && (
|
||||||
<div className="flex items-center gap-2 rounded-md bg-success/10 border border-success/50 px-3 py-2">
|
<div className="flex items-center gap-2 rounded-md bg-success/10 border border-success/50 px-3 py-2">
|
||||||
<CheckCircle2 className="h-4 w-4 text-success flex-shrink-0" />
|
<CheckCircle2 className="h-4 w-4 text-success flex-shrink-0" />
|
||||||
<p className="text-xs text-success">Device connected and added successfully</p>
|
<p className="text-xs text-success">Device added successfully</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{connectionStatus === 'error' && error && (
|
{connectionStatus === 'error' && error && (
|
||||||
@@ -186,14 +262,287 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) {
|
|||||||
<p className="text-xs text-error">{error}</p>
|
<p className="text-xs text-error">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
<DialogFooter>
|
// Helper to get profile list as array
|
||||||
<Button type="button" variant="ghost" onClick={handleClose} size="sm">
|
const rosProfileList: CredentialProfileResponse[] =
|
||||||
Cancel
|
rosProfiles?.profiles ?? []
|
||||||
</Button>
|
const snmpCredProfileList: CredentialProfileResponse[] =
|
||||||
<Button type="submit" size="sm" disabled={mutation.isPending}>
|
snmpCredProfiles?.profiles ?? []
|
||||||
{mutation.isPending ? 'Connecting...' : 'Add Device'}
|
const snmpDeviceProfileList: SNMPProfileResponse[] = Array.isArray(snmpDeviceProfiles)
|
||||||
</Button>
|
? snmpDeviceProfiles
|
||||||
|
: snmpDeviceProfiles?.profiles ?? []
|
||||||
|
|
||||||
|
// ─── RouterOS Tab ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const routerosTab = showBulk ? (
|
||||||
|
<BulkAddForm
|
||||||
|
tenantId={tenantId}
|
||||||
|
deviceType="routeros"
|
||||||
|
onClose={handleClose}
|
||||||
|
onBack={() => setShowBulk(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleRosSubmit} className="space-y-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUseProfile(!useProfile)}
|
||||||
|
className="text-xs text-accent hover:text-accent-hover transition-colors"
|
||||||
|
>
|
||||||
|
{useProfile ? 'Enter credentials manually' : 'Use credential profile'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{useProfile && (
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label>Credential Profile *</Label>
|
||||||
|
<Select value={rosProfileId} onValueChange={setRosProfileId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select profile..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{rosProfileList.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label htmlFor="ros-ip">IP Address *</Label>
|
||||||
|
<Input
|
||||||
|
id="ros-ip"
|
||||||
|
value={form.ip_address}
|
||||||
|
onChange={updateRos('ip_address')}
|
||||||
|
placeholder="192.168.1.1"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ros-hostname">Display Name</Label>
|
||||||
|
<Input
|
||||||
|
id="ros-hostname"
|
||||||
|
value={form.hostname}
|
||||||
|
onChange={updateRos('hostname')}
|
||||||
|
placeholder="router-01 (optional)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ros-api-port">API Port</Label>
|
||||||
|
<Input
|
||||||
|
id="ros-api-port"
|
||||||
|
value={form.api_port}
|
||||||
|
onChange={updateRos('api_port')}
|
||||||
|
placeholder="8728"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ros-ssl-port">TLS API Port</Label>
|
||||||
|
<Input
|
||||||
|
id="ros-ssl-port"
|
||||||
|
value={form.api_ssl_port}
|
||||||
|
onChange={updateRos('api_ssl_port')}
|
||||||
|
placeholder="8729"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!useProfile && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ros-username">Username *</Label>
|
||||||
|
<Input
|
||||||
|
id="ros-username"
|
||||||
|
value={form.username}
|
||||||
|
onChange={updateRos('username')}
|
||||||
|
placeholder="admin"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label htmlFor="ros-password">Password *</Label>
|
||||||
|
<Input
|
||||||
|
id="ros-password"
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={updateRos('password')}
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{statusBanner}
|
||||||
|
|
||||||
|
<DialogFooter className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowBulk(true)}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-text-secondary hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<List className="h-3.5 w-3.5" />
|
||||||
|
Add Multiple
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant="ghost" onClick={handleClose} size="sm">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" size="sm" disabled={rosMutation.isPending}>
|
||||||
|
{rosMutation.isPending ? 'Connecting...' : 'Add Device'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── SNMP Tab ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const snmpTab = showSnmpBulk ? (
|
||||||
|
<BulkAddForm
|
||||||
|
tenantId={tenantId}
|
||||||
|
deviceType="snmp"
|
||||||
|
onClose={handleClose}
|
||||||
|
onBack={() => setShowSnmpBulk(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSnmpSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label>SNMP Version</Label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={snmpVersion === 'v2c' ? 'default' : 'outline'}
|
||||||
|
onClick={() => {
|
||||||
|
setSnmpVersion('v2c')
|
||||||
|
setSnmpForm((f) => ({ ...f, credential_profile_id: '' }))
|
||||||
|
}}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
v2c
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={snmpVersion === 'v3' ? 'default' : 'outline'}
|
||||||
|
onClick={() => {
|
||||||
|
setSnmpVersion('v3')
|
||||||
|
setSnmpForm((f) => ({ ...f, credential_profile_id: '' }))
|
||||||
|
}}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
v3
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label>Credential Profile *</Label>
|
||||||
|
<Select
|
||||||
|
value={snmpForm.credential_profile_id}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setSnmpForm((f) => ({ ...f, credential_profile_id: v }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select SNMP credential profile..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{snmpCredProfileList.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="snmp-ip">IP Address *</Label>
|
||||||
|
<Input
|
||||||
|
id="snmp-ip"
|
||||||
|
value={snmpForm.ip_address}
|
||||||
|
onChange={updateSnmp('ip_address')}
|
||||||
|
placeholder="192.168.1.1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="snmp-port">SNMP Port</Label>
|
||||||
|
<Input
|
||||||
|
id="snmp-port"
|
||||||
|
value={snmpForm.snmp_port}
|
||||||
|
onChange={updateSnmp('snmp_port')}
|
||||||
|
placeholder="161"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label>Device Profile</Label>
|
||||||
|
<Select
|
||||||
|
value={snmpForm.snmp_profile_id}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setSnmpForm((f) => ({ ...f, snmp_profile_id: v }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Auto-detect (optional)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{snmpDeviceProfileList.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label htmlFor="snmp-hostname">Display Name</Label>
|
||||||
|
<Input
|
||||||
|
id="snmp-hostname"
|
||||||
|
value={snmpForm.hostname}
|
||||||
|
onChange={updateSnmp('hostname')}
|
||||||
|
placeholder="switch-01 (optional)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{statusBanner}
|
||||||
|
|
||||||
|
<DialogFooter className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowSnmpBulk(true)}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-text-secondary hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<List className="h-3.5 w-3.5" />
|
||||||
|
Add Multiple
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant="ghost" onClick={handleClose} size="sm">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" size="sm" disabled={snmpMutation.isPending}>
|
||||||
|
{snmpMutation.isPending ? 'Adding...' : 'Add Device'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
@@ -204,12 +553,27 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add Device</DialogTitle>
|
<DialogTitle>Add Device</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{vpnEnabled ? (
|
<Tabs defaultValue="routeros" className="mt-2">
|
||||||
<Tabs defaultValue="vpn" className="mt-2">
|
<TabsList className="w-full">
|
||||||
<TabsList className="w-full">
|
<TabsTrigger value="routeros" className="flex-1">
|
||||||
<TabsTrigger value="vpn" className="flex-1">VPN Onboarding</TabsTrigger>
|
RouterOS
|
||||||
<TabsTrigger value="direct" className="flex-1">Direct Connection</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
<TabsTrigger value="snmp" className="flex-1">
|
||||||
|
SNMP
|
||||||
|
</TabsTrigger>
|
||||||
|
{vpnEnabled && (
|
||||||
|
<TabsTrigger value="vpn" className="flex-1">
|
||||||
|
VPN
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="routeros" className="mt-4">
|
||||||
|
{routerosTab}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="snmp" className="mt-4">
|
||||||
|
{snmpTab}
|
||||||
|
</TabsContent>
|
||||||
|
{vpnEnabled && (
|
||||||
<TabsContent value="vpn" className="mt-4">
|
<TabsContent value="vpn" className="mt-4">
|
||||||
<VpnOnboardingWizard
|
<VpnOnboardingWizard
|
||||||
tenantId={tenantId}
|
tenantId={tenantId}
|
||||||
@@ -217,13 +581,8 @@ export function AddDeviceForm({ tenantId, open, onClose }: Props) {
|
|||||||
onCancel={handleClose}
|
onCancel={handleClose}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="direct" className="mt-4">
|
)}
|
||||||
{directConnectionForm}
|
</Tabs>
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
) : (
|
|
||||||
directConnectionForm
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user