feat: add cluster update/delete endpoints, expand tcp/udp protocol handling, and enhance cluster management UI
CI / backend (push) Failing after 3s
CI / frontend (push) Failing after 28s

Add PATCH /clusters/{cluster_id} endpoint with optional token update and audit logging, implement DELETE /clusters/{cluster_id} with cascading deletion of nodes, workloads, networks, subnets, and IP addresses, expand firewall rule generation to split tcp/udp protocol into separate tcp and udp rules for Proxmox compatibility, add ClusterUpdate schema with optional api_token field, include
This commit is contained in:
2026-07-09 14:04:17 +02:00
parent 1ada59d3c7
commit 88badf1f22
7 changed files with 270 additions and 79 deletions
+83 -30
View File
@@ -1,10 +1,10 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { GitBranch, Play, Plus, Trash2 } from "lucide-react";
import { Eye, GitBranch, Pencil, Play, Plus, Trash2 } from "lucide-react";
import { api, Policy, Project, ServiceCatalogItem } from "../api/client";
import { DataTable } from "../components/DataTable";
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
import { buttonClass, Field, iconButtonClass, inputClass, selectClass } from "../components/FormControls";
import { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader";
@@ -21,6 +21,21 @@ function policyService(policy: Policy) {
return `${String(value.protocol ?? "")}/${String(value.ports ?? "")}`;
}
const defaultPolicyForm = {
project_id: "",
name: "Web to DB",
source: "sg:Web Tier",
destination: "sg:Database",
service_id: "",
protocol: "tcp",
ports: "5432",
action: "allow",
direction: "egress",
enforcement_mode: "enforced",
logging: true,
description: "Allow application database traffic",
};
export function Policies() {
const queryClient = useQueryClient();
const policies = useQuery({ queryKey: ["policies"], queryFn: () => api<Policy[]>("/policies") });
@@ -28,26 +43,14 @@ export function Policies() {
const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api<ServiceCatalogItem[]>("/service-catalog") });
const [preview, setPreview] = useState("");
const [open, setOpen] = useState(false);
const [form, setForm] = useState({
project_id: "",
name: "Web to DB",
source: "sg:Web Tier",
destination: "sg:Database",
service_id: "",
protocol: "tcp",
ports: "5432",
action: "allow",
direction: "egress",
enforcement_mode: "enforced",
logging: true,
description: "Allow application database traffic",
});
const [editing, setEditing] = useState<Policy | null>(null);
const [form, setForm] = useState(defaultPolicyForm);
const create = useMutation({
const save = useMutation({
mutationFn: () => {
const service = services.data?.find((item) => item.id === form.service_id);
return api<Policy>("/policies", {
method: "POST",
return api<Policy>(editing ? `/policies/${editing.id}` : "/policies", {
method: editing ? "PATCH" : "POST",
body: JSON.stringify({
project_id: form.project_id || null,
name: form.name,
@@ -67,6 +70,7 @@ export function Policies() {
},
onSuccess: () => {
setOpen(false);
setEditing(null);
queryClient.invalidateQueries({ queryKey: ["policies"] });
},
});
@@ -77,7 +81,7 @@ export function Policies() {
async function submit(event: FormEvent) {
event.preventDefault();
await create.mutateAsync();
await save.mutateAsync();
}
async function compile(policy: Policy) {
@@ -91,14 +95,56 @@ export function Policies() {
setPreview(JSON.stringify(data, null, 2));
}
function addPolicy() {
setEditing(null);
setForm(defaultPolicyForm);
setOpen(true);
}
function editPolicy(policy: Policy) {
const service = policy.definition?.service as { protocol?: string; ports?: string } | undefined;
setEditing(policy);
setForm({
project_id: policy.project_id ?? "",
name: policy.name,
source: policyValue(policy, "source") || "any",
destination: policyValue(policy, "destination") || "any",
service_id: "",
protocol: service?.protocol ?? "tcp",
ports: service?.ports ?? "",
action: policyValue(policy, "action") || "allow",
direction: policyValue(policy, "direction") || "ingress",
enforcement_mode: policy.enforcement_mode || "enforced",
logging: Boolean(policy.definition?.logging),
description: policyValue(policy, "description"),
});
setOpen(true);
}
function chooseService(serviceId: string) {
const service = services.data?.find((item) => item.id === serviceId);
setForm({
...form,
service_id: serviceId,
protocol: service?.protocol ?? form.protocol,
ports: service?.ports ?? form.ports,
});
}
function deletePolicy(policy: Policy) {
if (window.confirm(`Delete policy "${policy.name}"?`)) {
remove.mutate(policy);
}
}
return (
<>
<PageHeader title="Policies" subtitle="Create versioned microsegmentation policies and compile firewall previews." />
<div className="space-y-4">
<button className={buttonClass} onClick={() => setOpen(true)}><Plus size={16} /> Add Policy</button>
<Modal title="Add Policy" open={open} onClose={() => setOpen(false)}>
<button className={buttonClass} onClick={addPolicy}><Plus size={16} /> Add Policy</button>
<Modal title={editing ? "Edit Policy" : "Add Policy"} open={open} onClose={() => setOpen(false)}>
<form onSubmit={submit}>
<div className="mb-4 flex items-center gap-2 font-medium"><GitBranch size={18} /> Add Policy</div>
<div className="mb-4 flex items-center gap-2 font-medium"><GitBranch size={18} /> {editing ? "Edit Policy" : "Add Policy"}</div>
<div className="grid gap-3">
<Field label="Project"><select className={selectClass} value={form.project_id} onChange={(event) => setForm({ ...form, project_id: event.target.value })}><option value="">Global</option>{(projects.data ?? []).map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}</select></Field>
<Field label="Name"><input className={inputClass} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field>
@@ -106,9 +152,15 @@ export function Policies() {
<Field label="Source"><input className={inputClass} value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })} /></Field>
<Field label="Destination"><input className={inputClass} value={form.destination} onChange={(event) => setForm({ ...form, destination: event.target.value })} /></Field>
</div>
<Field label="Service"><select className={selectClass} value={form.service_id} onChange={(event) => setForm({ ...form, service_id: event.target.value })}><option value="">Custom</option>{(services.data ?? []).map((service) => <option key={service.id} value={service.id}>{service.name} {service.ports}</option>)}</select></Field>
<Field label="Service"><select className={selectClass} value={form.service_id} onChange={(event) => chooseService(event.target.value)}><option value="">Custom</option>{(services.data ?? []).map((service) => <option key={service.id} value={service.id}>{service.name} {service.ports}</option>)}</select></Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Protocol"><input className={inputClass} value={form.protocol} onChange={(event) => setForm({ ...form, protocol: event.target.value })} /></Field>
<Field label="Protocol">
<select className={selectClass} value={form.protocol} onChange={(event) => setForm({ ...form, protocol: event.target.value })}>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
<option value="tcp/udp">tcp & udp</option>
</select>
</Field>
<Field label="Ports"><input className={inputClass} value={form.ports} onChange={(event) => setForm({ ...form, ports: event.target.value })} /></Field>
</div>
<div className="grid grid-cols-2 gap-3">
@@ -122,7 +174,7 @@ export function Policies() {
</select>
</Field>
<Field label="Description"><input className={inputClass} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Field>
<button className={buttonClass}><Plus size={16} /> Save Policy</button>
<button className={buttonClass}><Plus size={16} /> {editing ? "Update Policy" : "Save Policy"}</button>
</div>
</form>
</Modal>
@@ -142,10 +194,11 @@ export function Policies() {
render: (row) => {
const policy = row as unknown as Policy;
return (
<div className="flex flex-wrap gap-2">
<button className={secondaryButtonClass} onClick={() => compile(policy)}><Play size={16} /> Compile</button>
<button className={secondaryButtonClass} onClick={() => firewallPreview(policy)}><Play size={16} /> Preview</button>
<button className={secondaryButtonClass} disabled={remove.isPending} onClick={() => remove.mutate(policy)}><Trash2 size={16} /> Delete</button>
<div className="flex justify-end gap-2">
<button className={iconButtonClass} title="Compile policy" aria-label={`Compile ${policy.name}`} onClick={() => compile(policy)}><Play size={16} /></button>
<button className={iconButtonClass} title="Preview policy" aria-label={`Preview ${policy.name}`} onClick={() => firewallPreview(policy)}><Eye size={16} /></button>
<button className={iconButtonClass} title="Edit policy" aria-label={`Edit ${policy.name}`} onClick={() => editPolicy(policy)}><Pencil size={16} /></button>
<button className={iconButtonClass} title="Delete policy" aria-label={`Delete ${policy.name}`} disabled={remove.isPending} onClick={() => deletePolicy(policy)}><Trash2 size={16} /></button>
</div>
);
},