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
+110
View File
@@ -25,6 +25,7 @@ from app.models.domain import (
SecurityGroup,
SecurityRule,
ServiceCatalogItem,
SystemSetting,
Subnet,
Tenant,
User,
@@ -54,12 +55,15 @@ from app.schemas.domain import (
ServiceCatalogRead,
SecurityGroupCreate,
SecurityGroupRead,
SetupCompleteRequest,
SetupStatus,
SubnetCreate,
SubnetRead,
TenantCreate,
TenantRead,
UserCreate,
UserRead,
WorkloadInsight,
WorkloadRead,
)
from app.services.audit import write_audit
@@ -79,6 +83,69 @@ def commit_or_400(db: Session) -> None:
raise HTTPException(status_code=409, detail="Resource conflicts with an existing record") from exc
def setup_setting(db: Session) -> SystemSetting:
setting = db.get(SystemSetting, "setup")
if not setting:
setting = SystemSetting(key="setup", value={"complete": False})
db.add(setting)
db.commit()
db.refresh(setting)
return setting
@api_router.get("/setup/status", response_model=SetupStatus)
def setup_status(db: Session = Depends(get_db)) -> SetupStatus:
setting = setup_setting(db)
return SetupStatus(
complete=bool((setting.value or {}).get("complete")),
has_users=bool(db.scalar(select(func.count()).select_from(User))),
has_clusters=bool(db.scalar(select(func.count()).select_from(Cluster))),
)
@api_router.post("/setup/complete", response_model=SetupStatus)
def complete_setup(payload: SetupCompleteRequest, db: Session = Depends(get_db)) -> SetupStatus:
setting = setup_setting(db)
if bool((setting.value or {}).get("complete")):
raise HTTPException(status_code=409, detail="Setup has already been completed")
super_admin = db.scalar(select(Role).where(Role.name == "Super Admin"))
if not super_admin:
super_admin = Role(name="Super Admin", permissions=["*"])
db.add(super_admin)
db.flush()
email = payload.admin_email.strip().lower()
admin = db.scalar(select(User).where(User.email == email))
if not admin:
admin = User(email=email, display_name=payload.admin_name, password_hash=hash_password(payload.admin_password))
db.add(admin)
admin.display_name = payload.admin_name
admin.password_hash = hash_password(payload.admin_password)
admin.is_active = True
if super_admin not in admin.roles:
admin.roles.append(super_admin)
if payload.cluster_name and payload.cluster_api_url and payload.cluster_api_token:
existing_cluster = db.scalar(select(Cluster).where(Cluster.name == payload.cluster_name))
if not existing_cluster:
db.add(
Cluster(
name=payload.cluster_name,
api_url=payload.cluster_api_url,
token_ref=payload.cluster_api_token,
provider=payload.cluster_provider,
mode=payload.cluster_mode,
verify_tls=payload.verify_tls,
)
)
setting.value = {"complete": True, "completed_at": datetime.utcnow().isoformat()}
db.add(AuditLog(user_id=admin.id, action="setup.completed", object_type="system", result="success"))
commit_or_400(db)
return setup_status(db)
@api_router.get("/dashboard")
def dashboard(_: CurrentUser, db: Session = Depends(get_db)) -> dict:
return {
@@ -260,6 +327,49 @@ def workloads(_: CurrentUser, db: Session = Depends(get_db)) -> list[Workload]:
return db.scalars(select(Workload).order_by(Workload.name)).all()
@api_router.get("/vms/{workload_id}/insights", response_model=WorkloadInsight)
def workload_insights(workload_id: str, _: CurrentUser, db: Session = Depends(get_db)) -> WorkloadInsight:
workload = db.get(Workload, workload_id)
if not workload:
raise HTTPException(status_code=404, detail="Workload not found")
policies = db.scalars(
select(Policy).where((Policy.project_id == workload.project_id) | (Policy.project_id.is_(None))).order_by(Policy.name)
).all()
traffic = [
{
"timestamp": datetime.utcnow().isoformat(),
"source": workload.name,
"destination": "finance-db-1" if "web" in workload.tags else "core-services-1",
"protocol": "tcp",
"port": 5432 if "web" in workload.tags else 22,
"bytes": 1489200,
"decision": "allowed",
},
{
"timestamp": datetime.utcnow().isoformat(),
"source": "unknown-external",
"destination": workload.name,
"protocol": "tcp",
"port": 3389,
"bytes": 22140,
"decision": "would_block",
},
]
audit_mode_notes = [
f"{policy.name} is in audit mode; matching traffic is logged without enforcement."
for policy in policies
if policy.enforcement_mode == "audit"
]
decision = "audit" if audit_mode_notes else "allowed"
return WorkloadInsight(
workload=workload,
traffic=traffic,
matching_policies=policies,
effective_decision=decision,
audit_mode_notes=audit_mode_notes,
)
@api_router.get("/networks", response_model=list[NetworkRead])
def networks(_: CurrentUser, db: Session = Depends(get_db)) -> list[Network]:
return db.scalars(select(Network).order_by(Network.name)).all()
+11 -1
View File
@@ -49,6 +49,13 @@ class TimestampMixin:
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class SystemSetting(Base, TimestampMixin):
__tablename__ = "system_settings"
key: Mapped[str] = mapped_column(String(100), primary_key=True)
value: Mapped[dict] = mapped_column(JSON, default=dict)
class User(Base, TimestampMixin):
__tablename__ = "users"
@@ -212,6 +219,10 @@ class Policy(Base, TimestampMixin):
definition: Mapped[dict] = mapped_column(JSON, default=dict)
last_compiled: Mapped[dict | None] = mapped_column(JSON)
@property
def enforcement_mode(self) -> str:
return (self.definition or {}).get("enforcement_mode", "enforced")
class ServiceCatalogItem(Base, TimestampMixin):
__tablename__ = "service_catalog"
@@ -251,4 +262,3 @@ class AuditLog(Base):
user_agent: Mapped[str | None] = mapped_column(String(512))
result: Mapped[str] = mapped_column(String(100), default="success")
error_text: Mapped[str | None] = mapped_column(Text)
+27
View File
@@ -19,6 +19,24 @@ class LoginRequest(BaseModel):
password: str
class SetupStatus(BaseModel):
complete: bool
has_users: bool
has_clusters: bool
class SetupCompleteRequest(BaseModel):
admin_email: str
admin_name: str
admin_password: str = Field(min_length=12)
cluster_name: str | None = None
cluster_api_url: str | None = None
cluster_api_token: str | None = None
cluster_provider: str = "proxmox"
cluster_mode: str = "read_only"
verify_tls: bool = True
class UserRead(OrmModel):
id: str
email: str
@@ -238,10 +256,19 @@ class PolicyRead(OrmModel):
name: str
version: int
enabled: bool
enforcement_mode: str
definition: dict[str, Any]
last_compiled: dict[str, Any] | None
class WorkloadInsight(BaseModel):
workload: WorkloadRead
traffic: list[dict[str, Any]]
matching_policies: list[PolicyRead]
effective_decision: str
audit_mode_notes: list[str]
class ServiceCatalogRead(OrmModel):
id: str
name: str
+5
View File
@@ -14,6 +14,7 @@ from app.models.domain import (
Role,
SecurityGroup,
ServiceCatalogItem,
SystemSetting,
Subnet,
Tenant,
User,
@@ -44,6 +45,10 @@ SERVICES = [
def seed_demo_data(db: Session) -> None:
if not db.get(SystemSetting, "setup"):
db.add(SystemSetting(key="setup", value={"complete": False}))
db.commit()
if db.scalar(select(User).where(User.email == "admin@nexafabric.local")):
return
+3 -1
View File
@@ -11,6 +11,7 @@ class PolicyEngine:
service = definition.get("service", {"protocol": "any", "ports": "any"})
action = definition.get("action", "allow")
direction = definition.get("direction", "ingress")
enforcement_mode = definition.get("enforcement_mode", "enforced")
generated_rule = {
"policy_id": policy.id,
@@ -21,6 +22,8 @@ class PolicyEngine:
"ports": service.get("ports", "any"),
"direction": direction,
"action": action,
"enforcement_mode": enforcement_mode,
"audit_only": enforcement_mode == "audit",
"logging": bool(definition.get("logging", False)),
"description": definition.get("description", policy.name),
}
@@ -32,4 +35,3 @@ class PolicyEngine:
warnings.append("Broad allow policy uses all ports.")
return {"rules": [generated_rule], "warnings": warnings, "conflicts": []}
+35 -10
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,29 +15,39 @@ 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();
export function App() {
const dark = useTheme((state) => state.dark);
function AppRoutes() {
const setup = useQuery({ queryKey: ["setup-status"], queryFn: () => publicApi<SetupStatus>("/setup/status") });
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
}, [dark]);
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 (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<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={<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="workloads" element={<Workloads />} />
<Route path="networks" element={<Networks />} />
<Route path="ipam" element={<Ipam />} />
<Route path="tenants" element={<TenantsProjects />} />
@@ -51,6 +62,20 @@ export function App() {
<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);
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
}, [dark]);
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<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>
);
}
+13 -6
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>
@@ -65,6 +72,7 @@ export function Clusters() {
<button className={buttonClass} disabled={create.isPending}>Save Cluster</button>
</div>
</form>
</Modal>
<section className="space-y-4">
<DataTable
rows={(clusters.data ?? []) as unknown as Record<string, unknown>[]}
@@ -89,4 +97,3 @@ export function Clusters() {
</>
);
}
+23 -6
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">
@@ -65,7 +80,9 @@ export function Ipam() {
<button className={buttonClass}><Plus size={16} /> Add Subnet</button>
</div>
</form>
<form onSubmit={submitIp} className="rounded-md border border-border bg-panel p-4">
</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">
@@ -84,8 +101,8 @@ export function Ipam() {
<button className={buttonClass}><Plus size={16} /> Save IP</button>
</div>
</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>
+11 -4
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>
@@ -68,9 +75,9 @@ export function Networks() {
<button className={buttonClass}><Plus size={16} /> Save Network</button>
</div>
</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>
</>
);
}
+19 -4
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>
</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() {
</>
);
}
+22 -6
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">
@@ -69,7 +83,9 @@ export function SecurityGroups() {
<button className={buttonClass}><Plus size={16} /> Save Group</button>
</div>
</form>
<form onSubmit={submitRule} className="rounded-md border border-border bg-panel p-4">
</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">
@@ -90,6 +106,7 @@ export function SecurityGroups() {
<button className={buttonClass} disabled={!selectedGroup}><Plus size={16} /> Save Rule</button>
</div>
</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() {
</>
);
}
+11 -4
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>
@@ -34,9 +41,9 @@ export function ServiceCatalog() {
<button className={buttonClass}><Plus size={16} /> Save Service</button>
</div>
</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>
);
}
+22 -6
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,8 +45,13 @@ 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>
@@ -45,7 +59,9 @@ export function TenantsProjects() {
<button className={buttonClass}><Plus size={16} /> Save Tenant</button>
</div>
</form>
<form onSubmit={submitProject} className="rounded-md border border-border bg-panel p-4">
</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>
@@ -54,6 +70,7 @@ export function TenantsProjects() {
<button className={buttonClass}><Plus size={16} /> Save Project</button>
</div>
</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() {
</>
);
}
+22 -6
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,8 +44,13 @@ 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>
@@ -44,7 +58,9 @@ export function UsersRoles() {
<button className={buttonClass}><Plus size={16} /> Save Role</button>
</div>
</form>
<form onSubmit={submitUser} className="rounded-md border border-border bg-panel p-4">
</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>
@@ -54,6 +70,7 @@ export function UsersRoles() {
<button className={buttonClass}><Plus size={16} /> Save User</button>
</div>
</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>
</>
);
}