feat: add subnet edit functionality with internal subnet labeling in workload traffic insights
Add SubnetUpdate schema with optional fields for PATCH operations, implement update_subnet endpoint with validation and audit logging, add subnet_label_for_ip helper to match IPs against known subnets using longest prefix matching, update flow_endpoint_label to show "internal (CIDR)" for traffic within known subnets instead of "external", add DNS servers and DHCP toggle to subnet form UI, implement edit
This commit is contained in:
+62
-13
@@ -1,34 +1,41 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Database, Download, Plus } from "lucide-react";
|
||||
import { Database, Download, Pencil, Plus } from "lucide-react";
|
||||
|
||||
import { api, authorizedFetch, IpAddress, Network, Subnet } from "../api/client";
|
||||
import { DataTable } from "../components/DataTable";
|
||||
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
||||
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<Network[]>("/networks") });
|
||||
const subnets = useQuery({ queryKey: ["subnets"], queryFn: () => api<Subnet[]>("/ipam/subnets") });
|
||||
const addresses = useQuery({ queryKey: ["addresses"], queryFn: () => api<IpAddress[]>("/ipam/addresses") });
|
||||
const [message, setMessage] = useState("");
|
||||
const [subnetForm, setSubnetForm] = useState({ network_id: "", cidr: "10.50.0.0/24", gateway: "10.50.0.1", dns: ["10.50.0.10"], dhcp_enabled: false });
|
||||
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<Subnet | null>(null);
|
||||
|
||||
const createSubnet = useMutation({
|
||||
mutationFn: () => api<Subnet>("/ipam/subnets", { method: "POST", body: JSON.stringify({ ...subnetForm, network_id: subnetForm.network_id || networks.data?.[0]?.id }) }),
|
||||
const saveSubnet = useMutation({
|
||||
mutationFn: () => api<Subnet>(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("Subnet created.");
|
||||
setMessage(editingSubnet ? "Subnet updated." : "Subnet created.");
|
||||
setSubnetOpen(false);
|
||||
setEditingSubnet(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["subnets"] });
|
||||
},
|
||||
onError: (error) => setMessage(error instanceof Error ? error.message : "Subnet creation failed."),
|
||||
onError: (error) => setMessage(error instanceof Error ? error.message : "Subnet save failed."),
|
||||
});
|
||||
const createIp = useMutation({
|
||||
mutationFn: () => api<IpAddress>("/ipam/addresses", { method: "POST", body: JSON.stringify({ ...ipForm, subnet_id: ipForm.subnet_id || subnets.data?.[0]?.id }) }),
|
||||
@@ -42,7 +49,7 @@ export function Ipam() {
|
||||
|
||||
async function submitSubnet(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await createSubnet.mutateAsync();
|
||||
await saveSubnet.mutateAsync();
|
||||
}
|
||||
|
||||
async function submitIp(event: FormEvent) {
|
||||
@@ -80,21 +87,39 @@ export function Ipam() {
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<PageHeader title="IPAM" subtitle="Manage subnets, reservations, assignments, conflicts, and export state." />
|
||||
<LoadingOverlay open={Boolean(busyMessage)} message={busyMessage} />
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className={buttonClass} onClick={() => setSubnetOpen(true)}><Plus size={16} /> Add Subnet</button>
|
||||
<button className={buttonClass} onClick={addSubnet}><Plus size={16} /> Add Subnet</button>
|
||||
<button className={buttonClass} onClick={() => setIpOpen(true)}><Plus size={16} /> Reserve IP</button>
|
||||
<button className={secondaryButtonClass} onClick={discoverIpam}>Discover from Proxmox</button>
|
||||
<button className={secondaryButtonClass} onClick={exportCsv}><Download size={16} /> Export CSV</button>
|
||||
</div>
|
||||
{message ? <div className="rounded-md border border-border bg-panel p-3 text-sm">{message}</div> : null}
|
||||
<Modal title="Add Subnet" open={subnetOpen} onClose={() => setSubnetOpen(false)}>
|
||||
<Modal title={editingSubnet ? "Edit Subnet" : "Add Subnet"} open={subnetOpen} onClose={() => setSubnetOpen(false)}>
|
||||
<form onSubmit={submitSubnet}>
|
||||
<div className="mb-4 flex items-center gap-2 font-medium"><Database size={18} /> Add Subnet</div>
|
||||
<div className="mb-4 flex items-center gap-2 font-medium"><Database size={18} /> {editingSubnet ? "Edit Subnet" : "Add Subnet"}</div>
|
||||
<div className="grid gap-3">
|
||||
<Field label="Network">
|
||||
<select className={selectClass} value={subnetForm.network_id} onChange={(event) => setSubnetForm({ ...subnetForm, network_id: event.target.value })}>
|
||||
@@ -104,7 +129,12 @@ export function Ipam() {
|
||||
</Field>
|
||||
<Field label="CIDR"><input className={inputClass} value={subnetForm.cidr} onChange={(event) => setSubnetForm({ ...subnetForm, cidr: event.target.value })} /></Field>
|
||||
<Field label="Gateway"><input className={inputClass} value={subnetForm.gateway} onChange={(event) => setSubnetForm({ ...subnetForm, gateway: event.target.value })} /></Field>
|
||||
<button className={buttonClass}><Plus size={16} /> Add Subnet</button>
|
||||
<Field label="DNS Servers"><input className={inputClass} value={subnetForm.dns.join(", ")} onChange={(event) => setSubnetForm({ ...subnetForm, dns: event.target.value.split(",").map((item) => item.trim()).filter(Boolean) })} /></Field>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={subnetForm.dhcp_enabled} onChange={(event) => setSubnetForm({ ...subnetForm, dhcp_enabled: event.target.checked })} />
|
||||
DHCP enabled
|
||||
</label>
|
||||
<button className={buttonClass} disabled={saveSubnet.isPending}><Plus size={16} /> {editingSubnet ? "Update Subnet" : "Add Subnet"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
@@ -130,7 +160,26 @@ export function Ipam() {
|
||||
</form>
|
||||
</Modal>
|
||||
<section className="space-y-4">
|
||||
<DataTable rows={(subnets.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "cidr", label: "Subnet" }, { key: "gateway", label: "Gateway" }, { key: "dhcp_enabled", label: "DHCP" }]} />
|
||||
<DataTable
|
||||
rows={(subnets.data ?? []) as unknown as Record<string, unknown>[]}
|
||||
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 (
|
||||
<div className="flex justify-end">
|
||||
<button className={iconButtonClass} title="Edit subnet" aria-label={`Edit subnet ${subnet.cidr}`} onClick={() => editSubnet(subnet)}><Pencil size={16} /></button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<DataTable
|
||||
rows={(addresses.data ?? []) as unknown as Record<string, unknown>[]}
|
||||
columns={[
|
||||
|
||||
Reference in New Issue
Block a user