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 ( -
+ A guided setup will create your administrator, connect Proxmox, and start safely in read-only mode. +
+ +Create your first administrator and connect your first provider.