import { FormEvent, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Database, Download, Pencil, Plus, RefreshCcw } from "lucide-react"; import { api, authorizedFetch, IpAddress, Network, RuntimeSettings, Subnet } from "../api/client"; import { DataTable } from "../components/DataTable"; import { buttonClass, Field, iconButtonClass, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls"; import { LoadingOverlay } from "../components/LoadingOverlay"; import { Modal } from "../components/Modal"; import { PageHeader } from "../components/PageHeader"; const emptySubnetForm = { network_id: "", cidr: "10.50.0.0/24", gateway: "10.50.0.1", dns: ["10.50.0.10"], dhcp_enabled: false }; export function Ipam() { const queryClient = useQueryClient(); const networks = useQuery({ queryKey: ["networks"], queryFn: () => api("/networks") }); const subnets = useQuery({ queryKey: ["subnets"], queryFn: () => api("/ipam/subnets") }); const addresses = useQuery({ queryKey: ["addresses"], queryFn: () => api("/ipam/addresses") }); const settings = useQuery({ queryKey: ["settings"], queryFn: () => api("/settings") }); const [message, setMessage] = useState(""); const [subnetForm, setSubnetForm] = useState(emptySubnetForm); const [ipForm, setIpForm] = useState({ subnet_id: "", address: "10.50.0.20", status: "reserved", note: "" }); const [subnetOpen, setSubnetOpen] = useState(false); const [ipOpen, setIpOpen] = useState(false); const [busyMessage, setBusyMessage] = useState(""); const [editingSubnet, setEditingSubnet] = useState(null); const saveSubnet = useMutation({ mutationFn: () => api(editingSubnet ? `/ipam/subnets/${editingSubnet.id}` : "/ipam/subnets", { method: editingSubnet ? "PATCH" : "POST", body: JSON.stringify({ ...subnetForm, network_id: subnetForm.network_id || networks.data?.[0]?.id }), }), onSuccess: () => { setMessage(editingSubnet ? "Subnet updated." : "Subnet created."); setSubnetOpen(false); setEditingSubnet(null); queryClient.invalidateQueries({ queryKey: ["subnets"] }); }, onError: (error) => setMessage(error instanceof Error ? error.message : "Subnet save failed."), }); const createIp = useMutation({ mutationFn: () => api("/ipam/addresses", { method: "POST", body: JSON.stringify({ ...ipForm, subnet_id: ipForm.subnet_id || subnets.data?.[0]?.id }) }), onSuccess: () => { setMessage("IP address saved."); setIpOpen(false); queryClient.invalidateQueries({ queryKey: ["addresses"] }); }, onError: (error) => setMessage(error instanceof Error ? error.message : "IP reservation failed."), }); const toggleAutoDiscover = useMutation({ mutationFn: () => api("/settings", { method: "PATCH", body: JSON.stringify({ auto_ipam_sync_enabled: !settings.data?.auto_ipam_sync_enabled }), }), onSuccess: () => settings.refetch(), }); async function submitSubnet(event: FormEvent) { event.preventDefault(); await saveSubnet.mutateAsync(); } async function submitIp(event: FormEvent) { event.preventDefault(); await createIp.mutateAsync(); } async function exportCsv() { setBusyMessage("Preparing IPAM export..."); try { const response = await authorizedFetch("/ipam/export.csv"); const blob = await response.blob(); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "nexafabric-ipam.csv"; link.click(); URL.revokeObjectURL(url); } finally { setBusyMessage(""); } } async function discoverIpam() { setBusyMessage("Discovering IP addresses from Proxmox..."); try { const result = await api<{ imported: number; removed: number; errors: Array> }>("/ipam/discover", { method: "POST" }); setMessage(`Discovery imported ${result.imported} IP addresses and removed ${result.removed} container bridge entries${result.errors.length ? " with errors" : ""}.`); await queryClient.invalidateQueries({ queryKey: ["subnets"] }); await queryClient.invalidateQueries({ queryKey: ["addresses"] }); } catch (error) { setMessage(error instanceof Error ? error.message : "IPAM discovery failed."); } finally { setBusyMessage(""); } } function addSubnet() { setEditingSubnet(null); setSubnetForm(emptySubnetForm); setSubnetOpen(true); } function editSubnet(subnet: Subnet) { setEditingSubnet(subnet); setSubnetForm({ network_id: subnet.network_id, cidr: subnet.cidr, gateway: subnet.gateway ?? "", dns: subnet.dns ?? [], dhcp_enabled: subnet.dhcp_enabled, }); setSubnetOpen(true); } return ( <>
Automatic IPAM discovery
{settings.data?.auto_ipam_sync_enabled ? `Enabled every ${settings.data.auto_ipam_sync_interval_minutes} minutes` : "Disabled"}
{message ?
{message}
: null} setSubnetOpen(false)}>
{editingSubnet ? "Edit Subnet" : "Add Subnet"}
setSubnetForm({ ...subnetForm, cidr: event.target.value })} /> setSubnetForm({ ...subnetForm, gateway: event.target.value })} /> setSubnetForm({ ...subnetForm, dns: event.target.value.split(",").map((item) => item.trim()).filter(Boolean) })} />
setIpOpen(false)}>
Reserve IP
setIpForm({ ...ipForm, address: event.target.value })} /> setIpForm({ ...ipForm, note: event.target.value })} />
[]} columns={[ { key: "cidr", label: "Subnet" }, { key: "gateway", label: "Gateway" }, { key: "dhcp_enabled", label: "DHCP" }, { key: "actions", label: "Actions", render: (row) => { const subnet = row as unknown as Subnet; return (
); }, }, ]} /> []} columns={[ { key: "address", label: "Address" }, { key: "subnet_cidr", label: "Subnet" }, { key: "status", label: "Status" }, { key: "workload_name", label: "VM/LXC" }, { key: "workload_external_id", label: "VMID" }, { key: "note", label: "Note" }, ]} />
); }