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:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
+25
-13
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user