feat: add cluster update/delete endpoints, expand tcp/udp protocol handling, and enhance cluster management UI
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:
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user