feat: improve setup wizard UX, add cluster sync error handling, and conditional demo data seeding
CI / backend (push) Failing after 2s
CI / frontend (push) Failing after 31s

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
This commit is contained in:
2026-07-09 12:57:07 +02:00
parent 3cd2c0a2f1
commit 4554b00b73
7 changed files with 149 additions and 42 deletions
+1
View File
@@ -10,4 +10,5 @@ JWT_REFRESH_TOKEN_DAYS=14
TOKEN_ENCRYPTION_KEY=change-this-fernet-key-before-production TOKEN_ENCRYPTION_KEY=change-this-fernet-key-before-production
CORS_ORIGINS=http://localhost:5173,http://localhost:8080 CORS_ORIGINS=http://localhost:5173,http://localhost:8080
DEMO_ADMIN_PASSWORD=ChangeMe_UseEnvInstead DEMO_ADMIN_PASSWORD=ChangeMe_UseEnvInstead
SEED_DEMO_DATA=false
VITE_API_BASE_URL=/api/v1 VITE_API_BASE_URL=/api/v1
+34 -2
View File
@@ -148,14 +148,29 @@ def complete_setup(payload: SetupCompleteRequest, db: Session = Depends(get_db))
@api_router.get("/dashboard") @api_router.get("/dashboard")
def dashboard(_: CurrentUser, db: Session = Depends(get_db)) -> dict: 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 { return {
"clusters": db.scalar(select(func.count()).select_from(Cluster)), "clusters": db.scalar(select(func.count()).select_from(Cluster)),
"nodes": db.scalar(select(func.count()).select_from(Node)), "nodes": db.scalar(select(func.count()).select_from(Node)),
"workloads": db.scalar(select(func.count()).select_from(Workload)), "workloads": db.scalar(select(func.count()).select_from(Workload)),
"networks": db.scalar(select(func.count()).select_from(Network)), "networks": db.scalar(select(func.count()).select_from(Network)),
"open_policy_violations": 1, "open_policy_violations": 1,
"last_syncs": db.scalars(select(Cluster).order_by(Cluster.updated_at.desc()).limit(5)).all(), "last_syncs": [
"faulty_nodes": db.scalars(select(Node).where(Node.status != "online")).all(), {
"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": [ "top_talkers": [
{"name": "finance-app-2", "bytes": 942000000}, {"name": "finance-app-2", "bytes": 942000000},
{"name": "core-services-1", "bytes": 512000000}, {"name": "core-services-1", "bytes": 512000000},
@@ -246,6 +261,7 @@ async def sync_cluster(cluster_id: str, user: CurrentUser, db: Session = Depends
if not cluster: if not cluster:
raise HTTPException(status_code=404, detail="Cluster not found") raise HTTPException(status_code=404, detail="Cluster not found")
provider = get_provider(cluster.provider) provider = get_provider(cluster.provider)
try:
inventory = await provider.sync_inventory( inventory = await provider.sync_inventory(
ProviderConnection( ProviderConnection(
api_url=cluster.api_url, api_url=cluster.api_url,
@@ -254,6 +270,22 @@ async def sync_cluster(cluster_id: str, user: CurrentUser, db: Session = Depends
read_only=cluster.mode == "read_only", 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_at = datetime.utcnow()
cluster.last_sync_status = "success" cluster.last_sync_status = "success"
cluster.last_sync_error = None cluster.last_sync_error = None
+1
View File
@@ -18,6 +18,7 @@ class Settings(BaseSettings):
jwt_refresh_token_days: int = 14 jwt_refresh_token_days: int = 14
token_encryption_key: str = "dev-only-change-me" token_encryption_key: str = "dev-only-change-me"
demo_admin_password: str = "ChangeMe_UseEnvInstead" demo_admin_password: str = "ChangeMe_UseEnvInstead"
seed_demo_data: bool = False
cors_origins: list[AnyHttpUrl] | list[str] = ["http://localhost:5173", "http://localhost:8080"] cors_origins: list[AnyHttpUrl] | list[str] = ["http://localhost:5173", "http://localhost:8080"]
@field_validator("cors_origins", mode="before") @field_validator("cors_origins", mode="before")
+24 -12
View File
@@ -45,29 +45,42 @@ SERVICES = [
def seed_demo_data(db: Session) -> None: def seed_demo_data(db: Session) -> None:
settings = get_settings()
if not db.get(SystemSetting, "setup"): if not db.get(SystemSetting, "setup"):
db.add(SystemSetting(key="setup", value={"complete": False})) db.add(SystemSetting(key="setup", value={"complete": False}))
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() db.commit()
if not settings.seed_demo_data:
return
if db.scalar(select(User).where(User.email == "admin@nexafabric.local")): if db.scalar(select(User).where(User.email == "admin@nexafabric.local")):
return return
super_admin = Role(name="Super Admin", permissions=["*"]) super_admin = db.scalar(select(Role).where(Role.name == "Super Admin"))
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"]),
]
user = User( user = User(
email="admin@nexafabric.local", email="admin@nexafabric.local",
display_name="NexaFabric Administrator", display_name="NexaFabric Administrator",
password_hash=hash_password(get_settings().demo_admin_password), password_hash=hash_password(settings.demo_admin_password),
roles=[super_admin], roles=[super_admin],
) )
db.add_all(roles + [user]) db.add(user)
tenants = [ tenants = [
Tenant(name="Platform", description="Shared infrastructure and platform services"), 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.add(AuditLog(user_id=user.id, action="seed.created", object_type="system", result="success"))
db.commit() db.commit()
+12 -4
View File
@@ -8,20 +8,28 @@ from app.services.providers.base import Provider, ProviderConnection
class ProxmoxProvider(Provider): class ProxmoxProvider(Provider):
name = "proxmox" 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]: 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: async with httpx.AsyncClient(verify=connection.verify_tls, timeout=10) as client:
response = await client.get( response = await client.get(
f"{connection.api_url.rstrip('/')}/api2/json/version", f"{connection.api_url.rstrip('/')}/api2/json/version",
headers={"Authorization": connection.token}, headers=headers,
) )
response.raise_for_status() response.raise_for_status()
return response.json().get("data", {}) return response.json().get("data", {})
async def sync_inventory(self, connection: ProviderConnection) -> dict[str, list[dict[str, Any]]]: 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: async with httpx.AsyncClient(verify=connection.verify_tls, timeout=20) as client:
resources = await client.get( resources = await client.get(
f"{connection.api_url.rstrip('/')}/api2/json/cluster/resources", f"{connection.api_url.rstrip('/')}/api2/json/cluster/resources",
headers={"Authorization": connection.token}, headers=headers,
) )
resources.raise_for_status() resources.raise_for_status()
data = resources.json().get("data", []) data = resources.json().get("data", [])
@@ -32,10 +40,11 @@ class ProxmoxProvider(Provider):
return {"nodes": nodes, "workloads": workloads, "networks": networks} return {"nodes": nodes, "workloads": workloads, "networks": networks}
async def list_networks(self, connection: ProviderConnection) -> list[dict[str, Any]]: 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: async with httpx.AsyncClient(verify=connection.verify_tls, timeout=20) as client:
resources = await client.get( resources = await client.get(
f"{connection.api_url.rstrip('/')}/api2/json/cluster/resources", f"{connection.api_url.rstrip('/')}/api2/json/cluster/resources",
headers={"Authorization": connection.token}, headers=headers,
) )
resources.raise_for_status() resources.raise_for_status()
data = resources.json().get("data", []) data = resources.json().get("data", [])
@@ -53,4 +62,3 @@ class ProxmoxProvider(Provider):
if connection.read_only: if connection.read_only:
return {"applied": False, "reason": "Cluster is read-only", "rules": rules} return {"applied": False, "reason": "Cluster is read-only", "rules": rules}
return {"applied": False, "reason": "Apply adapter intentionally requires explicit implementation", "rules": rules} return {"applied": False, "reason": "Apply adapter intentionally requires explicit implementation", "rules": rules}
+48 -16
View File
@@ -1,12 +1,14 @@
import { FormEvent, useState } from "react"; 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 { publicApi } from "../api/client";
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls"; import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
import { useTheme } from "../stores/theme";
export function SetupWizard() { export function SetupWizard() {
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [error, setError] = useState(""); const [error, setError] = useState("");
const { dark, toggle } = useTheme();
const [form, setForm] = useState({ const [form, setForm] = useState({
admin_email: "admin@nexafabric.local", admin_email: "admin@nexafabric.local",
admin_name: "NexaFabric Administrator", admin_name: "NexaFabric Administrator",
@@ -31,34 +33,58 @@ export function SetupWizard() {
} }
return ( return (
<div className="min-h-screen bg-canvas px-4 py-8 text-slate-900 dark:text-slate-100"> <div className="min-h-screen overflow-hidden bg-canvas px-4 py-8 text-slate-900 dark:text-slate-100">
<button
className="fixed right-4 top-4 z-10 rounded-md border border-border bg-panel p-2 hover:bg-slate-100 dark:hover:bg-slate-800"
onClick={toggle}
type="button"
aria-label="Toggle theme"
>
{dark ? <Sun size={18} /> : <Moon size={18} />}
</button>
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<div className="mb-8 flex items-center gap-3"> {step === 0 ? (
<section className="grid min-h-[78vh] place-items-center text-center">
<div className="animate-[fadeIn_700ms_ease-out]">
<div className="mx-auto mb-8 grid h-24 w-24 place-items-center rounded-2xl bg-accent text-white shadow-xl shadow-teal-900/20">
<Sparkles size={42} />
</div>
<h1 className="mb-4 text-5xl font-semibold tracking-normal">Welcome to NexaFabric</h1>
<p className="mx-auto mb-8 max-w-2xl text-base text-slate-500 dark:text-slate-400">
A guided setup will create your administrator, connect Proxmox, and start safely in read-only mode.
</p>
<button className={buttonClass} type="button" onClick={() => setStep(1)}>Continue</button>
</div>
</section>
) : null}
{step > 0 ? (
<>
<div className="mb-8 flex items-center gap-3 animate-[slideUp_420ms_ease-out]">
<div className="rounded-md bg-accent p-3 text-white"><Sparkles size={24} /></div> <div className="rounded-md bg-accent p-3 text-white"><Sparkles size={24} /></div>
<div> <div>
<h1 className="text-3xl font-semibold">Welcome to NexaFabric</h1> <h1 className="text-3xl font-semibold">NexaFabric Setup</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">Create your first administrator and connect your first provider.</p> <p className="text-sm text-slate-500 dark:text-slate-400">Create your first administrator and connect your first provider.</p>
</div> </div>
</div> </div>
<div className="mb-6 grid gap-3 md:grid-cols-3"> <div className="mb-6 grid gap-3 md:grid-cols-3 animate-[slideUp_480ms_ease-out]">
{["Admin", "Provider", "Finish"].map((label, index) => ( {["Admin", "Provider", "Finish"].map((label, index) => (
<div key={label} className={`rounded-md border p-3 text-sm ${index === step ? "border-accent bg-panel" : "border-border bg-panel/60"}`}> <div key={label} className={`rounded-md border p-3 text-sm ${index + 1 === step ? "border-accent bg-panel" : "border-border bg-panel/60"}`}>
<div className="font-medium">{label}</div> <div className="font-medium">{label}</div>
<div className="text-xs text-slate-500">{index < step ? "Done" : index === step ? "Current" : "Pending"}</div> <div className="text-xs text-slate-500">{index + 1 < step ? "Done" : index + 1 === step ? "Current" : "Pending"}</div>
</div> </div>
))} ))}
</div> </div>
<form onSubmit={complete} className="rounded-md border border-border bg-panel p-5"> <form onSubmit={complete} className="animate-[slideUp_540ms_ease-out] rounded-md border border-border bg-panel p-5">
{step === 0 ? ( {step === 1 ? (
<div className="grid gap-4"> <div className="grid gap-4 animate-[fadeIn_300ms_ease-out]">
<div className="flex items-center gap-2 font-medium"><ShieldCheck size={18} /> Super Admin</div> <div className="flex items-center gap-2 font-medium"><ShieldCheck size={18} /> Super Admin</div>
<Field label="Email"><input className={inputClass} value={form.admin_email} onChange={(event) => setForm({ ...form, admin_email: event.target.value })} /></Field> <Field label="Email"><input className={inputClass} value={form.admin_email} onChange={(event) => setForm({ ...form, admin_email: event.target.value })} /></Field>
<Field label="Name"><input className={inputClass} value={form.admin_name} onChange={(event) => setForm({ ...form, admin_name: event.target.value })} /></Field> <Field label="Name"><input className={inputClass} value={form.admin_name} onChange={(event) => setForm({ ...form, admin_name: event.target.value })} /></Field>
<Field label="Password"><input className={inputClass} type="password" value={form.admin_password} onChange={(event) => setForm({ ...form, admin_password: event.target.value })} /></Field> <Field label="Password"><input className={inputClass} type="password" value={form.admin_password} onChange={(event) => setForm({ ...form, admin_password: event.target.value })} /></Field>
</div> </div>
) : null} ) : null}
{step === 1 ? ( {step === 2 ? (
<div className="grid gap-4"> <div className="grid gap-4 animate-[fadeIn_300ms_ease-out]">
<div className="flex items-center gap-2 font-medium"><Server size={18} /> First Provider</div> <div className="flex items-center gap-2 font-medium"><Server size={18} /> First Provider</div>
<Field label="Cluster Name"><input className={inputClass} value={form.cluster_name} onChange={(event) => setForm({ ...form, cluster_name: event.target.value })} /></Field> <Field label="Cluster Name"><input className={inputClass} value={form.cluster_name} onChange={(event) => setForm({ ...form, cluster_name: event.target.value })} /></Field>
<Field label="API URL"><input className={inputClass} value={form.cluster_api_url} onChange={(event) => setForm({ ...form, cluster_api_url: event.target.value })} /></Field> <Field label="API URL"><input className={inputClass} value={form.cluster_api_url} onChange={(event) => setForm({ ...form, cluster_api_url: event.target.value })} /></Field>
@@ -67,10 +93,14 @@ export function SetupWizard() {
<Field label="Provider"><select className={selectClass} value={form.cluster_provider} onChange={(event) => setForm({ ...form, cluster_provider: event.target.value })}><option>proxmox</option><option>demo</option></select></Field> <Field label="Provider"><select className={selectClass} value={form.cluster_provider} onChange={(event) => setForm({ ...form, cluster_provider: event.target.value })}><option>proxmox</option><option>demo</option></select></Field>
<Field label="Mode"><select className={selectClass} value={form.cluster_mode} onChange={(event) => setForm({ ...form, cluster_mode: event.target.value })}><option value="read_only">read_only</option><option value="write_enabled">write_enabled</option></select></Field> <Field label="Mode"><select className={selectClass} value={form.cluster_mode} onChange={(event) => setForm({ ...form, cluster_mode: event.target.value })}><option value="read_only">read_only</option><option value="write_enabled">write_enabled</option></select></Field>
</div> </div>
<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 certificate
</label>
</div> </div>
) : null} ) : null}
{step === 2 ? ( {step === 3 ? (
<div className="grid gap-4"> <div className="grid gap-4 animate-[fadeIn_300ms_ease-out]">
<div className="flex items-center gap-2 font-medium"><CheckCircle2 size={18} /> Ready</div> <div className="flex items-center gap-2 font-medium"><CheckCircle2 size={18} /> Ready</div>
<div className="rounded-md border border-border bg-canvas p-4 text-sm"> <div className="rounded-md border border-border bg-canvas p-4 text-sm">
NexaFabric will create the administrator, store the provider in read-only mode by default, and open the login screen. 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} ) : null}
{error ? <div className="mt-4 rounded-md border border-danger p-3 text-sm text-danger">{error}</div> : null} {error ? <div className="mt-4 rounded-md border border-danger p-3 text-sm text-danger">{error}</div> : null}
<div className="mt-6 flex justify-between"> <div className="mt-6 flex justify-between">
<button className={secondaryButtonClass} type="button" onClick={() => setStep(Math.max(0, step - 1))} disabled={step === 0}>Back</button> <button className={secondaryButtonClass} type="button" onClick={() => setStep(Math.max(0, step - 1))}>Back</button>
{step < 2 ? <button className={buttonClass} type="button" onClick={() => setStep(step + 1)}>Next</button> : <button className={buttonClass}>Complete Setup</button>} {step < 3 ? <button className={buttonClass} type="button" onClick={() => setStep(step + 1)}>Next</button> : <button className={buttonClass}>Complete Setup</button>}
</div> </div>
</form> </form>
</>
) : null}
</div> </div>
</div> </div>
); );
+21
View File
@@ -37,3 +37,24 @@ select {
font: inherit; 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);
}
}