feat: add comprehensive CRUD endpoints, cluster sync improvements, and firewall orchestration
CI / backend (push) Failing after 2s
CI / frontend (push) Failing after 30s

Add create endpoints for users, roles, tenants, projects, networks, subnets, and security rules with audit logging, implement commit_or_400 helper for IntegrityError handling with 409 responses, enhance cluster sync to populate nodes, workloads, and networks from provider inventory with last_sync_at tracking, add update/delete operations for IP addresses and policies with version tracking, implement IP
This commit is contained in:
2026-07-09 12:33:36 +02:00
parent dea27b5459
commit a911d36f34
15 changed files with 1267 additions and 36 deletions
+16 -7
View File
@@ -5,9 +5,17 @@ import { BrowserRouter, Route, Routes } from "react-router-dom";
import { Layout } from "./components/Layout";
import { Dashboard } from "./pages/Dashboard";
import { FirewallPreview } from "./pages/FirewallPreview";
import { Clusters } from "./pages/Clusters";
import { Ipam } from "./pages/Ipam";
import { ListPage } from "./pages/ListPage";
import { Login } from "./pages/Login";
import { Networks } from "./pages/Networks";
import { Policies } from "./pages/Policies";
import { PolicyDesigner } from "./pages/PolicyDesigner";
import { SecurityGroups } from "./pages/SecurityGroups";
import { ServiceCatalog } from "./pages/ServiceCatalog";
import { TenantsProjects } from "./pages/TenantsProjects";
import { UsersRoles } from "./pages/UsersRoles";
import { useTheme } from "./stores/theme";
const queryClient = new QueryClient();
@@ -26,19 +34,20 @@ export function App() {
<Route path="/login" element={<Login />} />
<Route element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="clusters" element={<ListPage title="Clusters" subtitle="Registered Proxmox clusters and sync state." path="/clusters" columns={[{ key: "name", label: "Name" }, { key: "api_url", label: "API URL" }, { key: "mode", label: "Mode" }, { key: "last_sync_status", label: "Sync" }]} />} />
<Route path="clusters" element={<Clusters />} />
<Route path="nodes" element={<ListPage title="Nodes" subtitle="Cluster nodes, capacity, and health." path="/nodes" columns={[{ key: "name", label: "Name" }, { key: "status", label: "Status" }, { key: "cpu_count", label: "CPU" }, { key: "memory_mb", label: "Memory MB" }]} />} />
<Route path="workloads" element={<ListPage title="VMs/LXCs" subtitle="Virtual machine and container inventory." path="/vms" columns={[{ key: "name", label: "Name" }, { key: "kind", label: "Kind" }, { key: "status", label: "Status" }, { key: "external_id", label: "VMID" }]} />} />
<Route path="networks" element={<ListPage title="Networks" subtitle="Bridges, VLANs, VNets, gateways, tags, and MTU." path="/networks" columns={[{ key: "name", label: "Name" }, { key: "kind", label: "Kind" }, { key: "vlan_id", label: "VLAN" }, { key: "gateway", label: "Gateway" }, { key: "mtu", label: "MTU" }]} />} />
<Route path="ipam" element={<ListPage title="IPAM" subtitle="Subnets and tracked IP address states." path="/ipam/addresses" columns={[{ key: "address", label: "Address" }, { key: "status", label: "Status" }, { key: "note", label: "Note" }]} />} />
<Route path="tenants" element={<ListPage title="Tenants" subtitle="Tenant and project boundaries for RBAC and policies." path="/tenants" columns={[{ key: "name", label: "Name" }, { key: "description", label: "Description" }]} />} />
<Route path="security-groups" element={<ListPage title="Security Groups" subtitle="Logical targets for microsegmentation rules." path="/security-groups" columns={[{ key: "name", label: "Name" }, { key: "description", label: "Description" }]} />} />
<Route path="policies" element={<ListPage title="Policies" subtitle="Versioned policy definitions and compile state." path="/policies" columns={[{ key: "name", label: "Name" }, { key: "version", label: "Version" }, { key: "enabled", label: "Enabled" }]} />} />
<Route path="networks" element={<Networks />} />
<Route path="ipam" element={<Ipam />} />
<Route path="tenants" element={<TenantsProjects />} />
<Route path="security-groups" element={<SecurityGroups />} />
<Route path="policies" element={<Policies />} />
<Route path="services" element={<ServiceCatalog />} />
<Route path="designer" element={<PolicyDesigner />} />
<Route path="firewall" element={<FirewallPreview />} />
<Route path="jobs" element={<ListPage title="Jobs" subtitle="Background task state and execution logs." path="/jobs" columns={[{ key: "kind", label: "Kind" }, { key: "status", label: "Status" }, { key: "progress", label: "Progress" }]} />} />
<Route path="audit" element={<ListPage title="Audit Logs" subtitle="Security-relevant activity and change history." path="/audit" columns={[{ key: "created_at", label: "Time" }, { key: "action", label: "Action" }, { key: "object_type", label: "Object" }, { key: "result", label: "Result" }]} />} />
<Route path="users" element={<ListPage title="Users" subtitle="Local users, roles, and access state." path="/users" columns={[{ key: "email", label: "Email" }, { key: "display_name", label: "Name" }, { key: "is_active", label: "Active" }]} />} />
<Route path="users" element={<UsersRoles />} />
<Route path="settings" element={<ListPage title="Settings" subtitle="Runtime settings and safety defaults." path="/settings" columns={[{ key: "product", label: "Product" }, { key: "firewall_apply_requires_preview", label: "Preview Required" }, { key: "agent_optional", label: "Agent Optional" }]} />} />
</Route>
</Routes>
+72 -1
View File
@@ -19,6 +19,48 @@ export type Cluster = {
last_sync_status: string | null;
};
export type Project = {
id: string;
name: string;
tenant_id: string;
description: string | null;
};
export type Tenant = {
id: string;
name: string;
description: string | null;
};
export type User = {
id: string;
email: string;
display_name: string;
is_active: boolean;
};
export type Role = {
id: string;
name: string;
permissions: string[];
};
export type Subnet = {
id: string;
network_id: string;
cidr: string;
gateway: string | null;
dhcp_enabled: boolean;
};
export type IpAddress = {
id: string;
subnet_id: string;
address: string;
status: string;
note: string | null;
};
export type Network = {
id: string;
name: string;
@@ -29,12 +71,42 @@ export type Network = {
tags: string[];
};
export type SecurityGroup = {
id: string;
project_id: string | null;
name: string;
description: string | null;
};
export type SecurityRule = {
id: string;
security_group_id: string;
direction: string;
action: string;
protocol: string;
source: string;
destination: string;
port: string | null;
priority: number;
logging: boolean;
description: string | null;
};
export type Policy = {
id: string;
name: string;
version: number;
enabled: boolean;
definition: Record<string, unknown>;
last_compiled: Record<string, unknown> | null;
};
export type ServiceCatalogItem = {
id: string;
name: string;
protocol: string;
ports: string;
editable: boolean;
};
export type AuditLog = {
@@ -76,4 +148,3 @@ export async function login(email: string, password: string) {
setToken(data.access_token);
return data;
}
+21
View File
@@ -0,0 +1,21 @@
import { ReactNode } from "react";
type FieldProps = {
label: string;
children: ReactNode;
};
export function Field({ label, children }: FieldProps) {
return (
<label className="block text-sm">
<span className="mb-1 block text-slate-600 dark:text-slate-300">{label}</span>
{children}
</label>
);
}
export const inputClass = "h-10 w-full rounded-md border border-border bg-transparent px-3 text-sm outline-none focus:border-accent";
export const selectClass = inputClass;
export const buttonClass = "inline-flex h-10 items-center justify-center gap-2 rounded-md bg-accent px-4 text-sm font-medium text-white disabled:opacity-50";
export const secondaryButtonClass = "inline-flex h-10 items-center justify-center gap-2 rounded-md border border-border px-4 text-sm hover:bg-slate-100 dark:hover:bg-slate-800";
+2
View File
@@ -15,6 +15,7 @@ import {
Server,
Settings,
Shield,
SquareStack,
Sun,
Users,
} from "lucide-react";
@@ -33,6 +34,7 @@ const nav = [
{ to: "/tenants", label: "Tenants", icon: BriefcaseBusiness },
{ to: "/security-groups", label: "Security Groups", icon: Shield },
{ to: "/policies", label: "Policies", icon: GitBranch },
{ to: "/services", label: "Service Catalog", icon: SquareStack },
{ to: "/designer", label: "Policy Designer", icon: LockKeyhole },
{ to: "/firewall", label: "Firewall Preview", icon: Flame },
{ to: "/jobs", label: "Jobs", icon: ClipboardList },
+92
View File
@@ -0,0 +1,92 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Cable, RefreshCcw, Server } from "lucide-react";
import { api, Cluster } from "../api/client";
import { DataTable } from "../components/DataTable";
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
import { PageHeader } from "../components/PageHeader";
export function Clusters() {
const queryClient = useQueryClient();
const clusters = useQuery({ queryKey: ["clusters"], queryFn: () => api<Cluster[]>("/clusters") });
const [form, setForm] = useState({
name: "Demo Provider",
api_url: "https://demo.local:8006",
api_token: "PVEAPIToken=demo",
provider: "demo",
mode: "read_only",
verify_tls: true,
});
const [result, setResult] = useState("");
const create = useMutation({
mutationFn: () => api<Cluster>("/clusters", { method: "POST", body: JSON.stringify(form) }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clusters"] }),
});
async function submit(event: FormEvent) {
event.preventDefault();
await create.mutateAsync();
}
async function action(cluster: Cluster, kind: "test" | "sync") {
const data = await api<Record<string, unknown>>(`/clusters/${cluster.id}/${kind}`, { method: "POST" });
setResult(JSON.stringify(data, null, 2));
await queryClient.invalidateQueries({ queryKey: ["clusters"] });
}
return (
<>
<PageHeader title="Clusters" subtitle="Register, test, and sync Proxmox or demo providers." />
<div className="grid gap-4 xl:grid-cols-[380px_1fr]">
<form onSubmit={submit} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><Server size={18} /> Add Cluster</div>
<div className="grid gap-3">
<Field label="Name"><input className={inputClass} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field>
<Field label="API URL"><input className={inputClass} value={form.api_url} onChange={(event) => setForm({ ...form, api_url: event.target.value })} /></Field>
<Field label="API Token"><input className={inputClass} value={form.api_token} onChange={(event) => setForm({ ...form, api_token: event.target.value })} /></Field>
<Field label="Provider">
<select className={selectClass} value={form.provider} onChange={(event) => setForm({ ...form, provider: event.target.value })}>
<option value="demo">demo</option>
<option value="proxmox">proxmox</option>
</select>
</Field>
<Field label="Mode">
<select className={selectClass} value={form.mode} onChange={(event) => setForm({ ...form, mode: event.target.value })}>
<option value="read_only">read_only</option>
<option value="write_enabled">write_enabled</option>
</select>
</Field>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.verify_tls} onChange={(event) => setForm({ ...form, verify_tls: event.target.checked })} />
Verify TLS
</label>
<button className={buttonClass} disabled={create.isPending}>Save Cluster</button>
</div>
</form>
<section className="space-y-4">
<DataTable
rows={(clusters.data ?? []) as unknown as Record<string, unknown>[]}
columns={[
{ key: "name", label: "Name" },
{ key: "provider", label: "Provider" },
{ key: "mode", label: "Mode" },
{ key: "last_sync_status", label: "Sync" },
]}
/>
<div className="flex flex-wrap gap-2">
{(clusters.data ?? []).map((cluster) => (
<div key={cluster.id} className="flex gap-2">
<button className={secondaryButtonClass} onClick={() => action(cluster, "test")}><Cable size={16} /> {cluster.name}</button>
<button className={secondaryButtonClass} onClick={() => action(cluster, "sync")}><RefreshCcw size={16} /> Sync</button>
</div>
))}
</div>
<pre className="min-h-24 overflow-auto rounded-md border border-border bg-panel p-3 text-xs">{result || "No cluster action result yet."}</pre>
</section>
</div>
</>
);
}
+50 -15
View File
@@ -1,33 +1,68 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { Play } from "lucide-react";
import { Play, ShieldCheck } from "lucide-react";
import { useState } from "react";
import { api, Policy } from "../api/client";
import { api, Cluster, Policy } from "../api/client";
import { buttonClass, Field, secondaryButtonClass, selectClass } from "../components/FormControls";
import { PageHeader } from "../components/PageHeader";
export function FirewallPreview() {
const policies = useQuery({ queryKey: ["policies"], queryFn: () => api<Policy[]>("/policies") });
const clusters = useQuery({ queryKey: ["clusters"], queryFn: () => api<Cluster[]>("/clusters") });
const [policyId, setPolicyId] = useState("");
const [clusterId, setClusterId] = useState("");
const [dryRun, setDryRun] = useState(true);
const preview = useMutation({
mutationFn: (policyId: string) => api<Record<string, unknown>>(`/firewall/preview/${policyId}`, { method: "POST" }),
});
const firstPolicy = policies.data?.[0];
const apply = useMutation({
mutationFn: () =>
api<Record<string, unknown>>("/firewall/apply", {
method: "POST",
body: JSON.stringify({
policy_id: policyId || policies.data?.[0]?.id,
cluster_id: clusterId || clusters.data?.[0]?.id,
confirm: true,
dry_run: dryRun,
}),
}),
});
const selectedPolicyId = policyId || policies.data?.[0]?.id || "";
return (
<>
<PageHeader title="Firewall Preview" subtitle="Compile policies into provider-specific rules before any apply operation." />
<div className="rounded-md border border-border bg-panel p-4">
<button
className="inline-flex h-10 items-center gap-2 rounded-md bg-accent px-4 text-sm text-white disabled:opacity-50"
disabled={!firstPolicy}
onClick={() => firstPolicy && preview.mutate(firstPolicy.id)}
>
<Play size={18} />
Generate Preview
</button>
<pre className="mt-4 max-h-[520px] overflow-auto rounded-md border border-border bg-canvas p-4 text-xs">
{preview.data ? JSON.stringify(preview.data, null, 2) : "No preview generated yet."}
<div className="grid gap-4 lg:grid-cols-[360px_1fr]">
<section className="rounded-md border border-border bg-panel p-4">
<div className="grid gap-3">
<Field label="Policy">
<select className={selectClass} value={selectedPolicyId} onChange={(event) => setPolicyId(event.target.value)}>
{(policies.data ?? []).map((policy) => <option key={policy.id} value={policy.id}>{policy.name}</option>)}
</select>
</Field>
<Field label="Cluster">
<select className={selectClass} value={clusterId || clusters.data?.[0]?.id || ""} onChange={(event) => setClusterId(event.target.value)}>
{(clusters.data ?? []).map((cluster) => <option key={cluster.id} value={cluster.id}>{cluster.name}</option>)}
</select>
</Field>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={dryRun} onChange={(event) => setDryRun(event.target.checked)} />
Dry run
</label>
<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
</button>
</div>
</section>
<pre className="max-h-[620px] overflow-auto rounded-md border border-border bg-panel p-4 text-xs">
{apply.data ? JSON.stringify(apply.data, null, 2) : preview.data ? JSON.stringify(preview.data, null, 2) : "No firewall output yet."}
</pre>
</div>
</>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Database, Download, Plus } from "lucide-react";
import { api, IpAddress, Network, Subnet, token } from "../api/client";
import { DataTable } from "../components/DataTable";
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
import { PageHeader } from "../components/PageHeader";
export function Ipam() {
const queryClient = useQueryClient();
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 [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 createSubnet = useMutation({
mutationFn: () => api<Subnet>("/ipam/subnets", { method: "POST", body: JSON.stringify({ ...subnetForm, network_id: subnetForm.network_id || networks.data?.[0]?.id }) }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["subnets"] }),
});
const createIp = useMutation({
mutationFn: () => api<IpAddress>("/ipam/addresses", { method: "POST", body: JSON.stringify({ ...ipForm, subnet_id: ipForm.subnet_id || subnets.data?.[0]?.id }) }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["addresses"] }),
});
async function submitSubnet(event: FormEvent) {
event.preventDefault();
await createSubnet.mutateAsync();
}
async function submitIp(event: FormEvent) {
event.preventDefault();
await createIp.mutateAsync();
}
async function exportCsv() {
const response = await fetch("/api/v1/ipam/export.csv", {
headers: token() ? { Authorization: `Bearer ${token()}` } : {},
});
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "nexafabric-ipam.csv";
link.click();
URL.revokeObjectURL(url);
}
return (
<>
<PageHeader title="IPAM" subtitle="Manage subnets, reservations, assignments, conflicts, and export state." />
<div className="grid gap-4 xl:grid-cols-[360px_360px_1fr]">
<form onSubmit={submitSubnet} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><Database size={18} /> Add Subnet</div>
<div className="grid gap-3">
<Field label="Network">
<select className={selectClass} value={subnetForm.network_id} onChange={(event) => setSubnetForm({ ...subnetForm, network_id: event.target.value })}>
<option value="">Auto select</option>
{(networks.data ?? []).map((network) => <option key={network.id} value={network.id}>{network.name}</option>)}
</select>
</Field>
<Field label="CIDR"><input className={inputClass} value={subnetForm.cidr} onChange={(event) => setSubnetForm({ ...subnetForm, cidr: event.target.value })} /></Field>
<Field label="Gateway"><input className={inputClass} value={subnetForm.gateway} onChange={(event) => setSubnetForm({ ...subnetForm, gateway: event.target.value })} /></Field>
<button className={buttonClass}><Plus size={16} /> Add Subnet</button>
</div>
</form>
<form onSubmit={submitIp} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><Plus size={18} /> Reserve IP</div>
<div className="grid gap-3">
<Field label="Subnet">
<select className={selectClass} value={ipForm.subnet_id} onChange={(event) => setIpForm({ ...ipForm, subnet_id: event.target.value })}>
<option value="">Auto select</option>
{(subnets.data ?? []).map((subnet) => <option key={subnet.id} value={subnet.id}>{subnet.cidr}</option>)}
</select>
</Field>
<Field label="Address"><input className={inputClass} value={ipForm.address} onChange={(event) => setIpForm({ ...ipForm, address: event.target.value })} /></Field>
<Field label="Status">
<select className={selectClass} value={ipForm.status} onChange={(event) => setIpForm({ ...ipForm, status: event.target.value })}>
{["free", "reserved", "assigned", "deprecated", "conflict"].map((status) => <option key={status}>{status}</option>)}
</select>
</Field>
<Field label="Note"><input className={inputClass} value={ipForm.note} onChange={(event) => setIpForm({ ...ipForm, note: event.target.value })} /></Field>
<button className={buttonClass}><Plus size={16} /> Save IP</button>
</div>
</form>
<section className="space-y-4">
<button className={secondaryButtonClass} onClick={exportCsv}><Download size={16} /> Export CSV</button>
<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>
</>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Network as NetworkIcon, Plus } from "lucide-react";
import { api, Cluster, Network, Project } from "../api/client";
import { DataTable } from "../components/DataTable";
import { buttonClass, Field, inputClass, selectClass } from "../components/FormControls";
import { PageHeader } from "../components/PageHeader";
export function Networks() {
const queryClient = useQueryClient();
const clusters = useQuery({ queryKey: ["clusters"], queryFn: () => api<Cluster[]>("/clusters") });
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
const networks = useQuery({ queryKey: ["networks"], queryFn: () => api<Network[]>("/networks") });
const [form, setForm] = useState({
cluster_id: "",
project_id: "",
name: "tenant-vlan-50",
kind: "vlan",
vlan_id: "50",
mtu: "1500",
gateway: "10.50.0.1",
description: "Tenant VLAN",
});
const create = useMutation({
mutationFn: () =>
api<Network>("/networks", {
method: "POST",
body: JSON.stringify({
cluster_id: form.cluster_id || clusters.data?.[0]?.id,
project_id: form.project_id || null,
name: form.name,
kind: form.kind,
vlan_id: form.vlan_id ? Number(form.vlan_id) : null,
mtu: Number(form.mtu),
gateway: form.gateway || null,
dns: [],
dhcp_enabled: false,
tags: [],
description: form.description,
}),
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["networks"] }),
});
async function submit(event: FormEvent) {
event.preventDefault();
await create.mutateAsync();
}
return (
<>
<PageHeader title="Networks" subtitle="Create bridges, VLANs, VNets, gateways, MTU, and ownership metadata." />
<div className="grid gap-4 lg:grid-cols-[380px_1fr]">
<form onSubmit={submit} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><NetworkIcon size={18} /> Add Network</div>
<div className="grid gap-3">
<Field label="Cluster"><select className={selectClass} value={form.cluster_id} onChange={(event) => setForm({ ...form, cluster_id: event.target.value })}><option value="">Auto select</option>{(clusters.data ?? []).map((cluster) => <option key={cluster.id} value={cluster.id}>{cluster.name}</option>)}</select></Field>
<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-3 gap-3">
<Field label="Kind"><select className={selectClass} value={form.kind} onChange={(event) => setForm({ ...form, kind: event.target.value })}><option>bridge</option><option>vlan</option><option>vxlan</option><option>vnet</option></select></Field>
<Field label="VLAN"><input className={inputClass} value={form.vlan_id} onChange={(event) => setForm({ ...form, vlan_id: event.target.value })} /></Field>
<Field label="MTU"><input className={inputClass} value={form.mtu} onChange={(event) => setForm({ ...form, mtu: event.target.value })} /></Field>
</div>
<Field label="Gateway"><input className={inputClass} value={form.gateway} onChange={(event) => setForm({ ...form, gateway: event.target.value })} /></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} /> Save Network</button>
</div>
</form>
<DataTable rows={(networks.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Name" }, { key: "kind", label: "Kind" }, { key: "vlan_id", label: "VLAN" }, { key: "gateway", label: "Gateway" }, { key: "mtu", label: "MTU" }]} />
</div>
</>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { GitBranch, Play, Plus } from "lucide-react";
import { api, Policy, Project, ServiceCatalogItem } from "../api/client";
import { DataTable } from "../components/DataTable";
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
import { PageHeader } from "../components/PageHeader";
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 [form, setForm] = useState({
project_id: "",
name: "Web to DB",
source: "sg:Web Tier",
destination: "sg:Database",
service_id: "",
protocol: "tcp",
ports: "5432",
action: "allow",
direction: "egress",
logging: true,
description: "Allow application database traffic",
});
const create = useMutation({
mutationFn: () => {
const service = services.data?.find((item) => item.id === form.service_id);
return api<Policy>("/policies", {
method: "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,
logging: form.logging,
description: form.description,
},
}),
});
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["policies"] }),
});
async function submit(event: FormEvent) {
event.preventDefault();
await create.mutateAsync();
}
async function compile(policy: Policy) {
const data = await api<Policy>(`/policies/${policy.id}/compile`, { method: "POST" });
setPreview(JSON.stringify(data.last_compiled, null, 2));
await queryClient.invalidateQueries({ queryKey: ["policies"] });
}
async function firewallPreview(policy: Policy) {
const data = await api<Record<string, unknown>>(`/firewall/preview/${policy.id}`, { method: "POST" });
setPreview(JSON.stringify(data, null, 2));
}
return (
<>
<PageHeader title="Policies" subtitle="Create versioned microsegmentation policies and compile firewall previews." />
<div className="grid gap-4 xl:grid-cols-[420px_1fr]">
<form onSubmit={submit} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><GitBranch size={18} /> 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) => 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>
<div className="grid grid-cols-2 gap-3">
<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>
</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="Description"><input className={inputClass} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Field>
<button className={buttonClass}><Plus size={16} /> Save Policy</button>
</div>
</form>
<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>
<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>
</>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { FormEvent, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Shield } from "lucide-react";
import { api, Project, SecurityGroup, SecurityRule } from "../api/client";
import { DataTable } from "../components/DataTable";
import { buttonClass, Field, inputClass, selectClass } from "../components/FormControls";
import { PageHeader } from "../components/PageHeader";
export function SecurityGroups() {
const queryClient = useQueryClient();
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
const groups = useQuery({ queryKey: ["security-groups"], queryFn: () => api<SecurityGroup[]>("/security-groups") });
const [selectedGroupId, setSelectedGroupId] = useState("");
const selectedGroup = useMemo(() => selectedGroupId || groups.data?.[0]?.id || "", [groups.data, selectedGroupId]);
const rules = useQuery({
queryKey: ["security-rules", selectedGroup],
queryFn: () => api<SecurityRule[]>(`/security-groups/${selectedGroup}/rules`),
enabled: Boolean(selectedGroup),
});
const [groupForm, setGroupForm] = useState({ project_id: "", name: "Web Tier", description: "Application frontend workloads" });
const [ruleForm, setRuleForm] = useState({
direction: "ingress",
action: "allow",
protocol: "tcp",
source: "any",
destination: "sg:Web Tier",
port: "443",
priority: 1000,
logging: true,
description: "Allow HTTPS",
});
const createGroup = useMutation({
mutationFn: () => api<SecurityGroup>("/security-groups", { method: "POST", body: JSON.stringify({ ...groupForm, project_id: groupForm.project_id || null }) }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["security-groups"] }),
});
const createRule = useMutation({
mutationFn: () => api<SecurityRule>("/security-rules", { method: "POST", body: JSON.stringify({ ...ruleForm, security_group_id: selectedGroup }) }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["security-rules", selectedGroup] }),
});
async function submitGroup(event: FormEvent) {
event.preventDefault();
const group = await createGroup.mutateAsync();
setSelectedGroupId(group.id);
}
async function submitRule(event: FormEvent) {
event.preventDefault();
await createRule.mutateAsync();
}
return (
<>
<PageHeader title="Security Groups" subtitle="Create logical groups and attach ordered ingress or egress rules." />
<div className="grid gap-4 xl:grid-cols-[340px_360px_1fr]">
<form onSubmit={submitGroup} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><Shield size={18} /> Add Group</div>
<div className="grid gap-3">
<Field label="Project">
<select className={selectClass} value={groupForm.project_id} onChange={(event) => setGroupForm({ ...groupForm, 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={groupForm.name} onChange={(event) => setGroupForm({ ...groupForm, name: event.target.value })} /></Field>
<Field label="Description"><input className={inputClass} value={groupForm.description} onChange={(event) => setGroupForm({ ...groupForm, description: event.target.value })} /></Field>
<button className={buttonClass}><Plus size={16} /> Save Group</button>
</div>
</form>
<form onSubmit={submitRule} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 font-medium">Add Rule</div>
<div className="grid gap-3">
<Field label="Security Group">
<select className={selectClass} value={selectedGroup} onChange={(event) => setSelectedGroupId(event.target.value)}>
{(groups.data ?? []).map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
</select>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Direction"><select className={selectClass} value={ruleForm.direction} onChange={(event) => setRuleForm({ ...ruleForm, direction: event.target.value })}><option>ingress</option><option>egress</option></select></Field>
<Field label="Action"><select className={selectClass} value={ruleForm.action} onChange={(event) => setRuleForm({ ...ruleForm, action: event.target.value })}><option>allow</option><option>deny</option><option>reject</option></select></Field>
</div>
<Field label="Source"><input className={inputClass} value={ruleForm.source} onChange={(event) => setRuleForm({ ...ruleForm, source: event.target.value })} /></Field>
<Field label="Destination"><input className={inputClass} value={ruleForm.destination} onChange={(event) => setRuleForm({ ...ruleForm, destination: event.target.value })} /></Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Protocol"><input className={inputClass} value={ruleForm.protocol} onChange={(event) => setRuleForm({ ...ruleForm, protocol: event.target.value })} /></Field>
<Field label="Port"><input className={inputClass} value={ruleForm.port} onChange={(event) => setRuleForm({ ...ruleForm, port: event.target.value })} /></Field>
</div>
<button className={buttonClass} disabled={!selectedGroup}><Plus size={16} /> Save Rule</button>
</div>
</form>
<section className="space-y-4">
<DataTable rows={(groups.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Group" }, { key: "description", label: "Description" }]} />
<DataTable rows={(rules.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "priority", label: "Priority" }, { key: "direction", label: "Direction" }, { key: "action", label: "Action" }, { key: "protocol", label: "Protocol" }, { key: "port", label: "Port" }]} />
</section>
</div>
</>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, SquareStack } from "lucide-react";
import { api, ServiceCatalogItem } from "../api/client";
import { DataTable } from "../components/DataTable";
import { buttonClass, Field, inputClass } from "../components/FormControls";
import { PageHeader } from "../components/PageHeader";
export function ServiceCatalog() {
const queryClient = useQueryClient();
const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api<ServiceCatalogItem[]>("/service-catalog") });
const [form, setForm] = useState({ name: "Custom API", protocol: "tcp", ports: "8443", editable: true });
const create = useMutation({
mutationFn: () => api<ServiceCatalogItem>("/service-catalog", { method: "POST", body: JSON.stringify(form) }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["service-catalog"] }),
});
async function submit(event: FormEvent) {
event.preventDefault();
await create.mutateAsync();
}
return (
<>
<PageHeader title="Service Catalog" subtitle="Maintain reusable protocols and port ranges for policy rules." />
<div className="grid gap-4 lg:grid-cols-[340px_1fr]">
<form onSubmit={submit} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><SquareStack size={18} /> Add Service</div>
<div className="grid gap-3">
<Field label="Name"><input className={inputClass} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></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>
<button className={buttonClass}><Plus size={16} /> Save Service</button>
</div>
</form>
<DataTable rows={(services.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Name" }, { key: "protocol", label: "Protocol" }, { key: "ports", label: "Ports" }]} />
</div>
</>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BriefcaseBusiness, Plus } from "lucide-react";
import { api, Project, Tenant } from "../api/client";
import { DataTable } from "../components/DataTable";
import { buttonClass, Field, inputClass, selectClass } from "../components/FormControls";
import { PageHeader } from "../components/PageHeader";
export function TenantsProjects() {
const queryClient = useQueryClient();
const tenants = useQuery({ queryKey: ["tenants"], queryFn: () => api<Tenant[]>("/tenants") });
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
const [tenantForm, setTenantForm] = useState({ name: "Operations", description: "Operations tenant" });
const [projectForm, setProjectForm] = useState({ tenant_id: "", name: "Monitoring", description: "Monitoring workloads" });
const createTenant = useMutation({
mutationFn: () => api<Tenant>("/tenants", { method: "POST", body: JSON.stringify(tenantForm) }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tenants"] }),
});
const createProject = useMutation({
mutationFn: () => api<Project>("/projects", { method: "POST", body: JSON.stringify({ ...projectForm, tenant_id: projectForm.tenant_id || tenants.data?.[0]?.id }) }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["projects"] }),
});
async function submitTenant(event: FormEvent) {
event.preventDefault();
await createTenant.mutateAsync();
}
async function submitProject(event: FormEvent) {
event.preventDefault();
await createProject.mutateAsync();
}
return (
<>
<PageHeader title="Tenants & Projects" subtitle="Define ownership boundaries for visibility, IPAM, networks, and policies." />
<div className="grid gap-4 xl:grid-cols-[330px_330px_1fr]">
<form onSubmit={submitTenant} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><BriefcaseBusiness size={18} /> Add Tenant</div>
<div className="grid gap-3">
<Field label="Name"><input className={inputClass} value={tenantForm.name} onChange={(event) => setTenantForm({ ...tenantForm, name: event.target.value })} /></Field>
<Field label="Description"><input className={inputClass} value={tenantForm.description} onChange={(event) => setTenantForm({ ...tenantForm, description: event.target.value })} /></Field>
<button className={buttonClass}><Plus size={16} /> Save Tenant</button>
</div>
</form>
<form onSubmit={submitProject} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 font-medium">Add Project</div>
<div className="grid gap-3">
<Field label="Tenant"><select className={selectClass} value={projectForm.tenant_id} onChange={(event) => setProjectForm({ ...projectForm, tenant_id: event.target.value })}><option value="">Auto select</option>{(tenants.data ?? []).map((tenant) => <option key={tenant.id} value={tenant.id}>{tenant.name}</option>)}</select></Field>
<Field label="Name"><input className={inputClass} value={projectForm.name} onChange={(event) => setProjectForm({ ...projectForm, name: event.target.value })} /></Field>
<Field label="Description"><input className={inputClass} value={projectForm.description} onChange={(event) => setProjectForm({ ...projectForm, description: event.target.value })} /></Field>
<button className={buttonClass}><Plus size={16} /> Save Project</button>
</div>
</form>
<section className="space-y-4">
<DataTable rows={(tenants.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Tenant" }, { key: "description", label: "Description" }]} />
<DataTable rows={(projects.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Project" }, { key: "description", label: "Description" }]} />
</section>
</div>
</>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Users } from "lucide-react";
import { api, Role, User } from "../api/client";
import { DataTable } from "../components/DataTable";
import { buttonClass, Field, inputClass, selectClass } from "../components/FormControls";
import { PageHeader } from "../components/PageHeader";
export function UsersRoles() {
const queryClient = useQueryClient();
const users = useQuery({ queryKey: ["users"], queryFn: () => api<User[]>("/users") });
const roles = useQuery({ queryKey: ["roles"], queryFn: () => api<Role[]>("/roles") });
const [roleForm, setRoleForm] = useState({ name: "Helpdesk", permissions: "clusters:read,networks:read,audit:read" });
const [userForm, setUserForm] = useState({ email: "operator@nexafabric.local", display_name: "Operator", password: "ChangeMe_12345", role_id: "" });
const createRole = useMutation({
mutationFn: () => api<Role>("/roles", { method: "POST", body: JSON.stringify({ name: roleForm.name, permissions: roleForm.permissions.split(",").map((item) => item.trim()).filter(Boolean) }) }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["roles"] }),
});
const createUser = useMutation({
mutationFn: () => api<User>("/users", { method: "POST", body: JSON.stringify({ email: userForm.email, display_name: userForm.display_name, password: userForm.password, role_ids: userForm.role_id ? [userForm.role_id] : [] }) }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["users"] }),
});
async function submitRole(event: FormEvent) {
event.preventDefault();
await createRole.mutateAsync();
}
async function submitUser(event: FormEvent) {
event.preventDefault();
await createUser.mutateAsync();
}
return (
<>
<PageHeader title="Users & Roles" subtitle="Manage local users, roles, and permission sets." />
<div className="grid gap-4 xl:grid-cols-[340px_340px_1fr]">
<form onSubmit={submitRole} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 font-medium">Add Role</div>
<div className="grid gap-3">
<Field label="Name"><input className={inputClass} value={roleForm.name} onChange={(event) => setRoleForm({ ...roleForm, name: event.target.value })} /></Field>
<Field label="Permissions"><input className={inputClass} value={roleForm.permissions} onChange={(event) => setRoleForm({ ...roleForm, permissions: event.target.value })} /></Field>
<button className={buttonClass}><Plus size={16} /> Save Role</button>
</div>
</form>
<form onSubmit={submitUser} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><Users size={18} /> Add User</div>
<div className="grid gap-3">
<Field label="Email"><input className={inputClass} value={userForm.email} onChange={(event) => setUserForm({ ...userForm, email: event.target.value })} /></Field>
<Field label="Name"><input className={inputClass} value={userForm.display_name} onChange={(event) => setUserForm({ ...userForm, display_name: event.target.value })} /></Field>
<Field label="Password"><input className={inputClass} type="password" value={userForm.password} onChange={(event) => setUserForm({ ...userForm, password: event.target.value })} /></Field>
<Field label="Role"><select className={selectClass} value={userForm.role_id} onChange={(event) => setUserForm({ ...userForm, role_id: event.target.value })}><option value="">No role</option>{(roles.data ?? []).map((role) => <option key={role.id} value={role.id}>{role.name}</option>)}</select></Field>
<button className={buttonClass}><Plus size={16} /> Save User</button>
</div>
</form>
<section className="space-y-4">
<DataTable rows={(users.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "email", label: "Email" }, { key: "display_name", label: "Name" }, { key: "is_active", label: "Active" }]} />
<DataTable rows={(roles.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Role" }, { key: "permissions", label: "Permissions" }]} />
</section>
</div>
</>
);
}