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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,6 +261,7 @@ 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)
|
||||
try:
|
||||
inventory = await provider.sync_inventory(
|
||||
ProviderConnection(
|
||||
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",
|
||||
)
|
||||
)
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
|
||||
+24
-12
@@ -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}))
|
||||
|
||||
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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<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="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>
|
||||
<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>
|
||||
</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) => (
|
||||
<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="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>
|
||||
<form onSubmit={complete} className="rounded-md border border-border bg-panel p-5">
|
||||
{step === 0 ? (
|
||||
<div className="grid gap-4">
|
||||
<form onSubmit={complete} className="animate-[slideUp_540ms_ease-out] rounded-md border border-border bg-panel p-5">
|
||||
{step === 1 ? (
|
||||
<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>
|
||||
<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="Password"><input className={inputClass} type="password" value={form.admin_password} onChange={(event) => setForm({ ...form, admin_password: event.target.value })} /></Field>
|
||||
</div>
|
||||
) : null}
|
||||
{step === 1 ? (
|
||||
<div className="grid gap-4">
|
||||
{step === 2 ? (
|
||||
<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>
|
||||
<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>
|
||||
@@ -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="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>
|
||||
<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>
|
||||
) : null}
|
||||
{step === 2 ? (
|
||||
<div className="grid gap-4">
|
||||
{step === 3 ? (
|
||||
<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="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.
|
||||
@@ -79,10 +109,12 @@ export function SetupWizard() {
|
||||
) : 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">
|
||||
<button className={secondaryButtonClass} type="button" onClick={() => setStep(Math.max(0, step - 1))} disabled={step === 0}>Back</button>
|
||||
{step < 2 ? <button className={buttonClass} type="button" onClick={() => setStep(step + 1)}>Next</button> : <button className={buttonClass}>Complete Setup</button>}
|
||||
<button className={secondaryButtonClass} type="button" onClick={() => setStep(Math.max(0, step - 1))}>Back</button>
|
||||
{step < 3 ? <button className={buttonClass} type="button" onClick={() => setStep(step + 1)}>Next</button> : <button className={buttonClass}>Complete Setup</button>}
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user