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