feat: add IPAM discovery from Proxmox with IP enrichment, improve workload insights, and enhance DataTable interactivity
Add /ipam/discover endpoint to automatically import IP addresses from Proxmox clusters with error tracking and audit logging, implement ensure_discovered_network helper to create "discovered-ipam" network for auto-discovered IPs, add import_discovered_ips function to parse IP interfaces and create subnet/address records with assignment tracking, enhance ProxmoxProvider.enrich_work
This commit is contained in:
@@ -1,48 +1,163 @@
|
||||
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: "Designed Policy",
|
||||
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,
|
||||
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_360px]">
|
||||
<section className="rounded-md border border-border bg-panel p-4">
|
||||
<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">
|
||||
{["Source", "Destination", "Service", "Action", "Direction", "Logging"].map((label) => (
|
||||
<label key={label} className="text-sm">
|
||||
{label}
|
||||
<select className="mt-1 h-10 w-full rounded-md border border-border bg-transparent px-3">
|
||||
<option>{label === "Action" ? "allow" : label === "Direction" ? "ingress" : "Any"}</option>
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
<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>
|
||||
<label className="mt-4 block text-sm">
|
||||
Description
|
||||
<input className="mt-1 h-10 w-full rounded-md border border-border bg-transparent px-3" placeholder="Policy intent" />
|
||||
<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="inline-flex h-10 items-center gap-2 rounded-md border border-border px-4 text-sm">
|
||||
<button className={secondaryButtonClass} type="button" onClick={dryRun}>
|
||||
<Wand2 size={18} />
|
||||
Dry Run
|
||||
</button>
|
||||
<button className="inline-flex h-10 items-center gap-2 rounded-md bg-accent px-4 text-sm text-white">
|
||||
<button className={buttonClass}>
|
||||
<Save size={18} />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
<aside className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-3 font-medium">Impact Preview</div>
|
||||
<div className="space-y-3 text-sm text-slate-600 dark:text-slate-300">
|
||||
<div className="rounded-md border border-border p-3">Affected VMs: calculated after dry run</div>
|
||||
<div className="rounded-md border border-border p-3">Conflicts: none detected in draft</div>
|
||||
<div className="rounded-md border border-border p-3">Generated rules: preview required before apply</div>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user