diff --git a/frontend/src/components/fleet/BulkAddForm.tsx b/frontend/src/components/fleet/BulkAddForm.tsx new file mode 100644 index 0000000..b3f6473 --- /dev/null +++ b/frontend/src/components/fleet/BulkAddForm.tsx @@ -0,0 +1,352 @@ +import { useState, useMemo } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { CheckCircle2, XCircle, ArrowLeft } from 'lucide-react' +import { + credentialProfilesApi, + snmpProfilesApi, + devicesApi, + type BulkAddWithProfileRequest, + type BulkAddWithProfileResult, + type CredentialProfileResponse, + type SNMPProfileResponse, +} from '@/lib/api' +import { toast } from '@/components/ui/toast' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' + +interface BulkAddFormProps { + tenantId: string + deviceType: 'routeros' | 'snmp' + onClose: () => void + onBack?: () => void + onSuccess?: () => void +} + +/** + * Parse a newline-separated list of IP addresses. + * Each line is trimmed; blank lines and duplicates are removed. + * Lines that don't look like a valid IPv4 address are skipped. + */ +function parseIPList(text: string): string[] { + const ipv4Re = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/ + const seen = new Set() + const result: string[] = [] + + for (const raw of text.split('\n')) { + const line = raw.trim() + if (!line) continue + + // TODO: CIDR and range expansion (e.g., 10.0.1.0/24, 10.0.1.1-10.0.1.50) + if (line.includes('/') || line.includes('-')) continue + + if (ipv4Re.test(line) && !seen.has(line)) { + seen.add(line) + result.push(line) + } + } + + return result +} + +export function BulkAddForm({ + tenantId, + deviceType, + onClose, + onBack, + onSuccess, +}: BulkAddFormProps) { + const queryClient = useQueryClient() + + const [profileId, setProfileId] = useState('') + const [snmpProfileId, setSnmpProfileId] = useState('') + const [ipText, setIpText] = useState('') + const [hostnamePrefix, setHostnamePrefix] = useState('') + const [snmpPort, setSnmpPort] = useState('161') + const [apiPort, setApiPort] = useState('8728') + const [apiSslPort, setApiSslPort] = useState('8729') + const [error, setError] = useState(null) + + // Credential profiles filtered by device type + const credType = deviceType === 'snmp' ? 'snmp_v2c' : 'routeros' + const { data: profiles } = useQuery({ + queryKey: ['credential-profiles', tenantId, credType], + queryFn: () => credentialProfilesApi.list(tenantId, credType), + }) + + // SNMP device profiles (only when deviceType is snmp) + const { data: snmpProfiles } = useQuery({ + queryKey: ['snmp-profiles', tenantId], + queryFn: () => snmpProfilesApi.list(tenantId), + enabled: deviceType === 'snmp', + }) + + const profileList: CredentialProfileResponse[] = profiles?.profiles ?? [] + const snmpProfileList: SNMPProfileResponse[] = Array.isArray(snmpProfiles) + ? snmpProfiles + : snmpProfiles?.profiles ?? [] + + const parsedIPs = useMemo(() => parseIPList(ipText), [ipText]) + + const bulkMutation = useMutation({ + mutationFn: (data: BulkAddWithProfileRequest) => + devicesApi.bulkAddWithProfile(tenantId, data), + onSuccess: (result: BulkAddWithProfileResult) => { + void queryClient.invalidateQueries({ queryKey: ['devices'] }) + void queryClient.invalidateQueries({ queryKey: ['tenants'] }) + if (result.succeeded > 0) { + toast({ + title: `${result.succeeded} device${result.succeeded !== 1 ? 's' : ''} added`, + }) + } + onSuccess?.() + }, + }) + + const handleSubmit = () => { + if (parsedIPs.length === 0) { + setError('Enter at least one valid IP address') + return + } + if (!profileId) { + setError('Select a credential profile') + return + } + setError(null) + + const devices = parsedIPs.map((ip, i) => ({ + ip_address: ip, + hostname: hostnamePrefix + ? `${hostnamePrefix}${String(i + 1).padStart(2, '0')}` + : undefined, + })) + + const request: BulkAddWithProfileRequest = { + credential_profile_id: profileId, + device_type: deviceType, + defaults: + deviceType === 'snmp' + ? { + snmp_port: parseInt(snmpPort) || 161, + snmp_profile_id: snmpProfileId || undefined, + } + : { + api_port: parseInt(apiPort) || 8728, + api_ssl_port: parseInt(apiSslPort) || 8729, + }, + devices, + } + bulkMutation.mutate(request) + } + + // Show results after successful bulk add + if (bulkMutation.isSuccess && bulkMutation.data) { + const result = bulkMutation.data + return ( +
+
+ + {result.succeeded} succeeded + + {result.failed > 0 && ( + {result.failed} failed + )} + + of {result.total} total + +
+ +
+ {result.results.map((r) => ( +
+ {r.success ? ( + + ) : ( + + )} + {r.ip_address} + {r.hostname && ( + {r.hostname} + )} + {r.error && {r.error}} +
+ ))} +
+ +
+ +
+
+ ) + } + + return ( +
+ {onBack && ( + + )} + +
+
+ + +
+ + {deviceType === 'snmp' && ( + <> +
+ + setSnmpPort(e.target.value)} + placeholder="161" + type="number" + /> +
+ +
+ + +
+ + )} + + {deviceType === 'routeros' && ( + <> +
+ + setApiPort(e.target.value)} + placeholder="8728" + type="number" + /> +
+ +
+ + setApiSslPort(e.target.value)} + placeholder="8729" + type="number" + /> +
+ + )} + +
+ +