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
This commit is contained in:
@@ -49,13 +49,18 @@ export function FirewallPreview() {
|
||||
<input type="checkbox" checked={dryRun} onChange={(event) => setDryRun(event.target.checked)} />
|
||||
Dry run
|
||||
</label>
|
||||
<div className="rounded-md border border-border bg-canvas p-3 text-xs text-slate-500 dark:text-slate-400">
|
||||
{dryRun
|
||||
? "Simulation only. NexaFabric will generate the same provider rules, but nothing is written to Proxmox."
|
||||
: "Live apply. NexaFabric will send the generated rules to the selected write-enabled cluster."}
|
||||
</div>
|
||||
<button className={secondaryButtonClass} disabled={!selectedPolicyId} onClick={() => preview.mutate(selectedPolicyId)}>
|
||||
<Play size={18} />
|
||||
Generate Preview
|
||||
</button>
|
||||
<button className={buttonClass} disabled={!selectedPolicyId || apply.isPending} onClick={() => apply.mutate()}>
|
||||
<ShieldCheck size={18} />
|
||||
Apply Confirmed
|
||||
{dryRun ? "Run Dry Apply" : "Apply Confirmed"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { GitBranch, Play, Plus } from "lucide-react";
|
||||
import { GitBranch, Play, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { api, Policy, Project, ServiceCatalogItem } from "../api/client";
|
||||
import { DataTable } from "../components/DataTable";
|
||||
@@ -8,6 +8,19 @@ import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } fro
|
||||
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 ?? "")}`;
|
||||
}
|
||||
|
||||
export function Policies() {
|
||||
const queryClient = useQueryClient();
|
||||
const policies = useQuery({ queryKey: ["policies"], queryFn: () => api<Policy[]>("/policies") });
|
||||
@@ -57,6 +70,10 @@ export function Policies() {
|
||||
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();
|
||||
@@ -110,15 +127,31 @@ export function Policies() {
|
||||
</form>
|
||||
</Modal>
|
||||
<section className="space-y-4">
|
||||
<DataTable rows={(policies.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Policy" }, { key: "version", label: "Version" }, { key: "enabled", label: "Enabled" }]} />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(policies.data ?? []).map((policy) => (
|
||||
<div key={policy.id} className="flex gap-2">
|
||||
<button className={secondaryButtonClass} onClick={() => compile(policy)}><Play size={16} /> Compile {policy.name}</button>
|
||||
<button className={secondaryButtonClass} onClick={() => firewallPreview(policy)}><Play size={16} /> Preview</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<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: "version", label: "Version" },
|
||||
{
|
||||
key: "actions",
|
||||
label: "Actions",
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -19,7 +19,7 @@ export function PolicyDesigner() {
|
||||
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: "Designed Policy",
|
||||
name: "",
|
||||
source: "any",
|
||||
destination: "any",
|
||||
service_id: "",
|
||||
@@ -45,7 +45,7 @@ export function PolicyDesigner() {
|
||||
const service = services.data?.find((item) => item.id === form.service_id);
|
||||
return {
|
||||
project_id: null,
|
||||
name: form.name,
|
||||
name: form.name.trim(),
|
||||
enabled: true,
|
||||
definition: {
|
||||
source: form.source,
|
||||
@@ -95,6 +95,16 @@ export function PolicyDesigner() {
|
||||
<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>)}
|
||||
@@ -145,7 +155,7 @@ export function PolicyDesigner() {
|
||||
<Wand2 size={18} />
|
||||
Dry Run
|
||||
</button>
|
||||
<button className={buttonClass}>
|
||||
<button className={buttonClass} disabled={!form.name.trim() || save.isPending}>
|
||||
<Save size={18} />
|
||||
Save
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user