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
189 lines
8.2 KiB
TypeScript
189 lines
8.2 KiB
TypeScript
import { FormEvent, useMemo, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Save, Wand2 } from "lucide-react";
|
|
|
|
import { api, Network, Policy, SecurityGroup, ServiceCatalogItem, Workload } from "../api/client";
|
|
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
|
import { PageHeader } from "../components/PageHeader";
|
|
|
|
type TargetOption = {
|
|
label: string;
|
|
value: string;
|
|
};
|
|
|
|
export function PolicyDesigner() {
|
|
const queryClient = useQueryClient();
|
|
const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") });
|
|
const securityGroups = useQuery({ queryKey: ["security-groups"], queryFn: () => api<SecurityGroup[]>("/security-groups") });
|
|
const networks = useQuery({ queryKey: ["networks"], queryFn: () => api<Network[]>("/networks") });
|
|
const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api<ServiceCatalogItem[]>("/service-catalog") });
|
|
const [preview, setPreview] = useState<Record<string, unknown> | null>(null);
|
|
const [form, setForm] = useState({
|
|
name: "",
|
|
source: "any",
|
|
destination: "any",
|
|
service_id: "",
|
|
protocol: "tcp",
|
|
ports: "443",
|
|
action: "allow",
|
|
direction: "ingress",
|
|
enforcement_mode: "enforced",
|
|
logging: true,
|
|
description: "Policy intent",
|
|
});
|
|
|
|
const targets = useMemo<TargetOption[]>(() => {
|
|
return [
|
|
{ label: "Any", value: "any" },
|
|
...(workloads.data ?? []).map((workload) => ({ label: `VM/LXC: ${workload.name}`, value: `workload:${workload.id}` })),
|
|
...(securityGroups.data ?? []).map((group) => ({ label: `Security Group: ${group.name}`, value: `sg:${group.name}` })),
|
|
...(networks.data ?? []).map((network) => ({ label: `Network: ${network.name}`, value: `network:${network.name}` })),
|
|
];
|
|
}, [networks.data, securityGroups.data, workloads.data]);
|
|
|
|
function payload() {
|
|
return {
|
|
project_id: null,
|
|
name: form.name.trim(),
|
|
enabled: true,
|
|
definition: {
|
|
source: form.source,
|
|
destination: form.destination,
|
|
service: { protocol: form.protocol, ports: form.ports },
|
|
action: form.action,
|
|
direction: form.direction,
|
|
enforcement_mode: form.enforcement_mode,
|
|
logging: form.logging,
|
|
description: form.description,
|
|
},
|
|
};
|
|
}
|
|
|
|
const save = useMutation({
|
|
mutationFn: () => api<Policy>("/policies", { method: "POST", body: JSON.stringify(payload()) }),
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["policies"] }),
|
|
});
|
|
|
|
async function submit(event: FormEvent) {
|
|
event.preventDefault();
|
|
await save.mutateAsync();
|
|
}
|
|
|
|
function dryRun() {
|
|
const current = payload();
|
|
const warnings = [];
|
|
if (current.definition.source === "any" && current.definition.destination === "any") {
|
|
warnings.push("Policy targets all sources and destinations.");
|
|
}
|
|
if (current.definition.enforcement_mode === "audit") {
|
|
warnings.push("Audit mode logs decisions without enforcing them.");
|
|
}
|
|
setPreview({
|
|
affected_workloads: (workloads.data ?? []).filter((workload) =>
|
|
[current.definition.source, current.definition.destination].includes(`workload:${workload.id}`),
|
|
),
|
|
generated_rule: current.definition,
|
|
conflicts: [],
|
|
warnings,
|
|
});
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<PageHeader title="Policy Designer" subtitle="Build tenant-aware microsegmentation policies and inspect their intended impact." />
|
|
<div className="grid gap-4 lg:grid-cols-[1fr_420px]">
|
|
<form onSubmit={submit} className="rounded-md border border-border bg-panel p-4">
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<Field label="Policy Name">
|
|
<input
|
|
className={inputClass}
|
|
value={form.name}
|
|
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
|
placeholder="DNS from VMs to resolver"
|
|
required
|
|
/>
|
|
</Field>
|
|
<div />
|
|
<Field label="Source">
|
|
<select className={selectClass} value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })}>
|
|
{targets.map((target) => <option key={target.value} value={target.value}>{target.label}</option>)}
|
|
</select>
|
|
</Field>
|
|
<Field label="Destination">
|
|
<select className={selectClass} value={form.destination} onChange={(event) => setForm({ ...form, destination: event.target.value })}>
|
|
{targets.map((target) => <option key={target.value} value={target.value}>{target.label}</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>
|
|
<Field label="Action">
|
|
<select className={selectClass} value={form.action} onChange={(event) => setForm({ ...form, action: event.target.value })}>
|
|
<option>allow</option>
|
|
<option>deny</option>
|
|
<option>reject</option>
|
|
</select>
|
|
</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>
|
|
<Field label="Direction">
|
|
<select className={selectClass} value={form.direction} onChange={(event) => setForm({ ...form, direction: event.target.value })}>
|
|
<option>ingress</option>
|
|
<option>egress</option>
|
|
</select>
|
|
</Field>
|
|
<Field label="Mode">
|
|
<select className={selectClass} value={form.enforcement_mode} onChange={(event) => setForm({ ...form, enforcement_mode: event.target.value })}>
|
|
<option value="enforced">enforced</option>
|
|
<option value="audit">audit</option>
|
|
</select>
|
|
</Field>
|
|
</div>
|
|
<Field label="Description">
|
|
<input className={inputClass} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
|
|
</Field>
|
|
<label className="mt-4 flex items-center gap-2 text-sm">
|
|
<input type="checkbox" checked={form.logging} onChange={(event) => setForm({ ...form, logging: event.target.checked })} />
|
|
Logging enabled
|
|
</label>
|
|
<div className="mt-5 flex gap-3">
|
|
<button className={secondaryButtonClass} type="button" onClick={dryRun}>
|
|
<Wand2 size={18} />
|
|
Dry Run
|
|
</button>
|
|
<button className={buttonClass} disabled={!form.name.trim() || save.isPending}>
|
|
<Save size={18} />
|
|
Save
|
|
</button>
|
|
</div>
|
|
</form>
|
|
<aside className="rounded-md border border-border bg-panel p-4">
|
|
<div className="mb-3 font-medium">Impact Preview</div>
|
|
<pre className="max-h-[520px] overflow-auto rounded-md border border-border bg-canvas p-3 text-xs">
|
|
{preview ? JSON.stringify(preview, null, 2) : "Run a dry run to calculate affected workloads, warnings, and generated rules."}
|
|
</pre>
|
|
</aside>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|