From a16b56614cf9c4350338288f18f5695d18ddcfc6 Mon Sep 17 00:00:00 2001 From: nessi Date: Thu, 9 Jul 2026 15:29:36 +0200 Subject: [PATCH] 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 --- backend/app/api/v1/router.py | 66 +++++++++++++++++++++++++++++- backend/app/schemas/domain.py | 8 ++++ frontend/src/api/client.ts | 1 + frontend/src/pages/Ipam.tsx | 75 +++++++++++++++++++++++++++++------ 4 files changed, 135 insertions(+), 15 deletions(-) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 2c23a08..3bee678 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -67,6 +67,7 @@ from app.schemas.domain import ( SetupStatus, SubnetCreate, SubnetRead, + SubnetUpdate, TenantCreate, TenantRead, UserCreate, @@ -246,6 +247,31 @@ def flow_int(value: object, default: int = 0) -> int: 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: 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) 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 = [] if workload_ips: 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) traffic.append( { - "source": source_owner.name if source_owner else "external", - "destination": destination_owner.name if destination_owner else "external", + "source": flow_endpoint_label(source_owner, known_subnets, flow.source_ip), + "destination": flow_endpoint_label(destination_owner, known_subnets, flow.destination_ip), "source_ip": flow.source_ip, "destination_ip": flow.destination_ip, "protocol": flow.protocol, @@ -972,6 +999,41 @@ def create_subnet(payload: SubnetCreate, user: CurrentUser, db: Session = Depend 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]) def ipam_addresses(_: CurrentUser, db: Session = Depends(get_db)) -> list[dict]: addresses = db.scalars(select(IpAddress).order_by(IpAddress.address)).all() diff --git a/backend/app/schemas/domain.py b/backend/app/schemas/domain.py index 5876780..bf5c389 100644 --- a/backend/app/schemas/domain.py +++ b/backend/app/schemas/domain.py @@ -113,6 +113,14 @@ class SubnetCreate(BaseModel): 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): project_id: str | None = None name: str diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1c47999..7f9a6e0 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -57,6 +57,7 @@ export type Subnet = { network_id: string; cidr: string; gateway: string | null; + dns: string[]; dhcp_enabled: boolean; }; diff --git a/frontend/src/pages/Ipam.tsx b/frontend/src/pages/Ipam.tsx index e5f0068..70bc8d8 100644 --- a/frontend/src/pages/Ipam.tsx +++ b/frontend/src/pages/Ipam.tsx @@ -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("/networks") }); const subnets = useQuery({ queryKey: ["subnets"], queryFn: () => api("/ipam/subnets") }); const addresses = useQuery({ queryKey: ["addresses"], queryFn: () => api("/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(null); - const createSubnet = useMutation({ - mutationFn: () => api("/ipam/subnets", { method: "POST", body: JSON.stringify({ ...subnetForm, network_id: subnetForm.network_id || networks.data?.[0]?.id }) }), + 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("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("/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 ( <>
- +
{message ?
{message}
: null} - setSubnetOpen(false)}> + setSubnetOpen(false)}>
-
Add Subnet
+
{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) })} /> + +
@@ -130,7 +160,26 @@ export function Ipam() {
- []} columns={[{ key: "cidr", label: "Subnet" }, { key: "gateway", label: "Gateway" }, { key: "dhcp_enabled", label: "DHCP" }]} /> + []} + 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={[