Files
NexaFabric/frontend/src/pages/PolicyDesigner.tsx
T
nessi 88badf1f22
CI / backend (push) Failing after 3s
CI / frontend (push) Failing after 28s
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
2026-07-09 14:04:17 +02:00

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>
</>
);
}