-
-
- {preview.data ? JSON.stringify(preview.data, null, 2) : "No preview generated yet."}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {apply.data ? JSON.stringify(apply.data, null, 2) : preview.data ? JSON.stringify(preview.data, null, 2) : "No firewall output yet."}
>
);
}
-
diff --git a/frontend/src/pages/Ipam.tsx b/frontend/src/pages/Ipam.tsx
new file mode 100644
index 0000000..0601649
--- /dev/null
+++ b/frontend/src/pages/Ipam.tsx
@@ -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("/networks") });
+ const subnets = useQuery({ queryKey: ["subnets"], queryFn: () => api("/ipam/subnets") });
+ const addresses = useQuery({ queryKey: ["addresses"], queryFn: () => api("/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("/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("/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 (
+ <>
+
+
+
+
+
+
+ []} columns={[{ key: "address", label: "Address" }, { key: "status", label: "Status" }, { key: "note", label: "Note" }]} />
+
+
+ >
+ );
+}
diff --git a/frontend/src/pages/Networks.tsx b/frontend/src/pages/Networks.tsx
new file mode 100644
index 0000000..905c121
--- /dev/null
+++ b/frontend/src/pages/Networks.tsx
@@ -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("/clusters") });
+ const projects = useQuery({ queryKey: ["projects"], queryFn: () => api("/projects") });
+ const networks = useQuery({ queryKey: ["networks"], queryFn: () => api("/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("/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 (
+ <>
+
+
+
+
[]} columns={[{ key: "name", label: "Name" }, { key: "kind", label: "Kind" }, { key: "vlan_id", label: "VLAN" }, { key: "gateway", label: "Gateway" }, { key: "mtu", label: "MTU" }]} />
+
+ >
+ );
+}
+
diff --git a/frontend/src/pages/Policies.tsx b/frontend/src/pages/Policies.tsx
new file mode 100644
index 0000000..9a6e337
--- /dev/null
+++ b/frontend/src/pages/Policies.tsx
@@ -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("/policies") });
+ const projects = useQuery({ queryKey: ["projects"], queryFn: () => api("/projects") });
+ const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api("/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("/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(`/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>(`/firewall/preview/${policy.id}`, { method: "POST" });
+ setPreview(JSON.stringify(data, null, 2));
+ }
+
+ return (
+ <>
+
+
+
+
+ []} columns={[{ key: "name", label: "Policy" }, { key: "version", label: "Version" }, { key: "enabled", label: "Enabled" }]} />
+
+ {(policies.data ?? []).map((policy) => (
+
+
+
+
+ ))}
+
+ {preview || "No policy output yet."}
+
+
+ >
+ );
+}
+
diff --git a/frontend/src/pages/SecurityGroups.tsx b/frontend/src/pages/SecurityGroups.tsx
new file mode 100644
index 0000000..be95c29
--- /dev/null
+++ b/frontend/src/pages/SecurityGroups.tsx
@@ -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("/projects") });
+ const groups = useQuery({ queryKey: ["security-groups"], queryFn: () => api("/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(`/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("/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("/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 (
+ <>
+
+
+
+
+
+ []} columns={[{ key: "name", label: "Group" }, { key: "description", label: "Description" }]} />
+ []} columns={[{ key: "priority", label: "Priority" }, { key: "direction", label: "Direction" }, { key: "action", label: "Action" }, { key: "protocol", label: "Protocol" }, { key: "port", label: "Port" }]} />
+
+
+ >
+ );
+}
+
diff --git a/frontend/src/pages/ServiceCatalog.tsx b/frontend/src/pages/ServiceCatalog.tsx
new file mode 100644
index 0000000..cbb911d
--- /dev/null
+++ b/frontend/src/pages/ServiceCatalog.tsx
@@ -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("/service-catalog") });
+ const [form, setForm] = useState({ name: "Custom API", protocol: "tcp", ports: "8443", editable: true });
+ const create = useMutation({
+ mutationFn: () => api("/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 (
+ <>
+
+
+
+
[]} columns={[{ key: "name", label: "Name" }, { key: "protocol", label: "Protocol" }, { key: "ports", label: "Ports" }]} />
+
+ >
+ );
+}
+
diff --git a/frontend/src/pages/TenantsProjects.tsx b/frontend/src/pages/TenantsProjects.tsx
new file mode 100644
index 0000000..5f0d6f4
--- /dev/null
+++ b/frontend/src/pages/TenantsProjects.tsx
@@ -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("/tenants") });
+ const projects = useQuery({ queryKey: ["projects"], queryFn: () => api("/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("/tenants", { method: "POST", body: JSON.stringify(tenantForm) }),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tenants"] }),
+ });
+ const createProject = useMutation({
+ mutationFn: () => api("/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 (
+ <>
+
+
+
+
+
+ []} columns={[{ key: "name", label: "Tenant" }, { key: "description", label: "Description" }]} />
+ []} columns={[{ key: "name", label: "Project" }, { key: "description", label: "Description" }]} />
+
+
+ >
+ );
+}
+
diff --git a/frontend/src/pages/UsersRoles.tsx b/frontend/src/pages/UsersRoles.tsx
new file mode 100644
index 0000000..631f4a1
--- /dev/null
+++ b/frontend/src/pages/UsersRoles.tsx
@@ -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("/users") });
+ const roles = useQuery({ queryKey: ["roles"], queryFn: () => api("/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("/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("/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 (
+ <>
+
+
+
+
+
+ []} columns={[{ key: "email", label: "Email" }, { key: "display_name", label: "Name" }, { key: "is_active", label: "Active" }]} />
+ []} columns={[{ key: "name", label: "Role" }, { key: "permissions", label: "Permissions" }]} />
+
+
+ >
+ );
+}
+