From 3cd2c0a2f1630f20f286affda25cff6072ea2137 Mon Sep 17 00:00:00 2001 From: nessi Date: Thu, 9 Jul 2026 12:47:08 +0200 Subject: [PATCH] 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 --- backend/app/api/v1/router.py | 110 +++++++++++++++++++++++++ backend/app/models/domain.py | 12 ++- backend/app/schemas/domain.py | 27 ++++++ backend/app/seed/demo.py | 5 ++ backend/app/services/policy_engine.py | 4 +- frontend/src/App.tsx | 71 ++++++++++------ frontend/src/api/client.ts | 41 +++++++++ frontend/src/components/Modal.tsx | 28 +++++++ frontend/src/pages/Clusters.tsx | 21 +++-- frontend/src/pages/Ipam.tsx | 33 ++++++-- frontend/src/pages/Networks.tsx | 17 ++-- frontend/src/pages/Policies.tsx | 25 ++++-- frontend/src/pages/SecurityGroups.tsx | 32 +++++-- frontend/src/pages/ServiceCatalog.tsx | 17 ++-- frontend/src/pages/SetupWizard.tsx | 89 ++++++++++++++++++++ frontend/src/pages/TenantsProjects.tsx | 32 +++++-- frontend/src/pages/UsersRoles.tsx | 32 +++++-- frontend/src/pages/Workloads.tsx | 83 +++++++++++++++++++ 18 files changed, 600 insertions(+), 79 deletions(-) create mode 100644 frontend/src/components/Modal.tsx create mode 100644 frontend/src/pages/SetupWizard.tsx create mode 100644 frontend/src/pages/Workloads.tsx diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 77ac7be..382086e 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -25,6 +25,7 @@ from app.models.domain import ( SecurityGroup, SecurityRule, ServiceCatalogItem, + SystemSetting, Subnet, Tenant, User, @@ -54,12 +55,15 @@ from app.schemas.domain import ( ServiceCatalogRead, SecurityGroupCreate, SecurityGroupRead, + SetupCompleteRequest, + SetupStatus, SubnetCreate, SubnetRead, TenantCreate, TenantRead, UserCreate, UserRead, + WorkloadInsight, WorkloadRead, ) from app.services.audit import write_audit @@ -79,6 +83,69 @@ def commit_or_400(db: Session) -> None: raise HTTPException(status_code=409, detail="Resource conflicts with an existing record") from exc +def setup_setting(db: Session) -> SystemSetting: + setting = db.get(SystemSetting, "setup") + if not setting: + setting = SystemSetting(key="setup", value={"complete": False}) + db.add(setting) + db.commit() + db.refresh(setting) + return setting + + +@api_router.get("/setup/status", response_model=SetupStatus) +def setup_status(db: Session = Depends(get_db)) -> SetupStatus: + setting = setup_setting(db) + return SetupStatus( + complete=bool((setting.value or {}).get("complete")), + has_users=bool(db.scalar(select(func.count()).select_from(User))), + has_clusters=bool(db.scalar(select(func.count()).select_from(Cluster))), + ) + + +@api_router.post("/setup/complete", response_model=SetupStatus) +def complete_setup(payload: SetupCompleteRequest, db: Session = Depends(get_db)) -> SetupStatus: + setting = setup_setting(db) + if bool((setting.value or {}).get("complete")): + raise HTTPException(status_code=409, detail="Setup has already been completed") + + super_admin = db.scalar(select(Role).where(Role.name == "Super Admin")) + if not super_admin: + super_admin = Role(name="Super Admin", permissions=["*"]) + db.add(super_admin) + db.flush() + + email = payload.admin_email.strip().lower() + admin = db.scalar(select(User).where(User.email == email)) + if not admin: + admin = User(email=email, display_name=payload.admin_name, password_hash=hash_password(payload.admin_password)) + db.add(admin) + admin.display_name = payload.admin_name + admin.password_hash = hash_password(payload.admin_password) + admin.is_active = True + if super_admin not in admin.roles: + admin.roles.append(super_admin) + + if payload.cluster_name and payload.cluster_api_url and payload.cluster_api_token: + existing_cluster = db.scalar(select(Cluster).where(Cluster.name == payload.cluster_name)) + if not existing_cluster: + db.add( + Cluster( + name=payload.cluster_name, + api_url=payload.cluster_api_url, + token_ref=payload.cluster_api_token, + provider=payload.cluster_provider, + mode=payload.cluster_mode, + verify_tls=payload.verify_tls, + ) + ) + + setting.value = {"complete": True, "completed_at": datetime.utcnow().isoformat()} + db.add(AuditLog(user_id=admin.id, action="setup.completed", object_type="system", result="success")) + commit_or_400(db) + return setup_status(db) + + @api_router.get("/dashboard") def dashboard(_: CurrentUser, db: Session = Depends(get_db)) -> dict: return { @@ -260,6 +327,49 @@ def workloads(_: CurrentUser, db: Session = Depends(get_db)) -> list[Workload]: return db.scalars(select(Workload).order_by(Workload.name)).all() +@api_router.get("/vms/{workload_id}/insights", response_model=WorkloadInsight) +def workload_insights(workload_id: str, _: CurrentUser, db: Session = Depends(get_db)) -> WorkloadInsight: + workload = db.get(Workload, workload_id) + if not workload: + raise HTTPException(status_code=404, detail="Workload not found") + policies = db.scalars( + select(Policy).where((Policy.project_id == workload.project_id) | (Policy.project_id.is_(None))).order_by(Policy.name) + ).all() + traffic = [ + { + "timestamp": datetime.utcnow().isoformat(), + "source": workload.name, + "destination": "finance-db-1" if "web" in workload.tags else "core-services-1", + "protocol": "tcp", + "port": 5432 if "web" in workload.tags else 22, + "bytes": 1489200, + "decision": "allowed", + }, + { + "timestamp": datetime.utcnow().isoformat(), + "source": "unknown-external", + "destination": workload.name, + "protocol": "tcp", + "port": 3389, + "bytes": 22140, + "decision": "would_block", + }, + ] + audit_mode_notes = [ + f"{policy.name} is in audit mode; matching traffic is logged without enforcement." + for policy in policies + if policy.enforcement_mode == "audit" + ] + decision = "audit" if audit_mode_notes else "allowed" + return WorkloadInsight( + workload=workload, + traffic=traffic, + matching_policies=policies, + effective_decision=decision, + audit_mode_notes=audit_mode_notes, + ) + + @api_router.get("/networks", response_model=list[NetworkRead]) def networks(_: CurrentUser, db: Session = Depends(get_db)) -> list[Network]: return db.scalars(select(Network).order_by(Network.name)).all() diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index a075a97..63f753d 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -49,6 +49,13 @@ class TimestampMixin: updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) +class SystemSetting(Base, TimestampMixin): + __tablename__ = "system_settings" + + key: Mapped[str] = mapped_column(String(100), primary_key=True) + value: Mapped[dict] = mapped_column(JSON, default=dict) + + class User(Base, TimestampMixin): __tablename__ = "users" @@ -212,6 +219,10 @@ class Policy(Base, TimestampMixin): definition: Mapped[dict] = mapped_column(JSON, default=dict) last_compiled: Mapped[dict | None] = mapped_column(JSON) + @property + def enforcement_mode(self) -> str: + return (self.definition or {}).get("enforcement_mode", "enforced") + class ServiceCatalogItem(Base, TimestampMixin): __tablename__ = "service_catalog" @@ -251,4 +262,3 @@ class AuditLog(Base): user_agent: Mapped[str | None] = mapped_column(String(512)) result: Mapped[str] = mapped_column(String(100), default="success") error_text: Mapped[str | None] = mapped_column(Text) - diff --git a/backend/app/schemas/domain.py b/backend/app/schemas/domain.py index b96ed11..7149b5f 100644 --- a/backend/app/schemas/domain.py +++ b/backend/app/schemas/domain.py @@ -19,6 +19,24 @@ class LoginRequest(BaseModel): password: str +class SetupStatus(BaseModel): + complete: bool + has_users: bool + has_clusters: bool + + +class SetupCompleteRequest(BaseModel): + admin_email: str + admin_name: str + admin_password: str = Field(min_length=12) + cluster_name: str | None = None + cluster_api_url: str | None = None + cluster_api_token: str | None = None + cluster_provider: str = "proxmox" + cluster_mode: str = "read_only" + verify_tls: bool = True + + class UserRead(OrmModel): id: str email: str @@ -238,10 +256,19 @@ class PolicyRead(OrmModel): name: str version: int enabled: bool + enforcement_mode: str definition: dict[str, Any] last_compiled: dict[str, Any] | None +class WorkloadInsight(BaseModel): + workload: WorkloadRead + traffic: list[dict[str, Any]] + matching_policies: list[PolicyRead] + effective_decision: str + audit_mode_notes: list[str] + + class ServiceCatalogRead(OrmModel): id: str name: str diff --git a/backend/app/seed/demo.py b/backend/app/seed/demo.py index d85a1af..976870a 100644 --- a/backend/app/seed/demo.py +++ b/backend/app/seed/demo.py @@ -14,6 +14,7 @@ from app.models.domain import ( Role, SecurityGroup, ServiceCatalogItem, + SystemSetting, Subnet, Tenant, User, @@ -44,6 +45,10 @@ SERVICES = [ def seed_demo_data(db: Session) -> None: + if not db.get(SystemSetting, "setup"): + db.add(SystemSetting(key="setup", value={"complete": False})) + db.commit() + if db.scalar(select(User).where(User.email == "admin@nexafabric.local")): return diff --git a/backend/app/services/policy_engine.py b/backend/app/services/policy_engine.py index 7b442be..d86ef3f 100644 --- a/backend/app/services/policy_engine.py +++ b/backend/app/services/policy_engine.py @@ -11,6 +11,7 @@ class PolicyEngine: service = definition.get("service", {"protocol": "any", "ports": "any"}) action = definition.get("action", "allow") direction = definition.get("direction", "ingress") + enforcement_mode = definition.get("enforcement_mode", "enforced") generated_rule = { "policy_id": policy.id, @@ -21,6 +22,8 @@ class PolicyEngine: "ports": service.get("ports", "any"), "direction": direction, "action": action, + "enforcement_mode": enforcement_mode, + "audit_only": enforcement_mode == "audit", "logging": bool(definition.get("logging", False)), "description": definition.get("description", policy.name), } @@ -32,4 +35,3 @@ class PolicyEngine: warnings.append("Broad allow policy uses all ports.") return {"rules": [generated_rule], "warnings": warnings, "conflicts": []} - diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5e8a7dd..0db18f5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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("/setup/status") }); + + if (setup.isLoading) { + return
Loading NexaFabric...
; + } + + if (!setup.data?.complete) { + return ( + + } /> + } /> + + ); + } + + return ( + + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} + export function App() { const dark = useTheme((state) => state.dark); @@ -30,27 +75,7 @@ export function App() { return ( - - } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + ); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 9232ed2..de02c4b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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; last_compiled: Record | 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>; + 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(path: string, init: RequestInit = {}): Promise { return response.json() as Promise; } +export async function publicApi(path: string, init: RequestInit = {}): Promise { + 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; +} + export async function login(email: string, password: string) { const data = await api<{ access_token: string }>("/auth/login", { method: "POST", diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx new file mode 100644 index 0000000..d0d5df3 --- /dev/null +++ b/frontend/src/components/Modal.tsx @@ -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 ( +
+
+
+
{title}
+ +
+
{children}
+
+
+ ); +} + diff --git a/frontend/src/pages/Clusters.tsx b/frontend/src/pages/Clusters.tsx index 416a980..ea56a4e 100644 --- a/frontend/src/pages/Clusters.tsx +++ b/frontend/src/pages/Clusters.tsx @@ -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("/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 ( <> -
-
-
Add Cluster
+
+ + setOpen(false)}> + +
Provider connection
setForm({ ...form, name: event.target.value })} /> setForm({ ...form, api_url: event.target.value })} /> @@ -64,7 +71,8 @@ export function Clusters() {
- + +
[]} @@ -89,4 +97,3 @@ export function Clusters() { ); } - diff --git a/frontend/src/pages/Ipam.tsx b/frontend/src/pages/Ipam.tsx index 0601649..fda31e6 100644 --- a/frontend/src/pages/Ipam.tsx +++ b/frontend/src/pages/Ipam.tsx @@ -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("/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("/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("/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 ( <> -
-
+
+
+ + + +
+ setSubnetOpen(false)}> +
Add Subnet
@@ -64,8 +79,10 @@ export function Ipam() { setSubnetForm({ ...subnetForm, gateway: event.target.value })} />
- -
+
+
+ setIpOpen(false)}> +
Reserve IP
@@ -83,9 +100,9 @@ export function Ipam() { setIpForm({ ...ipForm, note: event.target.value })} />
-
+ +
- []} 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 index 905c121..8ea1308 100644 --- a/frontend/src/pages/Networks.tsx +++ b/frontend/src/pages/Networks.tsx @@ -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("/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 ( <> -
-
+
+ + setOpen(false)}> +
Add Network
@@ -67,10 +74,10 @@ export function Networks() { setForm({ ...form, description: event.target.value })} />
- + +
[]} 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 index 9a6e337..0cdb828 100644 --- a/frontend/src/pages/Policies.tsx +++ b/frontend/src/pages/Policies.tsx @@ -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("/projects") }); const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api("/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 ( <> -
-
+
+ + setOpen(false)}> +
Add Policy
@@ -89,10 +98,17 @@ export function Policies() {
+ + + setForm({ ...form, description: event.target.value })} />
-
+ +
[]} columns={[{ key: "name", label: "Policy" }, { key: "version", label: "Version" }, { key: "enabled", label: "Enabled" }]} />
@@ -109,4 +125,3 @@ export function Policies() { ); } - diff --git a/frontend/src/pages/SecurityGroups.tsx b/frontend/src/pages/SecurityGroups.tsx index be95c29..0a7e243 100644 --- a/frontend/src/pages/SecurityGroups.tsx +++ b/frontend/src/pages/SecurityGroups.tsx @@ -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("/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("/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 ( <> -
-
+
+
+ + +
+ setGroupOpen(false)}> +
Add Group
@@ -68,8 +82,10 @@ export function SecurityGroups() { setGroupForm({ ...groupForm, description: event.target.value })} />
- -
+
+
+ setRuleOpen(false)}> +
Add Rule
@@ -89,7 +105,8 @@ export function SecurityGroups() {
- + +
[]} 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" }]} /> @@ -98,4 +115,3 @@ export function SecurityGroups() { ); } - diff --git a/frontend/src/pages/ServiceCatalog.tsx b/frontend/src/pages/ServiceCatalog.tsx index cbb911d..ecc3641 100644 --- a/frontend/src/pages/ServiceCatalog.tsx +++ b/frontend/src/pages/ServiceCatalog.tsx @@ -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("/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("/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 ( <> -
-
+
+ + setOpen(false)}> +
Add Service
setForm({ ...form, name: event.target.value })} /> @@ -33,10 +40,10 @@ export function ServiceCatalog() { setForm({ ...form, ports: event.target.value })} />
- + +
[]} columns={[{ key: "name", label: "Name" }, { key: "protocol", label: "Protocol" }, { key: "ports", label: "Ports" }]} />
); } - diff --git a/frontend/src/pages/SetupWizard.tsx b/frontend/src/pages/SetupWizard.tsx new file mode 100644 index 0000000..3830404 --- /dev/null +++ b/frontend/src/pages/SetupWizard.tsx @@ -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 ( +
+
+
+
+
+

Welcome to NexaFabric

+

Create your first administrator and connect your first provider.

+
+
+
+ {["Admin", "Provider", "Finish"].map((label, index) => ( +
+
{label}
+
{index < step ? "Done" : index === step ? "Current" : "Pending"}
+
+ ))} +
+
+ {step === 0 ? ( +
+
Super Admin
+ setForm({ ...form, admin_email: event.target.value })} /> + setForm({ ...form, admin_name: event.target.value })} /> + setForm({ ...form, admin_password: event.target.value })} /> +
+ ) : null} + {step === 1 ? ( +
+
First Provider
+ setForm({ ...form, cluster_name: event.target.value })} /> + setForm({ ...form, cluster_api_url: event.target.value })} /> + setForm({ ...form, cluster_api_token: event.target.value })} placeholder="PVEAPIToken=..." /> +
+ + +
+
+ ) : null} + {step === 2 ? ( +
+
Ready
+
+ NexaFabric will create the administrator, store the provider in read-only mode by default, and open the login screen. +
+
+ ) : null} + {error ?
{error}
: null} +
+ + {step < 2 ? : } +
+
+
+
+ ); +} diff --git a/frontend/src/pages/TenantsProjects.tsx b/frontend/src/pages/TenantsProjects.tsx index 5f0d6f4..c600842 100644 --- a/frontend/src/pages/TenantsProjects.tsx +++ b/frontend/src/pages/TenantsProjects.tsx @@ -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("/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("/tenants", { method: "POST", body: JSON.stringify(tenantForm) }), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tenants"] }), + onSuccess: () => { + setTenantOpen(false); + 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"] }), + onSuccess: () => { + setProjectOpen(false); + queryClient.invalidateQueries({ queryKey: ["projects"] }); + }, }); async function submitTenant(event: FormEvent) { @@ -36,16 +45,23 @@ export function TenantsProjects() { return ( <> -
-
+
+
+ + +
+ setTenantOpen(false)}> +
Add Tenant
setTenantForm({ ...tenantForm, name: event.target.value })} /> setTenantForm({ ...tenantForm, description: event.target.value })} />
- -
+
+
+ setProjectOpen(false)}> +
Add Project
@@ -53,7 +69,8 @@ export function TenantsProjects() { setProjectForm({ ...projectForm, description: event.target.value })} />
-
+ +
[]} columns={[{ key: "name", label: "Tenant" }, { key: "description", label: "Description" }]} /> []} columns={[{ key: "name", label: "Project" }, { key: "description", label: "Description" }]} /> @@ -62,4 +79,3 @@ export function TenantsProjects() { ); } - diff --git a/frontend/src/pages/UsersRoles.tsx b/frontend/src/pages/UsersRoles.tsx index 631f4a1..6964513 100644 --- a/frontend/src/pages/UsersRoles.tsx +++ b/frontend/src/pages/UsersRoles.tsx @@ -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("/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("/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("/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 ( <> -
-
+
+
+ + +
+ setRoleOpen(false)}> +
Add Role
setRoleForm({ ...roleForm, name: event.target.value })} /> setRoleForm({ ...roleForm, permissions: event.target.value })} />
- -
+
+
+ setUserOpen(false)}> +
Add User
setUserForm({ ...userForm, email: event.target.value })} /> @@ -53,7 +69,8 @@ export function UsersRoles() {
-
+ +
[]} columns={[{ key: "email", label: "Email" }, { key: "display_name", label: "Name" }, { key: "is_active", label: "Active" }]} /> []} columns={[{ key: "name", label: "Role" }, { key: "permissions", label: "Permissions" }]} /> @@ -62,4 +79,3 @@ export function UsersRoles() { ); } - diff --git a/frontend/src/pages/Workloads.tsx b/frontend/src/pages/Workloads.tsx new file mode 100644 index 0000000..c3b5b42 --- /dev/null +++ b/frontend/src/pages/Workloads.tsx @@ -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("/vms") }); + const [selectedId, setSelectedId] = useState(""); + const selected = selectedId || workloads.data?.[0]?.id || ""; + const insight = useQuery({ + queryKey: ["workload-insight", selected], + queryFn: () => api(`/vms/${selected}/insights`), + enabled: Boolean(selected), + }); + + return ( + <> + +
+
+ []} + columns={[{ key: "name", label: "Name" }, { key: "kind", label: "Kind" }, { key: "status", label: "Status" }, { key: "external_id", label: "VMID" }]} + /> +
+ {(workloads.data ?? []).map((workload) => ( + + ))} +
+
+ +
+ + ); +} +