Files
NexaFabric/frontend/src/pages/Policies.tsx
T
nessi 1802c2cbee feat: add policy deployment status tracking with cluster-level rule state monitoring and live firewall rule version comparison
Add policy_read_payload helper to build policy response with deployment status, implement nexafabric_rule_version to parse policy ID and version from rule comments, add policy_deployment_status to check active/stale/partial/unresolved states by comparing expected rules from preview against live firewall rules per cluster with version matching, extend PolicyRead schema with
2026-07-09 19:52:10 +02:00

282 lines
13 KiB
TypeScript

import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, CheckCircle2, Clock3, Eye, GitBranch, Pencil, Play, Plus, Shield, Trash2 } from "lucide-react";
import { api, Policy, Project, ServiceCatalogItem } from "../api/client";
import { DataTable } from "../components/DataTable";
import { buttonClass, Field, iconButtonClass, inputClass, selectClass } from "../components/FormControls";
import { LoadingOverlay } from "../components/LoadingOverlay";
import { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader";
function policyValue(policy: Policy, key: string) {
return String(policy.definition?.[key] ?? "");
}
function policyService(policy: Policy) {
const service = policy.definition?.service;
if (!service || typeof service !== "object") {
return "";
}
const value = service as { protocol?: unknown; ports?: unknown };
return `${String(value.protocol ?? "")}/${String(value.ports ?? "")}`;
}
function policyStatus(policy: Policy) {
const status = policy.deployment_status ?? {};
return {
state: String(status.state ?? "unknown"),
label: String(status.label ?? "Unknown"),
expectedRules: Number(status.expected_rules ?? 0),
activeRules: Number(status.active_rules ?? 0),
staleRules: Number(status.stale_rules ?? 0),
};
}
function statusClass(state: string) {
if (state === "active") {
return "border-accent/40 bg-accent/10 text-accent";
}
if (state === "audit") {
return "border-amber-400/40 bg-amber-400/10 text-amber-300";
}
if (["stale", "partial", "unresolved"].includes(state)) {
return "border-danger/40 bg-danger/10 text-danger";
}
return "border-border bg-canvas text-slate-500";
}
function StatusIcon({ state }: { state: string }) {
if (state === "active") {
return <CheckCircle2 size={15} />;
}
if (state === "audit") {
return <Shield size={15} />;
}
if (["stale", "partial", "unresolved"].includes(state)) {
return <AlertTriangle size={15} />;
}
return <Clock3 size={15} />;
}
function PolicyDeploymentStatus({ policy }: { policy: Policy }) {
const status = policyStatus(policy);
const detail =
status.state === "audit"
? "No live firewall write"
: `${status.activeRules}/${status.expectedRules} active${status.staleRules ? ` · ${status.staleRules} stale` : ""}`;
return (
<div className="flex flex-col gap-1">
<span className={`inline-flex w-fit items-center gap-1.5 rounded-md border px-2 py-1 text-xs ${statusClass(status.state)}`}>
<StatusIcon state={status.state} />
{status.label}
</span>
<span className="text-xs text-slate-500">{detail}</span>
</div>
);
}
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") });
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api<ServiceCatalogItem[]>("/service-catalog") });
const [preview, setPreview] = useState("");
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<Policy | null>(null);
const [form, setForm] = useState(defaultPolicyForm);
const [busyMessage, setBusyMessage] = useState("");
const save = useMutation({
mutationFn: () => {
const service = services.data?.find((item) => item.id === form.service_id);
return api<Policy>(editing ? `/policies/${editing.id}` : "/policies", {
method: editing ? "PATCH" : "POST",
body: JSON.stringify({
project_id: form.project_id || null,
name: form.name,
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,
},
}),
});
},
onSuccess: () => {
setOpen(false);
setEditing(null);
queryClient.invalidateQueries({ queryKey: ["policies"] });
},
});
const remove = useMutation({
mutationFn: (policy: Policy) => api(`/policies/${policy.id}`, { method: "DELETE" }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["policies"] }),
});
async function submit(event: FormEvent) {
event.preventDefault();
await save.mutateAsync();
}
async function compile(policy: Policy) {
setBusyMessage(`Compiling ${policy.name}...`);
try {
const data = await api<Policy>(`/policies/${policy.id}/compile`, { method: "POST" });
setPreview(JSON.stringify(data.last_compiled, null, 2));
await queryClient.invalidateQueries({ queryKey: ["policies"] });
} finally {
setBusyMessage("");
}
}
async function firewallPreview(policy: Policy) {
setBusyMessage(`Generating preview for ${policy.name}...`);
try {
const data = await api<Record<string, unknown>>(`/firewall/preview/${policy.id}`, { method: "POST" });
setPreview(JSON.stringify(data, null, 2));
} finally {
setBusyMessage("");
}
}
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." />
<LoadingOverlay open={Boolean(busyMessage)} message={busyMessage} />
<div className="space-y-4">
<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} /> {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>
<div className="grid grid-cols-2 gap-3">
<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) => 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">
<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">
<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="Direction"><select className={selectClass} value={form.direction} onChange={(event) => setForm({ ...form, direction: event.target.value })}><option>ingress</option><option>egress</option></select></Field>
</div>
<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>
<Field label="Description"><input className={inputClass} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Field>
<button className={buttonClass}><Plus size={16} /> {editing ? "Update Policy" : "Save Policy"}</button>
</div>
</form>
</Modal>
<section className="space-y-4">
<DataTable
rows={(policies.data ?? []) as unknown as Record<string, unknown>[]}
columns={[
{ key: "name", label: "Policy" },
{ key: "source", label: "Source", render: (row) => policyValue(row as unknown as Policy, "source") },
{ key: "destination", label: "Destination", render: (row) => policyValue(row as unknown as Policy, "destination") },
{ key: "service", label: "Service", render: (row) => policyService(row as unknown as Policy) },
{ key: "enforcement_mode", label: "Mode" },
{ key: "deployment_status", label: "Status", render: (row) => <PolicyDeploymentStatus policy={row as unknown as Policy} /> },
{ key: "version", label: "Version" },
{
key: "actions",
label: "Actions",
render: (row) => {
const policy = row as unknown as Policy;
return (
<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>
);
},
},
]}
/>
<pre className="min-h-40 overflow-auto rounded-md border border-border bg-panel p-3 text-xs">{preview || "No policy output yet."}</pre>
</section>
</div>
</>
);
}