feat: add initial setup wizard, workload insights, and policy audit mode
CI / backend (push) Failing after 3s
CI / frontend (push) Failing after 29s

Add setup wizard with status tracking via SystemSetting model, implement /setup/status and /setup/complete endpoints to create initial admin user and optional cluster configuration, add workload insights endpoint with traffic analysis and policy matching including audit mode detection, implement enforcement_mode property on Policy model with audit/enforced states, add Modal component for dialogs, create SetupWizard page with multi
This commit is contained in:
2026-07-09 12:47:08 +02:00
parent a911d36f34
commit 3cd2c0a2f1
18 changed files with 600 additions and 79 deletions
+48 -23
View File
@@ -1,7 +1,8 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
import { useEffect } from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { publicApi, SetupStatus } from "./api/client";
import { Layout } from "./components/Layout";
import { Dashboard } from "./pages/Dashboard";
import { FirewallPreview } from "./pages/FirewallPreview";
@@ -14,12 +15,56 @@ import { Policies } from "./pages/Policies";
import { PolicyDesigner } from "./pages/PolicyDesigner";
import { SecurityGroups } from "./pages/SecurityGroups";
import { ServiceCatalog } from "./pages/ServiceCatalog";
import { SetupWizard } from "./pages/SetupWizard";
import { TenantsProjects } from "./pages/TenantsProjects";
import { UsersRoles } from "./pages/UsersRoles";
import { Workloads } from "./pages/Workloads";
import { useTheme } from "./stores/theme";
const queryClient = new QueryClient();
function AppRoutes() {
const setup = useQuery({ queryKey: ["setup-status"], queryFn: () => publicApi<SetupStatus>("/setup/status") });
if (setup.isLoading) {
return <div className="grid min-h-screen place-items-center bg-canvas text-sm">Loading NexaFabric...</div>;
}
if (!setup.data?.complete) {
return (
<Routes>
<Route path="/setup" element={<SetupWizard />} />
<Route path="*" element={<Navigate to="/setup" replace />} />
</Routes>
);
}
return (
<Routes>
<Route path="/setup" element={<Navigate to="/login" replace />} />
<Route path="/login" element={<Login />} />
<Route element={<Layout />}>
<Route index element={<Dashboard />} />
<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={<Workloads />} />
<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={<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>
);
}
export function App() {
const dark = useTheme((state) => state.dark);
@@ -30,27 +75,7 @@ export function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<Layout />}>
<Route index element={<Dashboard />} />
<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={<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={<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>
<AppRoutes />
</BrowserRouter>
</QueryClientProvider>
);
+41
View File
@@ -10,6 +10,12 @@ export type Dashboard = {
top_talkers: Array<{ name: string; bytes: number }>;
};
export type SetupStatus = {
complete: boolean;
has_users: boolean;
has_clusters: boolean;
};
export type Cluster = {
id: string;
name: string;
@@ -97,10 +103,31 @@ export type Policy = {
name: string;
version: number;
enabled: boolean;
enforcement_mode: string;
definition: Record<string, unknown>;
last_compiled: Record<string, unknown> | null;
};
export type Workload = {
id: string;
cluster_id: string;
node_id: string;
project_id: string | null;
external_id: string;
name: string;
kind: string;
status: string;
tags: string[];
};
export type WorkloadInsight = {
workload: Workload;
traffic: Array<Record<string, unknown>>;
matching_policies: Policy[];
effective_decision: string;
audit_mode_notes: string[];
};
export type ServiceCatalogItem = {
id: string;
name: string;
@@ -140,6 +167,20 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
return response.json() as Promise<T>;
}
export async function publicApi<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...init.headers,
},
});
if (!response.ok) {
throw new Error(await response.text());
}
return response.json() as Promise<T>;
}
export async function login(email: string, password: string) {
const data = await api<{ access_token: string }>("/auth/login", {
method: "POST",
+28
View File
@@ -0,0 +1,28 @@
import { ReactNode } from "react";
import { X } from "lucide-react";
type ModalProps = {
title: string;
open: boolean;
onClose: () => void;
children: ReactNode;
};
export function Modal({ title, open, onClose, children }: ModalProps) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 grid place-items-center bg-black/55 p-4">
<div className="max-h-[90vh] w-full max-w-xl overflow-y-auto rounded-md border border-border bg-panel shadow-xl">
<div className="sticky top-0 flex h-14 items-center justify-between border-b border-border bg-panel px-4">
<div className="font-medium">{title}</div>
<button className="rounded-md p-2 hover:bg-slate-100 dark:hover:bg-slate-800" onClick={onClose} aria-label="Close dialog" type="button">
<X size={18} />
</button>
</div>
<div className="p-4">{children}</div>
</div>
</div>
);
}
+14 -7
View File
@@ -1,10 +1,11 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Cable, RefreshCcw, Server } from "lucide-react";
import { Cable, Plus, 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 { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader";
export function Clusters() {
@@ -19,10 +20,14 @@ export function Clusters() {
verify_tls: true,
});
const [result, setResult] = useState("");
const [open, setOpen] = useState(false);
const create = useMutation({
mutationFn: () => api<Cluster>("/clusters", { method: "POST", body: JSON.stringify(form) }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clusters"] }),
onSuccess: () => {
setOpen(false);
queryClient.invalidateQueries({ queryKey: ["clusters"] });
},
});
async function submit(event: FormEvent) {
@@ -39,9 +44,11 @@ export function 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="space-y-4">
<button className={buttonClass} onClick={() => setOpen(true)}><Plus size={16} /> Add Cluster</button>
<Modal title="Add Cluster" open={open} onClose={() => setOpen(false)}>
<form onSubmit={submit}>
<div className="mb-4 flex items-center gap-2 font-medium"><Server size={18} /> Provider connection</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>
@@ -64,7 +71,8 @@ export function Clusters() {
</label>
<button className={buttonClass} disabled={create.isPending}>Save Cluster</button>
</div>
</form>
</form>
</Modal>
<section className="space-y-4">
<DataTable
rows={(clusters.data ?? []) as unknown as Record<string, unknown>[]}
@@ -89,4 +97,3 @@ export function Clusters() {
</>
);
}
+25 -8
View File
@@ -5,6 +5,7 @@ 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 { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader";
export function Ipam() {
@@ -14,14 +15,22 @@ export function Ipam() {
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 [subnetOpen, setSubnetOpen] = useState(false);
const [ipOpen, setIpOpen] = useState(false);
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"] }),
onSuccess: () => {
setSubnetOpen(false);
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"] }),
onSuccess: () => {
setIpOpen(false);
queryClient.invalidateQueries({ queryKey: ["addresses"] });
},
});
async function submitSubnet(event: FormEvent) {
@@ -50,8 +59,14 @@ export function Ipam() {
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="space-y-4">
<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={exportCsv}><Download size={16} /> Export CSV</button>
</div>
<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>
<div className="grid gap-3">
<Field label="Network">
@@ -64,8 +79,10 @@ export function Ipam() {
<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">
</form>
</Modal>
<Modal title="Reserve IP" open={ipOpen} onClose={() => setIpOpen(false)}>
<form onSubmit={submitIp}>
<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">
@@ -83,9 +100,9 @@ export function Ipam() {
<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>
</form>
</Modal>
<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>
+12 -5
View File
@@ -5,6 +5,7 @@ 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 { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader";
export function Networks() {
@@ -22,6 +23,7 @@ export function Networks() {
gateway: "10.50.0.1",
description: "Tenant VLAN",
});
const [open, setOpen] = useState(false);
const create = useMutation({
mutationFn: () =>
api<Network>("/networks", {
@@ -40,7 +42,10 @@ export function Networks() {
description: form.description,
}),
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["networks"] }),
onSuccess: () => {
setOpen(false);
queryClient.invalidateQueries({ queryKey: ["networks"] });
},
});
async function submit(event: FormEvent) {
@@ -51,8 +56,10 @@ export function Networks() {
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="space-y-4">
<button className={buttonClass} onClick={() => setOpen(true)}><Plus size={16} /> Add Network</button>
<Modal title="Add Network" open={open} onClose={() => setOpen(false)}>
<form onSubmit={submit}>
<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>
@@ -67,10 +74,10 @@ export function Networks() {
<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>
</form>
</Modal>
<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>
</>
);
}
+20 -5
View File
@@ -5,6 +5,7 @@ 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 { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader";
export function Policies() {
@@ -13,6 +14,7 @@ export function 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 [form, setForm] = useState({
project_id: "",
name: "Web to DB",
@@ -23,6 +25,7 @@ export function Policies() {
ports: "5432",
action: "allow",
direction: "egress",
enforcement_mode: "enforced",
logging: true,
description: "Allow application database traffic",
});
@@ -42,13 +45,17 @@ export function Policies() {
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: () => queryClient.invalidateQueries({ queryKey: ["policies"] }),
onSuccess: () => {
setOpen(false);
queryClient.invalidateQueries({ queryKey: ["policies"] });
},
});
async function submit(event: FormEvent) {
@@ -70,8 +77,10 @@ export function Policies() {
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="space-y-4">
<button className={buttonClass} onClick={() => setOpen(true)}><Plus size={16} /> Add Policy</button>
<Modal title="Add Policy" open={open} onClose={() => setOpen(false)}>
<form onSubmit={submit}>
<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>
@@ -89,10 +98,17 @@ export function Policies() {
<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} /> Save Policy</button>
</div>
</form>
</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">
@@ -109,4 +125,3 @@ export function Policies() {
</>
);
}
+24 -8
View File
@@ -5,6 +5,7 @@ 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 { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader";
export function SecurityGroups() {
@@ -30,14 +31,22 @@ export function SecurityGroups() {
logging: true,
description: "Allow HTTPS",
});
const [groupOpen, setGroupOpen] = useState(false);
const [ruleOpen, setRuleOpen] = useState(false);
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"] }),
onSuccess: () => {
setGroupOpen(false);
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] }),
onSuccess: () => {
setRuleOpen(false);
queryClient.invalidateQueries({ queryKey: ["security-rules", selectedGroup] });
},
});
async function submitGroup(event: FormEvent) {
@@ -54,8 +63,13 @@ export function SecurityGroups() {
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="space-y-4">
<div className="flex flex-wrap gap-2">
<button className={buttonClass} onClick={() => setGroupOpen(true)}><Plus size={16} /> Add Group</button>
<button className={buttonClass} onClick={() => setRuleOpen(true)} disabled={!selectedGroup}><Plus size={16} /> Add Rule</button>
</div>
<Modal title="Add Security Group" open={groupOpen} onClose={() => setGroupOpen(false)}>
<form onSubmit={submitGroup}>
<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">
@@ -68,8 +82,10 @@ export function SecurityGroups() {
<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">
</form>
</Modal>
<Modal title="Add Security Rule" open={ruleOpen} onClose={() => setRuleOpen(false)}>
<form onSubmit={submitRule}>
<div className="mb-4 font-medium">Add Rule</div>
<div className="grid gap-3">
<Field label="Security Group">
@@ -89,7 +105,8 @@ export function SecurityGroups() {
</div>
<button className={buttonClass} disabled={!selectedGroup}><Plus size={16} /> Save Rule</button>
</div>
</form>
</form>
</Modal>
<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" }]} />
@@ -98,4 +115,3 @@ export function SecurityGroups() {
</>
);
}
+12 -5
View File
@@ -5,15 +5,20 @@ 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 { Modal } from "../components/Modal";
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 [open, setOpen] = useState(false);
const create = useMutation({
mutationFn: () => api<ServiceCatalogItem>("/service-catalog", { method: "POST", body: JSON.stringify(form) }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["service-catalog"] }),
onSuccess: () => {
setOpen(false);
queryClient.invalidateQueries({ queryKey: ["service-catalog"] });
},
});
async function submit(event: FormEvent) {
@@ -24,8 +29,10 @@ export function ServiceCatalog() {
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="space-y-4">
<button className={buttonClass} onClick={() => setOpen(true)}><Plus size={16} /> Add Service</button>
<Modal title="Add Service" open={open} onClose={() => setOpen(false)}>
<form onSubmit={submit}>
<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>
@@ -33,10 +40,10 @@ export function ServiceCatalog() {
<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>
</form>
</Modal>
<DataTable rows={(services.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Name" }, { key: "protocol", label: "Protocol" }, { key: "ports", label: "Ports" }]} />
</div>
</>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { FormEvent, useState } from "react";
import { CheckCircle2, Server, ShieldCheck, Sparkles } from "lucide-react";
import { publicApi } from "../api/client";
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
export function SetupWizard() {
const [step, setStep] = useState(0);
const [error, setError] = useState("");
const [form, setForm] = useState({
admin_email: "admin@nexafabric.local",
admin_name: "NexaFabric Administrator",
admin_password: "ChangeMe_UseEnvInstead",
cluster_name: "Production Proxmox",
cluster_api_url: "https://pve.example.local:8006",
cluster_api_token: "",
cluster_provider: "proxmox",
cluster_mode: "read_only",
verify_tls: true,
});
async function complete(event: FormEvent) {
event.preventDefault();
setError("");
try {
await publicApi("/setup/complete", { method: "POST", body: JSON.stringify(form) });
window.location.href = "/login";
} catch (err) {
setError(err instanceof Error ? err.message : "Setup failed");
}
}
return (
<div className="min-h-screen bg-canvas px-4 py-8 text-slate-900 dark:text-slate-100">
<div className="mx-auto max-w-4xl">
<div className="mb-8 flex items-center gap-3">
<div className="rounded-md bg-accent p-3 text-white"><Sparkles size={24} /></div>
<div>
<h1 className="text-3xl font-semibold">Welcome to NexaFabric</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">Create your first administrator and connect your first provider.</p>
</div>
</div>
<div className="mb-6 grid gap-3 md:grid-cols-3">
{["Admin", "Provider", "Finish"].map((label, index) => (
<div key={label} className={`rounded-md border p-3 text-sm ${index === step ? "border-accent bg-panel" : "border-border bg-panel/60"}`}>
<div className="font-medium">{label}</div>
<div className="text-xs text-slate-500">{index < step ? "Done" : index === step ? "Current" : "Pending"}</div>
</div>
))}
</div>
<form onSubmit={complete} className="rounded-md border border-border bg-panel p-5">
{step === 0 ? (
<div className="grid gap-4">
<div className="flex items-center gap-2 font-medium"><ShieldCheck size={18} /> Super Admin</div>
<Field label="Email"><input className={inputClass} value={form.admin_email} onChange={(event) => setForm({ ...form, admin_email: event.target.value })} /></Field>
<Field label="Name"><input className={inputClass} value={form.admin_name} onChange={(event) => setForm({ ...form, admin_name: event.target.value })} /></Field>
<Field label="Password"><input className={inputClass} type="password" value={form.admin_password} onChange={(event) => setForm({ ...form, admin_password: event.target.value })} /></Field>
</div>
) : null}
{step === 1 ? (
<div className="grid gap-4">
<div className="flex items-center gap-2 font-medium"><Server size={18} /> First Provider</div>
<Field label="Cluster Name"><input className={inputClass} value={form.cluster_name} onChange={(event) => setForm({ ...form, cluster_name: event.target.value })} /></Field>
<Field label="API URL"><input className={inputClass} value={form.cluster_api_url} onChange={(event) => setForm({ ...form, cluster_api_url: event.target.value })} /></Field>
<Field label="API Token"><input className={inputClass} value={form.cluster_api_token} onChange={(event) => setForm({ ...form, cluster_api_token: event.target.value })} placeholder="PVEAPIToken=..." /></Field>
<div className="grid gap-3 md:grid-cols-2">
<Field label="Provider"><select className={selectClass} value={form.cluster_provider} onChange={(event) => setForm({ ...form, cluster_provider: event.target.value })}><option>proxmox</option><option>demo</option></select></Field>
<Field label="Mode"><select className={selectClass} value={form.cluster_mode} onChange={(event) => setForm({ ...form, cluster_mode: event.target.value })}><option value="read_only">read_only</option><option value="write_enabled">write_enabled</option></select></Field>
</div>
</div>
) : null}
{step === 2 ? (
<div className="grid gap-4">
<div className="flex items-center gap-2 font-medium"><CheckCircle2 size={18} /> Ready</div>
<div className="rounded-md border border-border bg-canvas p-4 text-sm">
NexaFabric will create the administrator, store the provider in read-only mode by default, and open the login screen.
</div>
</div>
) : null}
{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))} disabled={step === 0}>Back</button>
{step < 2 ? <button className={buttonClass} type="button" onClick={() => setStep(step + 1)}>Next</button> : <button className={buttonClass}>Complete Setup</button>}
</div>
</form>
</div>
</div>
);
}
+24 -8
View File
@@ -5,6 +5,7 @@ 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 { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader";
export function TenantsProjects() {
@@ -13,14 +14,22 @@ export function TenantsProjects() {
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 [tenantOpen, setTenantOpen] = useState(false);
const [projectOpen, setProjectOpen] = useState(false);
const createTenant = useMutation({
mutationFn: () => api<Tenant>("/tenants", { method: "POST", body: JSON.stringify(tenantForm) }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tenants"] }),
onSuccess: () => {
setTenantOpen(false);
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"] }),
onSuccess: () => {
setProjectOpen(false);
queryClient.invalidateQueries({ queryKey: ["projects"] });
},
});
async function submitTenant(event: FormEvent) {
@@ -36,16 +45,23 @@ export function TenantsProjects() {
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="space-y-4">
<div className="flex flex-wrap gap-2">
<button className={buttonClass} onClick={() => setTenantOpen(true)}><Plus size={16} /> Add Tenant</button>
<button className={buttonClass} onClick={() => setProjectOpen(true)}><Plus size={16} /> Add Project</button>
</div>
<Modal title="Add Tenant" open={tenantOpen} onClose={() => setTenantOpen(false)}>
<form onSubmit={submitTenant}>
<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">
</form>
</Modal>
<Modal title="Add Project" open={projectOpen} onClose={() => setProjectOpen(false)}>
<form onSubmit={submitProject}>
<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>
@@ -53,7 +69,8 @@ export function TenantsProjects() {
<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>
</form>
</Modal>
<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" }]} />
@@ -62,4 +79,3 @@ export function TenantsProjects() {
</>
);
}
+24 -8
View File
@@ -5,6 +5,7 @@ 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 { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader";
export function UsersRoles() {
@@ -13,13 +14,21 @@ export function UsersRoles() {
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 [roleOpen, setRoleOpen] = useState(false);
const [userOpen, setUserOpen] = useState(false);
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"] }),
onSuccess: () => {
setRoleOpen(false);
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"] }),
onSuccess: () => {
setUserOpen(false);
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
async function submitRole(event: FormEvent) {
@@ -35,16 +44,23 @@ export function UsersRoles() {
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="space-y-4">
<div className="flex flex-wrap gap-2">
<button className={buttonClass} onClick={() => setRoleOpen(true)}><Plus size={16} /> Add Role</button>
<button className={buttonClass} onClick={() => setUserOpen(true)}><Plus size={16} /> Add User</button>
</div>
<Modal title="Add Role" open={roleOpen} onClose={() => setRoleOpen(false)}>
<form onSubmit={submitRole}>
<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">
</form>
</Modal>
<Modal title="Add User" open={userOpen} onClose={() => setUserOpen(false)}>
<form onSubmit={submitUser}>
<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>
@@ -53,7 +69,8 @@ export function UsersRoles() {
<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>
</form>
</Modal>
<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" }]} />
@@ -62,4 +79,3 @@ export function UsersRoles() {
</>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Activity, Server } 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() {
const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") });
const [selectedId, setSelectedId] = useState("");
const selected = selectedId || workloads.data?.[0]?.id || "";
const insight = useQuery({
queryKey: ["workload-insight", selected],
queryFn: () => api<WorkloadInsight>(`/vms/${selected}/insights`),
enabled: Boolean(selected),
});
return (
<>
<PageHeader title="VMs/LXCs" subtitle="Inspect workloads, observed traffic, and matching policy decisions." />
<div className="grid gap-4 xl:grid-cols-[1fr_440px]">
<section className="space-y-3">
<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" }]}
/>
<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>
{insight.data ? (
<div className="space-y-4 text-sm">
<div>
<div className="text-lg font-semibold">{insight.data.workload.name}</div>
<div className="text-slate-500">{insight.data.workload.kind} · {insight.data.workload.status} · decision {insight.data.effective_decision}</div>
</div>
<section>
<div className="mb-2 font-medium">Traffic</div>
<div className="space-y-2">
{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>
</div>
))}
</div>
</section>
<section>
<div className="mb-2 font-medium">Matching Policies</div>
<div className="space-y-2">
{insight.data.matching_policies.map((policy) => (
<div key={policy.id} className="rounded-md border border-border p-3">
<div>{policy.name}</div>
<div className="text-xs text-slate-500">v{policy.version} · {policy.enforcement_mode}</div>
</div>
))}
</div>
</section>
{insight.data.audit_mode_notes.length ? (
<section>
<div className="mb-2 font-medium">Audit Mode</div>
{insight.data.audit_mode_notes.map((note) => <div key={note} className="rounded-md border border-border p-3 text-xs">{note}</div>)}
</section>
) : null}
</div>
) : (
<div className="text-sm text-slate-500">Select a workload.</div>
)}
</aside>
</div>
</>
);
}