From 4554b00b73c049ff1ceb5e6f48b697c599322d14 Mon Sep 17 00:00:00 2001 From: nessi Date: Thu, 9 Jul 2026 12:57:07 +0200 Subject: [PATCH] feat: improve setup wizard UX, add cluster sync error handling, and conditional demo data seeding Add SEED_DEMO_DATA environment variable to control demo data population, enhance setup wizard with welcome screen and theme toggle, add smooth animations for wizard transitions, improve dashboard endpoint to return structured objects for last_syncs and faulty_nodes instead of raw models, implement comprehensive error handling in cluster sync with failed status tracking and audit logging, fix Proxmox provider --- .env.example | 1 + backend/app/api/v1/router.py | 50 ++++++++++++++---- backend/app/core/config.py | 1 + backend/app/seed/demo.py | 38 +++++++++----- backend/app/services/providers/proxmox.py | 16 ++++-- frontend/src/pages/SetupWizard.tsx | 64 +++++++++++++++++------ frontend/src/styles.css | 21 ++++++++ 7 files changed, 149 insertions(+), 42 deletions(-) diff --git a/.env.example b/.env.example index 51e7b0c..6669ced 100644 --- a/.env.example +++ b/.env.example @@ -10,4 +10,5 @@ JWT_REFRESH_TOKEN_DAYS=14 TOKEN_ENCRYPTION_KEY=change-this-fernet-key-before-production CORS_ORIGINS=http://localhost:5173,http://localhost:8080 DEMO_ADMIN_PASSWORD=ChangeMe_UseEnvInstead +SEED_DEMO_DATA=false VITE_API_BASE_URL=/api/v1 diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 382086e..9421353 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -148,14 +148,29 @@ def complete_setup(payload: SetupCompleteRequest, db: Session = Depends(get_db)) @api_router.get("/dashboard") def dashboard(_: CurrentUser, db: Session = Depends(get_db)) -> dict: + last_syncs = db.scalars(select(Cluster).order_by(Cluster.updated_at.desc()).limit(5)).all() + faulty_nodes = db.scalars(select(Node).where(Node.status != "online")).all() return { "clusters": db.scalar(select(func.count()).select_from(Cluster)), "nodes": db.scalar(select(func.count()).select_from(Node)), "workloads": db.scalar(select(func.count()).select_from(Workload)), "networks": db.scalar(select(func.count()).select_from(Network)), "open_policy_violations": 1, - "last_syncs": db.scalars(select(Cluster).order_by(Cluster.updated_at.desc()).limit(5)).all(), - "faulty_nodes": db.scalars(select(Node).where(Node.status != "online")).all(), + "last_syncs": [ + { + "id": cluster.id, + "name": cluster.name, + "provider": cluster.provider, + "status": cluster.last_sync_status, + "error": cluster.last_sync_error, + "at": cluster.last_sync_at.isoformat() if cluster.last_sync_at else None, + } + for cluster in last_syncs + ], + "faulty_nodes": [ + {"id": node.id, "name": node.name, "status": node.status, "cluster_id": node.cluster_id} + for node in faulty_nodes + ], "top_talkers": [ {"name": "finance-app-2", "bytes": 942000000}, {"name": "core-services-1", "bytes": 512000000}, @@ -246,14 +261,31 @@ async def sync_cluster(cluster_id: str, user: CurrentUser, db: Session = Depends if not cluster: raise HTTPException(status_code=404, detail="Cluster not found") provider = get_provider(cluster.provider) - inventory = await provider.sync_inventory( - ProviderConnection( - api_url=cluster.api_url, - token=cluster.token_ref or "", - verify_tls=cluster.verify_tls, - read_only=cluster.mode == "read_only", + try: + inventory = await provider.sync_inventory( + ProviderConnection( + api_url=cluster.api_url, + token=cluster.token_ref or "", + verify_tls=cluster.verify_tls, + read_only=cluster.mode == "read_only", + ) ) - ) + except Exception as exc: + cluster.last_sync_at = datetime.utcnow() + cluster.last_sync_status = "failed" + cluster.last_sync_error = str(exc) + db.add(Job(kind="proxmox.sync", status="failed", progress=100, logs=[f"Sync failed for {cluster.name}"], error=str(exc))) + db.commit() + write_audit( + db, + action="cluster.sync", + object_type="cluster", + object_id=cluster.id, + user_id=user.id, + result="failed", + error_text=str(exc), + ) + raise HTTPException(status_code=502, detail=f"Provider sync failed: {exc}") from exc cluster.last_sync_at = datetime.utcnow() cluster.last_sync_status = "success" cluster.last_sync_error = None diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 02b77b0..d63e4f2 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -18,6 +18,7 @@ class Settings(BaseSettings): jwt_refresh_token_days: int = 14 token_encryption_key: str = "dev-only-change-me" demo_admin_password: str = "ChangeMe_UseEnvInstead" + seed_demo_data: bool = False cors_origins: list[AnyHttpUrl] | list[str] = ["http://localhost:5173", "http://localhost:8080"] @field_validator("cors_origins", mode="before") diff --git a/backend/app/seed/demo.py b/backend/app/seed/demo.py index 976870a..b422133 100644 --- a/backend/app/seed/demo.py +++ b/backend/app/seed/demo.py @@ -45,29 +45,42 @@ SERVICES = [ def seed_demo_data(db: Session) -> None: + settings = get_settings() if not db.get(SystemSetting, "setup"): db.add(SystemSetting(key="setup", value={"complete": False})) - db.commit() + + roles = { + "Super Admin": ["*"], + "Network Admin": ["networks:*", "ipam:*", "clusters:read"], + "Security Admin": ["policies:*", "firewall:*", "security-groups:*"], + "Tenant Admin": ["tenants:read", "projects:*"], + "Auditor": ["audit:read", "clusters:read", "policies:read"], + "Read Only User": ["*:read"], + } + for name, permissions in roles.items(): + if not db.scalar(select(Role).where(Role.name == name)): + db.add(Role(name=name, permissions=permissions)) + + for name, proto, ports in SERVICES: + if not db.scalar(select(ServiceCatalogItem).where(ServiceCatalogItem.name == name)): + db.add(ServiceCatalogItem(name=name, protocol=proto, ports=ports, editable=True)) + + db.commit() + + if not settings.seed_demo_data: + return if db.scalar(select(User).where(User.email == "admin@nexafabric.local")): return - super_admin = Role(name="Super Admin", permissions=["*"]) - roles = [ - super_admin, - Role(name="Network Admin", permissions=["networks:*", "ipam:*", "clusters:read"]), - Role(name="Security Admin", permissions=["policies:*", "firewall:*", "security-groups:*"]), - Role(name="Tenant Admin", permissions=["tenants:read", "projects:*"]), - Role(name="Auditor", permissions=["audit:read", "clusters:read", "policies:read"]), - Role(name="Read Only User", permissions=["*:read"]), - ] + super_admin = db.scalar(select(Role).where(Role.name == "Super Admin")) user = User( email="admin@nexafabric.local", display_name="NexaFabric Administrator", - password_hash=hash_password(get_settings().demo_admin_password), + password_hash=hash_password(settings.demo_admin_password), roles=[super_admin], ) - db.add_all(roles + [user]) + db.add(user) tenants = [ Tenant(name="Platform", description="Shared infrastructure and platform services"), @@ -181,6 +194,5 @@ def seed_demo_data(db: Session) -> None: ] ) - db.add_all([ServiceCatalogItem(name=name, protocol=proto, ports=ports, editable=True) for name, proto, ports in SERVICES]) db.add(AuditLog(user_id=user.id, action="seed.created", object_type="system", result="success")) db.commit() diff --git a/backend/app/services/providers/proxmox.py b/backend/app/services/providers/proxmox.py index 0db1935..97f6eb8 100644 --- a/backend/app/services/providers/proxmox.py +++ b/backend/app/services/providers/proxmox.py @@ -8,20 +8,28 @@ from app.services.providers.base import Provider, ProviderConnection class ProxmoxProvider(Provider): name = "proxmox" + def auth_header(self, token: str) -> str: + token = token.strip() + if token.startswith("PVEAPIToken="): + return token + return f"PVEAPIToken={token}" + async def test_connection(self, connection: ProviderConnection) -> dict[str, Any]: + headers = {"Authorization": self.auth_header(connection.token)} async with httpx.AsyncClient(verify=connection.verify_tls, timeout=10) as client: response = await client.get( f"{connection.api_url.rstrip('/')}/api2/json/version", - headers={"Authorization": connection.token}, + headers=headers, ) response.raise_for_status() return response.json().get("data", {}) async def sync_inventory(self, connection: ProviderConnection) -> dict[str, list[dict[str, Any]]]: + headers = {"Authorization": self.auth_header(connection.token)} async with httpx.AsyncClient(verify=connection.verify_tls, timeout=20) as client: resources = await client.get( f"{connection.api_url.rstrip('/')}/api2/json/cluster/resources", - headers={"Authorization": connection.token}, + headers=headers, ) resources.raise_for_status() data = resources.json().get("data", []) @@ -32,10 +40,11 @@ class ProxmoxProvider(Provider): return {"nodes": nodes, "workloads": workloads, "networks": networks} async def list_networks(self, connection: ProviderConnection) -> list[dict[str, Any]]: + headers = {"Authorization": self.auth_header(connection.token)} async with httpx.AsyncClient(verify=connection.verify_tls, timeout=20) as client: resources = await client.get( f"{connection.api_url.rstrip('/')}/api2/json/cluster/resources", - headers={"Authorization": connection.token}, + headers=headers, ) resources.raise_for_status() data = resources.json().get("data", []) @@ -53,4 +62,3 @@ class ProxmoxProvider(Provider): if connection.read_only: return {"applied": False, "reason": "Cluster is read-only", "rules": rules} return {"applied": False, "reason": "Apply adapter intentionally requires explicit implementation", "rules": rules} - diff --git a/frontend/src/pages/SetupWizard.tsx b/frontend/src/pages/SetupWizard.tsx index 3830404..ffccee6 100644 --- a/frontend/src/pages/SetupWizard.tsx +++ b/frontend/src/pages/SetupWizard.tsx @@ -1,12 +1,14 @@ import { FormEvent, useState } from "react"; -import { CheckCircle2, Server, ShieldCheck, Sparkles } from "lucide-react"; +import { CheckCircle2, Moon, Server, ShieldCheck, Sparkles, Sun } from "lucide-react"; import { publicApi } from "../api/client"; import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls"; +import { useTheme } from "../stores/theme"; export function SetupWizard() { const [step, setStep] = useState(0); const [error, setError] = useState(""); + const { dark, toggle } = useTheme(); const [form, setForm] = useState({ admin_email: "admin@nexafabric.local", admin_name: "NexaFabric Administrator", @@ -31,34 +33,58 @@ export function SetupWizard() { } return ( -
+
+
-
+ {step === 0 ? ( +
+
+
+ +
+

Welcome to NexaFabric

+

+ A guided setup will create your administrator, connect Proxmox, and start safely in read-only mode. +

+ +
+
+ ) : null} + {step > 0 ? ( + <> +
-

Welcome to NexaFabric

+

NexaFabric Setup

Create your first administrator and connect your first provider.

-
+
{["Admin", "Provider", "Finish"].map((label, index) => ( -
+
{label}
-
{index < step ? "Done" : index === step ? "Current" : "Pending"}
+
{index + 1 < step ? "Done" : index + 1 === step ? "Current" : "Pending"}
))}
-
- {step === 0 ? ( -
+ + {step === 1 ? ( +
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 ? ( -
+ {step === 2 ? ( +
First Provider
setForm({ ...form, cluster_name: event.target.value })} /> setForm({ ...form, cluster_api_url: event.target.value })} /> @@ -67,10 +93,14 @@ export function SetupWizard() {
+
) : null} - {step === 2 ? ( -
+ {step === 3 ? ( +
Ready
NexaFabric will create the administrator, store the provider in read-only mode by default, and open the login screen. @@ -79,10 +109,12 @@ export function SetupWizard() { ) : null} {error ?
{error}
: null}
- - {step < 2 ? : } + + {step < 3 ? : }
+ + ) : null}
); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index bf99b71..da73ef7 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -37,3 +37,24 @@ select { font: inherit; } +@keyframes fadeIn { + from { + opacity: 0; + transform: scale(0.98); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(14px); + } + to { + opacity: 1; + transform: translateY(0); + } +}