feat: add initial setup wizard, workload insights, and policy audit mode
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:
+48
-23
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user