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:
2026-07-09 15:29:36 +02:00
parent 10e9406510
commit a16b56614c
4 changed files with 135 additions and 15 deletions
+64 -2
View File
@@ -67,6 +67,7 @@ from app.schemas.domain import (
SetupStatus, SetupStatus,
SubnetCreate, SubnetCreate,
SubnetRead, SubnetRead,
SubnetUpdate,
TenantCreate, TenantCreate,
TenantRead, TenantRead,
UserCreate, UserCreate,
@@ -246,6 +247,31 @@ def flow_int(value: object, default: int = 0) -> int:
return default return default
def subnet_label_for_ip(subnets: list[Subnet], value: str) -> str | None:
try:
address = ip_address(value)
except ValueError:
return None
matches: list[tuple[int, Subnet]] = []
for subnet in subnets:
try:
network = ip_network(subnet.cidr, strict=False)
except ValueError:
continue
if address in network:
matches.append((network.prefixlen, subnet))
if not matches:
return None
_, subnet = sorted(matches, key=lambda item: item[0], reverse=True)[0]
return f"internal ({subnet.cidr})"
def flow_endpoint_label(owner: Workload | None, subnets: list[Subnet], value: str) -> str:
if owner:
return owner.name
return subnet_label_for_ip(subnets, value) or "external"
def proxmox_action(action: str) -> str: def proxmox_action(action: str) -> str:
return {"allow": "ACCEPT", "deny": "DROP", "reject": "REJECT"}.get(action, "ACCEPT") return {"allow": "ACCEPT", "deny": "DROP", "reject": "REJECT"}.get(action, "ACCEPT")
@@ -868,6 +894,7 @@ def workload_insights(workload_id: str, _: CurrentUser, db: Session = Depends(ge
address.address: db.get(Workload, address.workload_id) address.address: db.get(Workload, address.workload_id)
for address in db.scalars(select(IpAddress).where(IpAddress.workload_id.is_not(None))).all() for address in db.scalars(select(IpAddress).where(IpAddress.workload_id.is_not(None))).all()
} }
known_subnets = db.scalars(select(Subnet).order_by(Subnet.cidr)).all()
traffic = [] traffic = []
if workload_ips: if workload_ips:
flows = db.scalars( flows = db.scalars(
@@ -881,8 +908,8 @@ def workload_insights(workload_id: str, _: CurrentUser, db: Session = Depends(ge
destination_owner = ip_owners.get(flow.destination_ip) destination_owner = ip_owners.get(flow.destination_ip)
traffic.append( traffic.append(
{ {
"source": source_owner.name if source_owner else "external", "source": flow_endpoint_label(source_owner, known_subnets, flow.source_ip),
"destination": destination_owner.name if destination_owner else "external", "destination": flow_endpoint_label(destination_owner, known_subnets, flow.destination_ip),
"source_ip": flow.source_ip, "source_ip": flow.source_ip,
"destination_ip": flow.destination_ip, "destination_ip": flow.destination_ip,
"protocol": flow.protocol, "protocol": flow.protocol,
@@ -972,6 +999,41 @@ def create_subnet(payload: SubnetCreate, user: CurrentUser, db: Session = Depend
return subnet return subnet
@api_router.patch("/ipam/subnets/{subnet_id}", response_model=SubnetRead)
def update_subnet(subnet_id: str, payload: SubnetUpdate, user: CurrentUser, db: Session = Depends(get_db)) -> Subnet:
subnet = db.get(Subnet, subnet_id)
if not subnet:
raise HTTPException(status_code=404, detail="Subnet not found")
changes = payload.model_dump(exclude_unset=True)
if "cidr" in changes and not changes["cidr"]:
raise HTTPException(status_code=400, detail="CIDR is required")
if "network_id" in changes and not changes["network_id"]:
raise HTTPException(status_code=400, detail="Network is required")
if "network_id" in changes and changes["network_id"] and not db.get(Network, changes["network_id"]):
raise HTTPException(status_code=404, detail="Network not found")
old_values = {
"network_id": subnet.network_id,
"cidr": subnet.cidr,
"gateway": subnet.gateway,
"dns": subnet.dns,
"dhcp_enabled": subnet.dhcp_enabled,
}
for key, value in changes.items():
setattr(subnet, key, value)
commit_or_400(db)
db.refresh(subnet)
write_audit(
db,
action="ipam.subnet.updated",
object_type="subnet",
object_id=subnet.id,
user_id=user.id,
old_values=old_values,
new_values=changes,
)
return subnet
@api_router.get("/ipam/addresses", response_model=list[IpAddressRead]) @api_router.get("/ipam/addresses", response_model=list[IpAddressRead])
def ipam_addresses(_: CurrentUser, db: Session = Depends(get_db)) -> list[dict]: def ipam_addresses(_: CurrentUser, db: Session = Depends(get_db)) -> list[dict]:
addresses = db.scalars(select(IpAddress).order_by(IpAddress.address)).all() addresses = db.scalars(select(IpAddress).order_by(IpAddress.address)).all()
+8
View File
@@ -113,6 +113,14 @@ class SubnetCreate(BaseModel):
dhcp_enabled: bool = False dhcp_enabled: bool = False
class SubnetUpdate(BaseModel):
network_id: str | None = None
cidr: str | None = None
gateway: str | None = None
dns: list[str] | None = None
dhcp_enabled: bool | None = None
class SecurityGroupCreate(BaseModel): class SecurityGroupCreate(BaseModel):
project_id: str | None = None project_id: str | None = None
name: str name: str
+1
View File
@@ -57,6 +57,7 @@ export type Subnet = {
network_id: string; network_id: string;
cidr: string; cidr: string;
gateway: string | null; gateway: string | null;
dns: string[];
dhcp_enabled: boolean; dhcp_enabled: boolean;
}; };
+62 -13
View File
@@ -1,34 +1,41 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { api, authorizedFetch, IpAddress, Network, Subnet } from "../api/client";
import { DataTable } from "../components/DataTable"; 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 { LoadingOverlay } from "../components/LoadingOverlay";
import { Modal } from "../components/Modal"; import { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader"; 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() { export function Ipam() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const networks = useQuery({ queryKey: ["networks"], queryFn: () => api<Network[]>("/networks") }); const networks = useQuery({ queryKey: ["networks"], queryFn: () => api<Network[]>("/networks") });
const subnets = useQuery({ queryKey: ["subnets"], queryFn: () => api<Subnet[]>("/ipam/subnets") }); const subnets = useQuery({ queryKey: ["subnets"], queryFn: () => api<Subnet[]>("/ipam/subnets") });
const addresses = useQuery({ queryKey: ["addresses"], queryFn: () => api<IpAddress[]>("/ipam/addresses") }); const addresses = useQuery({ queryKey: ["addresses"], queryFn: () => api<IpAddress[]>("/ipam/addresses") });
const [message, setMessage] = useState(""); 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 [ipForm, setIpForm] = useState({ subnet_id: "", address: "10.50.0.20", status: "reserved", note: "" });
const [subnetOpen, setSubnetOpen] = useState(false); const [subnetOpen, setSubnetOpen] = useState(false);
const [ipOpen, setIpOpen] = useState(false); const [ipOpen, setIpOpen] = useState(false);
const [busyMessage, setBusyMessage] = useState(""); const [busyMessage, setBusyMessage] = useState("");
const [editingSubnet, setEditingSubnet] = useState<Subnet | null>(null);
const createSubnet = useMutation({ const saveSubnet = useMutation({
mutationFn: () => api<Subnet>("/ipam/subnets", { method: "POST", body: JSON.stringify({ ...subnetForm, network_id: subnetForm.network_id || networks.data?.[0]?.id }) }), 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: () => { onSuccess: () => {
setMessage("Subnet created."); setMessage(editingSubnet ? "Subnet updated." : "Subnet created.");
setSubnetOpen(false); setSubnetOpen(false);
setEditingSubnet(null);
queryClient.invalidateQueries({ queryKey: ["subnets"] }); 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({ const createIp = useMutation({
mutationFn: () => api<IpAddress>("/ipam/addresses", { method: "POST", body: JSON.stringify({ ...ipForm, subnet_id: ipForm.subnet_id || subnets.data?.[0]?.id }) }), 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) { async function submitSubnet(event: FormEvent) {
event.preventDefault(); event.preventDefault();
await createSubnet.mutateAsync(); await saveSubnet.mutateAsync();
} }
async function submitIp(event: FormEvent) { 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 ( return (
<> <>
<PageHeader title="IPAM" subtitle="Manage subnets, reservations, assignments, conflicts, and export state." /> <PageHeader title="IPAM" subtitle="Manage subnets, reservations, assignments, conflicts, and export state." />
<LoadingOverlay open={Boolean(busyMessage)} message={busyMessage} /> <LoadingOverlay open={Boolean(busyMessage)} message={busyMessage} />
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap gap-2"> <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={buttonClass} onClick={() => setIpOpen(true)}><Plus size={16} /> Reserve IP</button>
<button className={secondaryButtonClass} onClick={discoverIpam}>Discover from Proxmox</button> <button className={secondaryButtonClass} onClick={discoverIpam}>Discover from Proxmox</button>
<button className={secondaryButtonClass} onClick={exportCsv}><Download size={16} /> Export CSV</button> <button className={secondaryButtonClass} onClick={exportCsv}><Download size={16} /> Export CSV</button>
</div> </div>
{message ? <div className="rounded-md border border-border bg-panel p-3 text-sm">{message}</div> : null} {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}> <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"> <div className="grid gap-3">
<Field label="Network"> <Field label="Network">
<select className={selectClass} value={subnetForm.network_id} onChange={(event) => setSubnetForm({ ...subnetForm, network_id: event.target.value })}> <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>
<Field label="CIDR"><input className={inputClass} value={subnetForm.cidr} onChange={(event) => setSubnetForm({ ...subnetForm, cidr: event.target.value })} /></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> <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> </div>
</form> </form>
</Modal> </Modal>
@@ -130,7 +160,26 @@ export function Ipam() {
</form> </form>
</Modal> </Modal>
<section className="space-y-4"> <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 <DataTable
rows={(addresses.data ?? []) as unknown as Record<string, unknown>[]} rows={(addresses.data ?? []) as unknown as Record<string, unknown>[]}
columns={[ columns={[