Files
NexaFabric/frontend/src/pages/PolicyDesigner.tsx
T
nessi 7fa4bcaad1
CI / backend (push) Failing after 3s
CI / frontend (push) Failing after 32s
feat: add policy deletion, improve dry run handling, and enhance policy designer UX
Add DELETE /policies/{policy_id} endpoint with audit logging, improve firewall apply to handle dry run mode without calling provider and track operation success separately from applied status, update Proxmox provider error message to clarify rule-to-VM mapping requirement, add dry run explanation text to FirewallPreview with conditional button labels, enhance Policies page with expanded DataTable columns showing source
2026-07-09 13:32:35 +02:00

174 lines
7.8 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() {
const service = services.data?.find((item) => item.id === form.service_id);
return {
project_id: null,
name: form.name.trim(),
enabled: true,
definition: {
source: form.source,
destination: form.destination,
service: { protocol: service?.protocol ?? form.protocol, ports: service?.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,
});
}
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) => 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="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"><input className={inputClass} value={form.protocol} onChange={(event) => setForm({ ...form, protocol: event.target.value })} /></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>
</>
);
}