feat: add IPAM discovery from Proxmox with IP enrichment, improve workload insights, and enhance DataTable interactivity
CI / backend (push) Failing after 3s
CI / frontend (push) Failing after 29s

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:
2026-07-09 13:14:01 +02:00
parent 4554b00b73
commit 150a69b60b
8 changed files with 368 additions and 70 deletions
+2 -3
View File
@@ -44,15 +44,14 @@ export function Dashboard() {
</section>
<section className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 font-medium">Top Talkers</div>
{(data?.top_talkers ?? []).map((item) => (
{(data?.top_talkers ?? []).length ? (data?.top_talkers ?? []).map((item) => (
<div key={item.name} className="flex justify-between border-t border-border py-3 text-sm">
<span>{item.name}</span>
<span>{Math.round(item.bytes / 1_000_000)} MB</span>
</div>
))}
)) : <div className="border-t border-border py-3 text-sm text-slate-500">No flow telemetry collected yet.</div>}
</section>
</div>
</>
);
}
+19
View File
@@ -13,6 +13,7 @@ export function Ipam() {
const networks = useQuery({ queryKey: ["networks"], queryFn: () => api<Network[]>("/networks") });
const subnets = useQuery({ queryKey: ["subnets"], queryFn: () => api<Subnet[]>("/ipam/subnets") });
const addresses = useQuery({ queryKey: ["addresses"], queryFn: () => api<IpAddress[]>("/ipam/addresses") });
const [message, setMessage] = useState("");
const [subnetForm, setSubnetForm] = useState({ network_id: "", cidr: "10.50.0.0/24", gateway: "10.50.0.1", dns: ["10.50.0.10"], dhcp_enabled: false });
const [ipForm, setIpForm] = useState({ subnet_id: "", address: "10.50.0.20", status: "reserved", note: "" });
const [subnetOpen, setSubnetOpen] = useState(false);
@@ -21,16 +22,20 @@ export function Ipam() {
const createSubnet = useMutation({
mutationFn: () => api<Subnet>("/ipam/subnets", { method: "POST", body: JSON.stringify({ ...subnetForm, network_id: subnetForm.network_id || networks.data?.[0]?.id }) }),
onSuccess: () => {
setMessage("Subnet created.");
setSubnetOpen(false);
queryClient.invalidateQueries({ queryKey: ["subnets"] });
},
onError: (error) => setMessage(error instanceof Error ? error.message : "Subnet creation failed."),
});
const createIp = useMutation({
mutationFn: () => api<IpAddress>("/ipam/addresses", { method: "POST", body: JSON.stringify({ ...ipForm, subnet_id: ipForm.subnet_id || subnets.data?.[0]?.id }) }),
onSuccess: () => {
setMessage("IP address saved.");
setIpOpen(false);
queryClient.invalidateQueries({ queryKey: ["addresses"] });
},
onError: (error) => setMessage(error instanceof Error ? error.message : "IP reservation failed."),
});
async function submitSubnet(event: FormEvent) {
@@ -56,6 +61,17 @@ export function Ipam() {
URL.revokeObjectURL(url);
}
async function discoverIpam() {
try {
const result = await api<{ imported: number; errors: Array<Record<string, unknown>> }>("/ipam/discover", { method: "POST" });
setMessage(`Discovery imported ${result.imported} IP addresses${result.errors.length ? " with errors" : ""}.`);
await queryClient.invalidateQueries({ queryKey: ["subnets"] });
await queryClient.invalidateQueries({ queryKey: ["addresses"] });
} catch (error) {
setMessage(error instanceof Error ? error.message : "IPAM discovery failed.");
}
}
return (
<>
<PageHeader title="IPAM" subtitle="Manage subnets, reservations, assignments, conflicts, and export state." />
@@ -63,8 +79,10 @@ export function Ipam() {
<div className="flex flex-wrap gap-2">
<button className={buttonClass} onClick={() => setSubnetOpen(true)}><Plus size={16} /> Add Subnet</button>
<button className={buttonClass} onClick={() => setIpOpen(true)}><Plus size={16} /> Reserve IP</button>
<button className={secondaryButtonClass} onClick={discoverIpam}>Discover from Proxmox</button>
<button className={secondaryButtonClass} onClick={exportCsv}><Download size={16} /> Export CSV</button>
</div>
{message ? <div className="rounded-md border border-border bg-panel p-3 text-sm">{message}</div> : null}
<Modal title="Add Subnet" open={subnetOpen} onClose={() => setSubnetOpen(false)}>
<form onSubmit={submitSubnet}>
<div className="mb-4 flex items-center gap-2 font-medium"><Database size={18} /> Add Subnet</div>
@@ -103,6 +121,7 @@ export function Ipam() {
</form>
</Modal>
<section className="space-y-4">
<DataTable rows={(subnets.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "cidr", label: "Subnet" }, { key: "gateway", label: "Gateway" }, { key: "dhcp_enabled", label: "DHCP" }]} />
<DataTable rows={(addresses.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "address", label: "Address" }, { key: "status", label: "Status" }, { key: "note", label: "Note" }]} />
</section>
</div>
+137 -22
View File
@@ -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>
</>
);
}
+42 -1
View File
@@ -5,9 +5,20 @@ import { publicApi } from "../api/client";
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
import { useTheme } from "../stores/theme";
const preparingMessages = [
"Creating your secure workspace...",
"Preparing the control plane...",
"Registering your Proxmox provider...",
"Setting safe read-only defaults...",
"Warming up inventory services...",
"Almost ready...",
];
export function SetupWizard() {
const [step, setStep] = useState(0);
const [error, setError] = useState("");
const [preparing, setPreparing] = useState(false);
const [prepareProgress, setPrepareProgress] = useState(0);
const { dark, toggle } = useTheme();
const [form, setForm] = useState({
admin_email: "admin@nexafabric.local",
@@ -26,12 +37,42 @@ export function SetupWizard() {
setError("");
try {
await publicApi("/setup/complete", { method: "POST", body: JSON.stringify(form) });
setPreparing(true);
setPrepareProgress(0);
for (let progress = 1; progress <= 100; progress += 1) {
await new Promise((resolve) => window.setTimeout(resolve, 150));
setPrepareProgress(progress);
}
window.location.href = "/login";
} catch (err) {
setPreparing(false);
setError(err instanceof Error ? err.message : "Setup failed");
}
}
if (preparing) {
const messageIndex = Math.min(
preparingMessages.length - 1,
Math.floor((prepareProgress / 100) * preparingMessages.length),
);
return (
<div className="grid min-h-screen place-items-center overflow-hidden bg-canvas px-4 text-slate-900 dark:text-slate-100">
<div className="w-full max-w-xl animate-[fadeIn_500ms_ease-out] text-center">
<div className="mx-auto mb-8 grid h-24 w-24 place-items-center rounded-2xl bg-accent text-white shadow-xl shadow-teal-900/20">
<Sparkles className="animate-pulse" size={42} />
</div>
<h1 className="mb-3 text-4xl font-semibold tracking-normal">Preparing NexaFabric</h1>
<p className="mb-8 text-sm text-slate-500 dark:text-slate-400">{preparingMessages[messageIndex]}</p>
<div className="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
<div className="h-full rounded-full bg-accent transition-all duration-150" style={{ width: `${prepareProgress}%` }} />
</div>
<div className="mt-3 text-xs text-slate-500">{prepareProgress}%</div>
</div>
</div>
);
}
return (
<div className="min-h-screen overflow-hidden bg-canvas px-4 py-8 text-slate-900 dark:text-slate-100">
<button
@@ -110,7 +151,7 @@ export function SetupWizard() {
{error ? <div className="mt-4 rounded-md border border-danger p-3 text-sm text-danger">{error}</div> : null}
<div className="mt-6 flex justify-between">
<button className={secondaryButtonClass} type="button" onClick={() => setStep(Math.max(0, step - 1))}>Back</button>
{step < 3 ? <button className={buttonClass} type="button" onClick={() => setStep(step + 1)}>Next</button> : <button className={buttonClass}>Complete Setup</button>}
{step < 3 ? <button className={buttonClass} type="button" onClick={() => setStep(step + 1)}>Next</button> : <button className={buttonClass} disabled={preparing}>Complete Setup</button>}
</div>
</form>
</>
+6 -13
View File
@@ -1,10 +1,9 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Activity, Server } from "lucide-react";
import { Activity } from "lucide-react";
import { api, Workload, WorkloadInsight } from "../api/client";
import { DataTable } from "../components/DataTable";
import { secondaryButtonClass } from "../components/FormControls";
import { PageHeader } from "../components/PageHeader";
export function Workloads() {
@@ -25,15 +24,9 @@ export function Workloads() {
<DataTable
rows={(workloads.data ?? []) as unknown as Record<string, unknown>[]}
columns={[{ key: "name", label: "Name" }, { key: "kind", label: "Kind" }, { key: "status", label: "Status" }, { key: "external_id", label: "VMID" }]}
selectedId={selected}
onRowClick={(row) => setSelectedId(String(row.id))}
/>
<div className="flex flex-wrap gap-2">
{(workloads.data ?? []).map((workload) => (
<button key={workload.id} className={secondaryButtonClass} onClick={() => setSelectedId(workload.id)}>
<Server size={16} />
{workload.name}
</button>
))}
</div>
</section>
<aside className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><Activity size={18} /> Workload Detail</div>
@@ -46,12 +39,13 @@ export function Workloads() {
<section>
<div className="mb-2 font-medium">Traffic</div>
<div className="space-y-2">
{insight.data.traffic.map((flow, index) => (
{insight.data.traffic.length ? insight.data.traffic.map((flow, index) => (
<div key={index} className="rounded-md border border-border p-3">
<div>{String(flow.source)} {String(flow.destination)}</div>
<div className="text-xs text-slate-500">{String(flow.protocol)}:{String(flow.port)} · {String(flow.bytes)} bytes · {String(flow.decision)}</div>
{Array.isArray(flow.ip_addresses) ? <div className="mt-1 text-xs text-slate-500">IPs: {flow.ip_addresses.join(", ")}</div> : null}
</div>
))}
)) : <div className="rounded-md border border-border p-3 text-xs text-slate-500">No real traffic telemetry has been collected yet. Install the node agent or enable a flow source to populate this section.</div>}
</div>
</section>
<section>
@@ -80,4 +74,3 @@ export function Workloads() {
</>
);
}