From 485476a297c111e37fec9913535a63a2383ca06e Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 21 Feb 2026 16:32:53 +0100
Subject: [PATCH] feat: Rewrite dashboard with sidebar nav, drill-down, and registry-based container resolution

---
 static/index.html           |  924 ++-------------------
 static/js/app.js            | 1515 ++++++++++++++++++-----------------
 app/app/routers/services.py |  126 ++
 3 files changed, 994 insertions(+), 1,571 deletions(-)

diff --git a/app/app/routers/services.py b/app/app/routers/services.py
index a23bade..7cdad19 100644
--- a/app/app/routers/services.py
+++ b/app/app/routers/services.py
@@ -1,5 +1,7 @@
+import os
 from typing import Any
 
+import yaml
 from fastapi import APIRouter, Depends, HTTPException, Query
 
 from app.auth import verify_token
@@ -8,14 +10,111 @@
 router = APIRouter()
 
 _DOCKER = "docker"
+_REGISTRY_PATH = os.environ.get(
+    "REGISTRY_PATH",
+    "/opt/infrastructure/servers/hetzner-vps/registry.yaml",
+)
+
+# ---------------------------------------------------------------------------
+# Registry-based name prefix lookup (cached)
+# ---------------------------------------------------------------------------
+_prefix_cache: dict[str, str] | None = None
 
 
-def _container_name(project: str, env: str, service: str) -> str:
+def _load_prefixes() -> dict[str, str]:
+    """Load project -> name_prefix mapping from the ops registry."""
+    global _prefix_cache
+    if _prefix_cache is not None:
+        return _prefix_cache
+
+    try:
+        with open(_REGISTRY_PATH) as f:
+            data = yaml.safe_load(f)
+        _prefix_cache = {}
+        for proj_name, cfg in data.get("projects", {}).items():
+            _prefix_cache[proj_name] = cfg.get("name_prefix", proj_name)
+        return _prefix_cache
+    except Exception:
+        return {}
+
+
+# ---------------------------------------------------------------------------
+# Container name resolution
+# ---------------------------------------------------------------------------
+
+
+async def _find_by_prefix(pattern: str) -> str | None:
+    """Find first running container whose name starts with `pattern`."""
+    result = await run_command(
+        [_DOCKER, "ps", "--filter", f"name={pattern}", "--format", "{{.Names}}"],
+        timeout=10,
+    )
+    if not result["success"]:
+        return None
+    for name in result["output"].strip().splitlines():
+        name = name.strip()
+        if name and name.startswith(pattern):
+            return name
+    return None
+
+
+async def _find_exact(name: str) -> str | None:
+    """Find a running container with exactly this name."""
+    result = await run_command(
+        [_DOCKER, "ps", "--filter", f"name={name}", "--format", "{{.Names}}"],
+        timeout=10,
+    )
+    if not result["success"]:
+        return None
+    for n in result["output"].strip().splitlines():
+        if n.strip() == name:
+            return name
+    return None
+
+
+async def _resolve_container(project: str, env: str, service: str) -> str:
     """
-    Derive the Docker container name from project, env, and service.
-    Docker Compose v2 default: {project}-{env}-{service}-1
+    Resolve the actual Docker container name from project/env/service.
+
+    Uses the ops registry name_prefix mapping and tries patterns in order:
+      1. {env}-{prefix}-{service}  (mdf, seriousletter: dev-mdf-mysql-UUID)
+      2. {prefix}-{service}        (ringsaday: ringsaday-website-UUID, coolify: coolify-db)
+      3. {prefix}-{env}            (ringsaday: ringsaday-dev-UUID)
+      4. exact {prefix}            (coolify infra: coolify)
     """
-    return f"{project}-{env}-{service}-1"
+    prefixes = _load_prefixes()
+    prefix = prefixes.get(project, project)
+
+    # Pattern 1: {env}-{prefix}-{service}
+    hit = await _find_by_prefix(f"{env}-{prefix}-{service}")
+    if hit:
+        return hit
+
+    # Pattern 2: {prefix}-{service}
+    hit = await _find_by_prefix(f"{prefix}-{service}")
+    if hit:
+        return hit
+
+    # Pattern 3: {prefix}-{env}
+    hit = await _find_by_prefix(f"{prefix}-{env}")
+    if hit:
+        return hit
+
+    # Pattern 4: exact match when service == prefix (e.g., coolify)
+    if service == prefix:
+        hit = await _find_exact(prefix)
+        if hit:
+            return hit
+
+    raise HTTPException(
+        status_code=404,
+        detail=f"Container not found for {project}/{env}/{service}",
+    )
+
+
+# ---------------------------------------------------------------------------
+# Endpoints
+# ---------------------------------------------------------------------------
 
 
 @router.get("/logs/{project}/{env}/{service}", summary="Get container logs")
@@ -23,20 +122,19 @@
     project: str,
     env: str,
     service: str,
-    lines: int = Query(default=100, ge=1, le=10000, description="Number of log lines to return"),
+    lines: int = Query(
+        default=100, ge=1, le=10000, description="Number of log lines to return"
+    ),
     _: str = Depends(verify_token),
 ) -> dict[str, Any]:
-    """
-    Fetch the last N lines of logs from a container.
-    Uses `docker logs --tail {lines} {container}`.
-    """
-    container = _container_name(project, env, service)
+    """Fetch the last N lines of logs from a container."""
+    container = await _resolve_container(project, env, service)
     result = await run_command(
         [_DOCKER, "logs", "--tail", str(lines), container],
         timeout=30,
     )
 
-    # docker logs writes to stderr by default; treat combined output as logs
+    # docker logs writes to stderr by default; combine both streams
     combined = result["output"] + result["error"]
 
     if not result["success"] and not combined.strip():
@@ -59,10 +157,8 @@
     service: str,
     _: str = Depends(verify_token),
 ) -> dict[str, Any]:
-    """
-    Restart a Docker container via `docker restart {container}`.
-    """
-    container = _container_name(project, env, service)
+    """Restart a Docker container."""
+    container = await _resolve_container(project, env, service)
     result = await run_command(
         [_DOCKER, "restart", container],
         timeout=60,
diff --git a/static/index.html b/static/index.html
index 4197aaa..d39e965 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1,843 +1,131 @@
 <!DOCTYPE html>
-<html lang="en" class="h-full">
+<html lang="en">
 <head>
-  <meta charset="UTF-8" />
-  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>OPS Dashboard</title>
-
-  <!-- Tailwind CSS Play CDN -->
   <script src="https://cdn.tailwindcss.com"></script>
-  <script>
-    tailwind.config = {
-      theme: {
-        extend: {
-          colors: {
-            gray: {
-              950: '#0a0e1a'
-            }
-          }
-        }
-      }
-    };
-  </script>
-
-  <!-- Custom styles -->
-  <link rel="stylesheet" href="/static/css/style.css" />
-
-  <!-- Alpine.js v3 -->
-  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
-
-  <!-- App logic -->
-  <script src="/static/js/app.js"></script>
+  <link rel="stylesheet" href="/static/css/style.css">
+  <style>
+    body { background: #0f172a; color: #e2e8f0; margin: 0; }
+    #app { display: flex; min-height: 100vh; }
+    #sidebar { width: 240px; background: #111827; border-right: 1px solid #1f2937; display: flex; flex-direction: column; flex-shrink: 0; }
+    #main { flex: 1; display: flex; flex-direction: column; overflow-x: hidden; }
+    #topbar { background: #111827; border-bottom: 1px solid #1f2937; padding: 0.75rem 1.5rem; display: flex; align-items: center; gap: 1rem; }
+    #page-content { flex: 1; padding: 1.5rem; overflow-y: auto; }
+    .breadcrumb { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; color: #9ca3af; }
+    .breadcrumb a { color: #60a5fa; cursor: pointer; text-decoration: none; }
+    .breadcrumb a:hover { text-decoration: underline; }
+    .breadcrumb .sep { color: #4b5563; }
+    .breadcrumb .current { color: #e2e8f0; font-weight: 500; }
+    .hamburger { display: none; background: none; border: none; color: #9ca3af; font-size: 1.5rem; cursor: pointer; padding: 0.25rem; }
+    .sidebar-logo { padding: 1.25rem 1rem; font-size: 1.125rem; font-weight: 700; color: #f3f4f6; border-bottom: 1px solid #1f2937; display: flex; align-items: center; gap: 0.5rem; }
+    .sidebar-nav { padding: 0.75rem 0.5rem; flex: 1; }
+    .sidebar-footer { padding: 0.75rem 1rem; border-top: 1px solid #1f2937; font-size: 0.75rem; color: #6b7280; }
+    .project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
+    .env-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; }
+    .service-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; }
+    .stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
+    .card-clickable { cursor: pointer; transition: border-color 0.2s, transform 0.15s; }
+    .card-clickable:hover { border-color: #60a5fa; transform: translateY(-1px); }
+    .mobile-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 40; }
+    @media (max-width: 768px) {
+      #sidebar { position: fixed; left: -240px; top: 0; bottom: 0; z-index: 50; transition: left 0.2s; }
+      #sidebar.open { left: 0; }
+      .mobile-overlay.open { display: block; }
+      .hamburger { display: block; }
+      .project-grid, .env-grid, .service-grid { grid-template-columns: 1fr; }
+    }
+  </style>
 </head>
+<body>
 
-<body class="h-full bg-gray-900 text-gray-100 antialiased">
-
-<!-- ============================================================
-     Toast Notifications
-     ============================================================ -->
-<div class="toast-container" x-data x-show="$store.toast.toasts.length > 0">
-  <template x-for="t in $store.toast.toasts" :key="t.id">
-    <div class="toast" :class="t.type">
-      <span class="text-base leading-none" x-text="$store.toast.iconFor(t.type)"></span>
-      <span x-text="t.msg" class="flex-1"></span>
-      <button class="toast-dismiss" @click="$store.toast.remove(t.id)">&#x2715;</button>
-    </div>
-  </template>
-</div>
-
-<!-- ============================================================
-     Login Screen
-     ============================================================ -->
-<div x-data x-show="!$store.auth.isAuthenticated" class="flex h-full items-center justify-center">
-  <div class="w-full max-w-sm px-4">
-    <div class="card text-center">
-      <!-- Server icon -->
-      <div class="flex justify-center mb-4">
-        <div class="bg-blue-600 rounded-xl p-3">
-          <svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
-            <path stroke-linecap="round" stroke-linejoin="round"
-              d="M21.75 17.25v.75A2.25 2.25 0 0119.5 20.25h-15a2.25 2.25 0 01-2.25-2.25v-.75m19.5 0A2.25 2.25 0 0019.5 15h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v-3A2.25 2.25 0 0019.5 9.75h-15A2.25 2.25 0 002.25 12v3M12 12.75h.008v.008H12v-.008zM12 6.75h.008v.008H12V6.75z"/>
-          </svg>
-        </div>
-      </div>
-
-      <h1 class="text-2xl font-bold text-white mb-1">OPS Dashboard</h1>
-      <p class="text-sm text-gray-500 mb-6">Enter your access token to continue</p>
-
-      <form @submit.prevent="$store.auth.login()" class="space-y-4">
-        <div>
-          <input
-            type="password"
-            class="form-input text-center tracking-widest"
-            placeholder="••••••••••••••••"
-            x-model="$store.auth.loginInput"
-            autofocus
-          />
-        </div>
-
-        <div x-show="$store.auth.loginError" class="text-red-400 text-sm" x-text="$store.auth.loginError"></div>
-
-        <button type="submit" class="btn btn-primary w-full" :disabled="$store.auth.loading">
-          <span x-show="$store.auth.loading" class="spinner"></span>
-          <span x-text="$store.auth.loading ? 'Verifying…' : 'Sign In'"></span>
-        </button>
-      </form>
-    </div>
+<!-- Login Overlay -->
+<div id="login-overlay" style="position:fixed;inset:0;background:#0f172a;z-index:100;display:flex;align-items:center;justify-content:center;">
+  <div class="card" style="width:100%;max-width:380px;text-align:center;">
+    <div style="font-size:1.5rem;font-weight:700;margin-bottom:1.5rem;color:#f3f4f6;">OPS Dashboard</div>
+    <input type="password" id="login-token" placeholder="Enter access token" class="form-input" style="margin-bottom:1rem;"
+      onkeydown="if(event.key==='Enter')doLogin()">
+    <button onclick="doLogin()" class="btn btn-primary" style="width:100%;">Login</button>
+    <div id="login-error" style="color:#f87171;font-size:0.875rem;margin-top:0.75rem;display:none;"></div>
   </div>
 </div>
 
-<!-- ============================================================
-     Main App Shell
-     ============================================================ -->
-<div x-data x-show="$store.auth.isAuthenticated" class="flex h-full">
+<!-- App Shell -->
+<div id="app" style="display:none;">
+  <!-- Mobile overlay -->
+  <div id="mobile-overlay" class="mobile-overlay" onclick="toggleSidebar()"></div>
 
-  <!-- Mobile sidebar overlay -->
-  <div
-    x-show="$store.app.sidebarOpen"
-    class="sidebar-mobile-overlay md:hidden"
-    @click="$store.app.sidebarOpen = false"
-  ></div>
-
-  <!-- ---- Sidebar ---- -->
-  <aside
-    class="fixed inset-y-0 left-0 z-50 flex flex-col w-60 bg-gray-950 border-r border-gray-800 transform transition-transform duration-200 md:relative md:translate-x-0"
-    :class="$store.app.sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'"
-  >
-    <!-- App name -->
-    <div class="flex items-center gap-3 px-4 py-5 border-b border-gray-800">
-      <div class="bg-blue-600 rounded-lg p-1.5 flex-shrink-0">
-        <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
-          <path stroke-linecap="round" stroke-linejoin="round"
-            d="M21.75 17.25v.75A2.25 2.25 0 0119.5 20.25h-15A2.25 2.25 0 012.25 18v-.75m19.5 0A2.25 2.25 0 0019.5 15h-15A2.25 2.25 0 002.25 17.25m19.5 0v-3A2.25 2.25 0 0019.5 9.75h-15A2.25 2.25 0 002.25 12v3M12 12.75h.008v.008H12v-.008zM12 6.75h.008v.008H12V6.75z"/>
-        </svg>
-      </div>
-      <div>
-        <div class="font-bold text-white text-base tracking-widest">OPS</div>
-        <div class="text-xs text-gray-500">tekmidian.com</div>
-      </div>
+  <!-- Sidebar -->
+  <aside id="sidebar">
+    <div class="sidebar-logo">
+      <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#60a5fa" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1" fill="#60a5fa"/><circle cx="6" cy="18" r="1" fill="#60a5fa"/></svg>
+      OPS Dashboard
     </div>
-
-    <!-- Nav links -->
-    <nav class="flex-1 p-3 space-y-1 overflow-y-auto">
-
-      <button class="sidebar-link w-full text-left"
-        :class="$store.app.page === 'dashboard' ? 'active' : ''"
-        @click="$store.app.navigate('dashboard')">
-        <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
-          <path stroke-linecap="round" stroke-linejoin="round"
-            d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z"/>
-        </svg>
+    <nav class="sidebar-nav" id="sidebar-nav">
+      <a class="sidebar-link active" data-page="dashboard" onclick="showPage('dashboard')">
+        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
         Dashboard
-      </button>
-
-      <button class="sidebar-link w-full text-left"
-        :class="$store.app.page === 'backups' ? 'active' : ''"
-        @click="$store.app.navigate('backups')">
-        <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
-          <path stroke-linecap="round" stroke-linejoin="round"
-            d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"/>
-        </svg>
-        Backups
-      </button>
-
-      <button class="sidebar-link w-full text-left"
-        :class="$store.app.page === 'restore' ? 'active' : ''"
-        @click="$store.app.navigate('restore')">
-        <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
-          <path stroke-linecap="round" stroke-linejoin="round"
-            d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"/>
-        </svg>
-        Restore
-      </button>
-
-      <button class="sidebar-link w-full text-left"
-        :class="$store.app.page === 'services' ? 'active' : ''"
-        @click="$store.app.navigate('services')">
-        <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
-          <path stroke-linecap="round" stroke-linejoin="round"
-            d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"/>
-        </svg>
+      </a>
+      <a class="sidebar-link" data-page="services" onclick="showPage('services')">
+        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
         Services
-      </button>
-
-      <button class="sidebar-link w-full text-left"
-        :class="$store.app.page === 'system' ? 'active' : ''"
-        @click="$store.app.navigate('system')">
-        <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
-          <path stroke-linecap="round" stroke-linejoin="round"
-            d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23-.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5"/>
-        </svg>
+      </a>
+      <a class="sidebar-link" data-page="backups" onclick="showPage('backups')">
+        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
+        Backups
+      </a>
+      <a class="sidebar-link" data-page="system" onclick="showPage('system')">
+        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
         System
-      </button>
+      </a>
+      <a class="sidebar-link" data-page="restore" onclick="showPage('restore')">
+        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>
+        Restore
+      </a>
     </nav>
-
-    <!-- Logout -->
-    <div class="p-3 border-t border-gray-800">
-      <button class="sidebar-link w-full text-left text-red-400 hover:text-red-300"
-        @click="$store.auth.logout()">
-        <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
-          <path stroke-linecap="round" stroke-linejoin="round"
-            d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75"/>
-        </svg>
-        Sign Out
-      </button>
+    <div class="sidebar-footer">
+      <a onclick="doLogout()" style="color:#9ca3af;cursor:pointer;">Logout</a>
     </div>
   </aside>
 
-  <!-- ---- Main content ---- -->
-  <div class="flex-1 flex flex-col min-w-0 overflow-hidden">
-
-    <!-- Top header -->
-    <header class="flex items-center justify-between px-4 py-3 bg-gray-950 border-b border-gray-800 flex-shrink-0">
-
-      <!-- Mobile hamburger -->
-      <button class="md:hidden p-1 rounded text-gray-400 hover:text-white hover:bg-gray-800"
-        @click="$store.app.sidebarOpen = !$store.app.sidebarOpen">
-        <svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
-          <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
-        </svg>
-      </button>
-
-      <!-- Page title -->
-      <h2 class="text-sm font-semibold text-gray-300 capitalize"
-        x-text="$store.app.page"></h2>
-
-      <!-- Right side: refresh indicator -->
-      <div class="flex items-center gap-3">
-
-        <!-- Auto-refresh for dashboard -->
-        <div x-show="$store.app.page === 'dashboard'" class="flex items-center gap-2 text-xs text-gray-500">
-          <button
-            @click="$store.dashboard.toggleAutoRefresh()"
-            class="flex items-center gap-1.5 hover:text-gray-300 transition-colors"
-            :title="$store.dashboard.autoRefreshEnabled ? 'Auto-refresh on (click to pause)' : 'Auto-refresh off (click to resume)'">
-            <div class="refresh-ring" :class="!$store.dashboard.autoRefreshEnabled ? 'paused' : ''"></div>
-            <span x-show="$store.dashboard.lastRefresh"
-              x-text="'Updated ' + $store.dashboard.timeAgo($store.dashboard.lastRefresh)"></span>
-          </button>
-
-          <button @click="$store.dashboard.manualRefresh()"
-            class="btn btn-ghost btn-xs"
-            :disabled="$store.dashboard.loading">
-            <svg class="w-3 h-3" :class="$store.dashboard.refreshing ? 'animate-spin' : ''" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
-              <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-4.566l3.181 3.182m0-4.991v4.99"/>
-            </svg>
-            Refresh
-          </button>
-        </div>
-
+  <!-- Main Content -->
+  <div id="main">
+    <!-- Top bar -->
+    <div id="topbar">
+      <button class="hamburger" onclick="toggleSidebar()">&#9776;</button>
+      <div id="breadcrumbs" class="breadcrumb" style="flex:1;"></div>
+      <div style="display:flex;align-items:center;gap:0.75rem;">
+        <div id="refresh-indicator" class="refresh-ring paused" title="Auto-refresh"></div>
+        <button class="btn btn-ghost btn-xs" onclick="refreshCurrentPage()" title="Refresh now">Refresh</button>
       </div>
-    </header>
+    </div>
 
-    <!-- ---- Page content area ---- -->
-    <main class="flex-1 overflow-y-auto">
-
-      <!-- ======================================================
-           DASHBOARD PAGE
-           ====================================================== -->
-      <div x-show="$store.app.page === 'dashboard'" class="p-4 md:p-6 page-enter">
-
-        <!-- Loading -->
-        <div x-show="$store.dashboard.loading && !$store.dashboard.projects.length"
-          class="flex items-center justify-center h-48 text-gray-500">
-          <div class="text-center">
-            <div class="spinner spinner-lg mx-auto mb-3"></div>
-            <div class="text-sm">Loading containers…</div>
-          </div>
-        </div>
-
-        <!-- Error -->
-        <div x-show="$store.dashboard.error" class="card border-red-800 text-red-400 mb-4">
-          <div class="flex items-center gap-2">
-            <span class="text-lg">&#9888;</span>
-            <span x-text="$store.dashboard.error"></span>
-          </div>
-        </div>
-
-        <!-- Projects -->
-        <template x-for="proj in $store.dashboard.projects" :key="proj.name">
-          <div class="mb-8">
-            <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3"
-              x-text="proj.name"></h3>
-
-            <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
-              <template x-for="svc in proj.services" :key="svc.name || svc.service">
-                <div class="card flex flex-col gap-2">
-                  <!-- Name + badge -->
-                  <div class="flex items-start justify-between gap-2">
-                    <div class="font-medium text-white text-sm truncate"
-                      x-text="svc.name || svc.service"></div>
-                    <div class="badge flex-shrink-0"
-                      :class="$store.dashboard.badgeClass(svc.status, svc.health)">
-                      <span class="status-dot"
-                        :class="$store.dashboard.dotClass(svc.status, svc.health)"></span>
-                      <span x-text="svc.status || 'unknown'"></span>
-                    </div>
-                  </div>
-
-                  <!-- Health + uptime row -->
-                  <div class="flex items-center gap-3 text-xs text-gray-500">
-                    <template x-if="svc.health && svc.health !== ''">
-                      <span class="flex items-center gap-1">
-                        <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
-                          <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
-                        </svg>
-                        <span x-text="svc.health"></span>
-                      </span>
-                    </template>
-                    <template x-if="svc.uptime || svc.started">
-                      <span x-text="svc.uptime || ('Up ' + $store.dashboard.timeAgo(svc.started))"></span>
-                    </template>
-                  </div>
-
-                  <!-- Domain link -->
-                  <template x-if="svc.domain">
-                    <a :href="'https://' + svc.domain" target="_blank" rel="noopener"
-                      class="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 truncate">
-                      <svg class="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
-                        <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/>
-                      </svg>
-                      <span x-text="svc.domain" class="truncate"></span>
-                    </a>
-                  </template>
-                </div>
-              </template>
-            </div>
-          </div>
-        </template>
-
-        <!-- Empty state -->
-        <div x-show="!$store.dashboard.loading && !$store.dashboard.projects.length && !$store.dashboard.error"
-          class="flex flex-col items-center justify-center h-48 text-gray-600">
-          <svg class="w-10 h-10 mb-3" fill="none" stroke="currentColor" stroke-width="1" viewBox="0 0 24 24">
-            <path stroke-linecap="round" stroke-linejoin="round"
-              d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7"/>
-          </svg>
-          <p class="text-sm">No containers found</p>
-        </div>
-      </div>
-
-      <!-- ======================================================
-           BACKUPS PAGE
-           ====================================================== -->
-      <div x-show="$store.app.page === 'backups'" class="p-4 md:p-6 page-enter space-y-8">
-
-        <!-- ---- Local Backups ---- -->
-        <section>
-          <div class="flex items-center justify-between mb-4">
-            <h3 class="text-sm font-semibold text-gray-400 uppercase tracking-widest">Local Backups</h3>
-            <button class="btn btn-ghost btn-sm" @click="$store.backups.fetchLocal()"
-              :disabled="$store.backups.loading">
-              <span x-show="$store.backups.loading" class="spinner"></span>
-              Refresh
-            </button>
-          </div>
-
-          <div x-show="$store.backups.loading && !$store.backups.local.length"
-            class="flex items-center gap-2 text-gray-500 text-sm py-6 justify-center">
-            <div class="spinner"></div> Loading…
-          </div>
-
-          <div x-show="!$store.backups.loading && !$store.backups.local.length"
-            class="text-gray-600 text-sm py-6 text-center">No local backups found</div>
-
-          <div x-show="$store.backups.local.length > 0" class="table-wrapper">
-            <table class="ops-table">
-              <thead>
-                <tr>
-                  <th>Project</th>
-                  <th>Env</th>
-                  <th>File</th>
-                  <th>Size</th>
-                  <th>Age</th>
-                  <th>Actions</th>
-                </tr>
-              </thead>
-              <tbody>
-                <template x-for="(b, idx) in $store.backups.local" :key="idx">
-                  <tr>
-                    <td class="font-medium text-white" x-text="b.project || '—'"></td>
-                    <td>
-                      <span class="badge badge-blue" x-text="b.env || '—'"></span>
-                    </td>
-                    <td class="mono text-xs text-gray-400 max-w-xs truncate" x-text="b.filename || b.file || '—'"></td>
-                    <td class="text-gray-400 text-xs" x-text="$store.backups.formatBytes(b.size)"></td>
-                    <td>
-                      <span class="badge"
-                        :class="$store.backups.ageBadge(b.created_at || b.modified)"
-                        x-text="$store.backups.timeAgo(b.created_at || b.modified)"></span>
-                    </td>
-                    <td>
-                      <div class="flex items-center gap-2">
-                        <button class="btn btn-success btn-xs"
-                          @click="$store.backups.backupNow(b.project, b.env)"
-                          :disabled="$store.backups.isRunning(b.project, b.env, 'backup')">
-                          <span x-show="$store.backups.isRunning(b.project, b.env, 'backup')" class="spinner"></span>
-                          <span x-text="$store.backups.isRunning(b.project, b.env, 'backup') ? 'Running…' : 'Backup Now'"></span>
-                        </button>
-                        <button class="btn btn-ghost btn-xs"
-                          @click="$store.backups.uploadOffsite(b.project, b.env)"
-                          :disabled="$store.backups.isRunning(b.project, b.env, 'upload')">
-                          <span x-show="$store.backups.isRunning(b.project, b.env, 'upload')" class="spinner"></span>
-                          <span x-text="$store.backups.isRunning(b.project, b.env, 'upload') ? 'Uploading…' : 'Upload Offsite'"></span>
-                        </button>
-                      </div>
-                    </td>
-                  </tr>
-                </template>
-              </tbody>
-            </table>
-          </div>
-        </section>
-
-        <!-- ---- Offsite Backups ---- -->
-        <section>
-          <div class="flex items-center justify-between mb-4">
-            <h3 class="text-sm font-semibold text-gray-400 uppercase tracking-widest">Offsite Backups</h3>
-            <div class="flex items-center gap-2">
-              <button class="btn btn-warning btn-sm"
-                @click="$store.backups.applyRetention()"
-                :disabled="$store.backups.retentionRunning">
-                <span x-show="$store.backups.retentionRunning" class="spinner"></span>
-                Apply Retention
-              </button>
-              <button class="btn btn-ghost btn-sm" @click="$store.backups.fetchOffsite()"
-                :disabled="$store.backups.loadingOffsite">
-                <span x-show="$store.backups.loadingOffsite" class="spinner"></span>
-                Refresh
-              </button>
-            </div>
-          </div>
-
-          <div x-show="$store.backups.loadingOffsite && !$store.backups.offsite.length"
-            class="flex items-center gap-2 text-gray-500 text-sm py-6 justify-center">
-            <div class="spinner"></div> Loading…
-          </div>
-
-          <div x-show="!$store.backups.loadingOffsite && !$store.backups.offsite.length"
-            class="text-gray-600 text-sm py-6 text-center">No offsite backups found</div>
-
-          <div x-show="$store.backups.offsite.length > 0" class="table-wrapper">
-            <table class="ops-table">
-              <thead>
-                <tr>
-                  <th>Project</th>
-                  <th>Env</th>
-                  <th>File</th>
-                  <th>Size</th>
-                  <th>Age</th>
-                </tr>
-              </thead>
-              <tbody>
-                <template x-for="(b, idx) in $store.backups.offsite" :key="idx">
-                  <tr>
-                    <td class="font-medium text-white" x-text="b.project || '—'"></td>
-                    <td>
-                      <span class="badge badge-blue" x-text="b.env || '—'"></span>
-                    </td>
-                    <td class="mono text-xs text-gray-400 max-w-xs truncate" x-text="b.filename || b.file || '—'"></td>
-                    <td class="text-gray-400 text-xs" x-text="$store.backups.formatBytes(b.size)"></td>
-                    <td>
-                      <span class="badge"
-                        :class="$store.backups.ageBadge(b.created_at || b.modified)"
-                        x-text="$store.backups.timeAgo(b.created_at || b.modified)"></span>
-                    </td>
-                  </tr>
-                </template>
-              </tbody>
-            </table>
-          </div>
-        </section>
-      </div>
-
-      <!-- ======================================================
-           RESTORE PAGE
-           ====================================================== -->
-      <div x-show="$store.app.page === 'restore'" class="p-4 md:p-6 page-enter">
-        <div class="max-w-2xl">
-
-          <!-- Config card -->
-          <div class="card mb-6">
-            <h3 class="text-sm font-semibold text-gray-300 mb-5">Restore Configuration</h3>
-
-            <div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
-
-              <!-- Source -->
-              <div>
-                <label class="form-label">Source</label>
-                <div class="flex gap-4 mt-1">
-                  <label class="flex items-center gap-2 cursor-pointer">
-                    <input type="radio" name="restore-source" value="local"
-                      x-model="$store.restore.source"
-                      @change="$store.restore.onSourceChange()"
-                      class="accent-blue-500 w-4 h-4" />
-                    <span class="text-sm text-gray-300">Local</span>
-                  </label>
-                  <label class="flex items-center gap-2 cursor-pointer">
-                    <input type="radio" name="restore-source" value="offsite"
-                      x-model="$store.restore.source"
-                      @change="$store.restore.onSourceChange()"
-                      class="accent-blue-500 w-4 h-4" />
-                    <span class="text-sm text-gray-300">Offsite</span>
-                  </label>
-                </div>
-              </div>
-
-              <!-- Dry run -->
-              <div class="flex items-center">
-                <label class="flex items-center gap-2 cursor-pointer mt-5 sm:mt-0">
-                  <input type="checkbox" x-model="$store.restore.dryRun"
-                    class="accent-blue-500 w-4 h-4 rounded" />
-                  <span class="text-sm text-gray-300">Dry run (simulate only)</span>
-                </label>
-              </div>
-
-              <!-- Project -->
-              <div>
-                <label class="form-label">Project</label>
-                <div x-show="$store.restore.loadingProjects" class="flex items-center gap-2 text-gray-500 text-sm mt-1">
-                  <div class="spinner"></div> Loading…
-                </div>
-                <select class="form-select" x-show="!$store.restore.loadingProjects"
-                  x-model="$store.restore.project"
-                  @change="$store.restore.onProjectChange()">
-                  <option value="" disabled>Select project</option>
-                  <template x-for="p in $store.restore.projects" :key="p">
-                    <option :value="p" x-text="p"></option>
-                  </template>
-                </select>
-              </div>
-
-              <!-- Environment -->
-              <div>
-                <label class="form-label">Environment</label>
-                <select class="form-select" x-model="$store.restore.env"
-                  :disabled="!$store.restore.project">
-                  <option value="" disabled>Select environment</option>
-                  <template x-for="e in $store.restore.envs" :key="e">
-                    <option :value="e" x-text="e"></option>
-                  </template>
-                </select>
-              </div>
-            </div>
-
-            <!-- Action bar -->
-            <div class="flex items-center gap-3 mt-6 pt-5 border-t border-gray-700">
-              <button class="btn btn-danger"
-                @click="$store.restore.confirm()"
-                :disabled="$store.restore.running || !$store.restore.project || !$store.restore.env">
-                <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
-                  <path stroke-linecap="round" stroke-linejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"/>
-                </svg>
-                <span x-text="$store.restore.dryRun ? 'Dry Run' : 'Restore'"></span>
-              </button>
-              <button x-show="$store.restore.running" class="btn btn-ghost"
-                @click="$store.restore.abort()">Abort</button>
-              <span x-show="$store.restore.running" class="flex items-center gap-2 text-sm text-yellow-400">
-                <div class="spinner"></div> Running…
-              </span>
-            </div>
-          </div>
-
-          <!-- Output terminal -->
-          <div x-show="$store.restore.output.length > 0">
-            <div class="flex items-center justify-between mb-2">
-              <span class="text-xs text-gray-500 font-semibold uppercase tracking-widest">Output</span>
-              <button class="btn btn-ghost btn-xs" @click="$store.restore.output = []">Clear</button>
-            </div>
-            <div id="restore-output" class="terminal" style="height: 360px;">
-              <template x-for="(line, i) in $store.restore.output" :key="i">
-                <div :class="line.cls" x-text="line.text"></div>
-              </template>
-            </div>
-          </div>
-
-          <!-- Error -->
-          <div x-show="$store.restore.error"
-            class="mt-4 card border-red-800 text-red-400 text-sm"
-            x-text="$store.restore.error"></div>
-        </div>
-
-        <!-- Confirm dialog -->
-        <div x-show="$store.restore.confirming" class="modal-overlay" @click.self="$store.restore.cancel()">
-          <div class="modal-box max-w-md">
-            <div class="modal-header">
-              <h3 class="font-semibold text-white">Confirm Restore</h3>
-            </div>
-            <div class="modal-body text-sm text-gray-300 space-y-3">
-              <p>You are about to restore:</p>
-              <div class="bg-gray-900 rounded-lg p-3 space-y-2 text-xs font-mono">
-                <div><span class="text-gray-500">Project:</span> <span class="text-white" x-text="$store.restore.project"></span></div>
-                <div><span class="text-gray-500">Environment:</span> <span class="text-white" x-text="$store.restore.env"></span></div>
-                <div><span class="text-gray-500">Source:</span> <span class="text-white" x-text="$store.restore.source"></span></div>
-                <div><span class="text-gray-500">Dry Run:</span> <span :class="$store.restore.dryRun ? 'text-yellow-400' : 'text-white'" x-text="$store.restore.dryRun ? 'YES — simulate only' : 'NO — live restore'"></span></div>
-              </div>
-              <p x-show="!$store.restore.dryRun" class="text-yellow-400 font-medium">
-                This will overwrite existing data. This action cannot be undone.
-              </p>
-            </div>
-            <div class="modal-footer">
-              <button class="btn btn-ghost" @click="$store.restore.cancel()">Cancel</button>
-              <button class="btn btn-danger" @click="$store.restore.execute()">
-                <span x-text="$store.restore.dryRun ? 'Run Dry Test' : 'Yes, Restore Now'"></span>
-              </button>
-            </div>
-          </div>
-        </div>
-      </div>
-
-      <!-- ======================================================
-           SERVICES PAGE
-           ====================================================== -->
-      <div x-show="$store.app.page === 'services'" class="p-4 md:p-6 page-enter">
-
-        <!-- Loading -->
-        <div x-show="$store.services.loading && !$store.services.projects.length"
-          class="flex items-center justify-center h-48 text-gray-500">
-          <div class="spinner spinner-lg"></div>
-        </div>
-
-        <!-- Error -->
-        <div x-show="$store.services.error" class="card border-red-800 text-red-400 text-sm mb-4"
-          x-text="$store.services.error"></div>
-
-        <!-- Projects -->
-        <template x-for="proj in $store.services.projects" :key="proj.name">
-          <div class="mb-8">
-            <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3"
-              x-text="proj.name"></h3>
-
-            <div class="space-y-2">
-              <template x-for="svc in proj.services" :key="svc.name || svc.service">
-                <div class="card flex flex-col sm:flex-row sm:items-center gap-3">
-                  <!-- Status + name -->
-                  <div class="flex items-center gap-3 flex-1 min-w-0">
-                    <div class="badge flex-shrink-0"
-                      :class="$store.services.badgeClass(svc.status, svc.health)">
-                      <span class="status-dot"
-                        :class="$store.services.dotClass(svc.status, svc.health)"></span>
-                      <span x-text="svc.status || 'unknown'"></span>
-                    </div>
-                    <div>
-                      <div class="font-medium text-white text-sm" x-text="svc.name || svc.service"></div>
-                      <div class="text-xs text-gray-500" x-text="svc.uptime || ''"></div>
-                    </div>
-                  </div>
-
-                  <!-- Actions -->
-                  <div class="flex items-center gap-2 flex-shrink-0">
-                    <template x-if="svc.domain">
-                      <a :href="'https://' + svc.domain" target="_blank" rel="noopener"
-                        class="btn btn-ghost btn-xs">
-                        <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
-                          <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/>
-                        </svg>
-                        Open
-                      </a>
-                    </template>
-
-                    <button class="btn btn-ghost btn-xs"
-                      @click="$store.services.viewLogs(svc.project || proj.name, svc.env || 'prod', svc.name || svc.service)">
-                      <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
-                        <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9h16.5m-16.5 6.75h16.5"/>
-                      </svg>
-                      Logs
-                    </button>
-
-                    <button class="btn btn-warning btn-xs"
-                      @click="$store.services.askRestart(svc.project || proj.name, svc.env || 'prod', svc.name || svc.service)">
-                      <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
-                        <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-4.566l3.181 3.182m0-4.991v4.99"/>
-                      </svg>
-                      Restart
-                    </button>
-                  </div>
-                </div>
-              </template>
-            </div>
-          </div>
-        </template>
-
-        <!-- Log Modal -->
-        <div x-show="$store.services.logModal.open" class="modal-overlay" @click.self="$store.services.closeLogs()">
-          <div class="modal-box" style="max-width: 780px;">
-            <div class="modal-header">
-              <h3 class="font-semibold text-white text-sm" x-text="$store.services.logModal.title"></h3>
-              <button class="text-gray-400 hover:text-white text-lg leading-none" @click="$store.services.closeLogs()">&#x2715;</button>
-            </div>
-            <div class="modal-body p-0">
-              <div x-show="$store.services.logModal.loading"
-                class="flex items-center justify-center h-32 text-gray-500">
-                <div class="spinner spinner-lg"></div>
-              </div>
-              <div id="log-output" class="terminal rounded-none rounded-b-xl"
-                x-show="!$store.services.logModal.loading"
-                style="height: 480px; border-radius: 0 0 0.875rem 0.875rem; border: none;">
-                <template x-for="(line, i) in $store.services.logModal.lines" :key="i">
-                  <div x-text="typeof line === 'string' ? line : (line.text || JSON.stringify(line))"></div>
-                </template>
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <!-- Restart Confirm Modal -->
-        <div x-show="$store.services.confirmRestart.open" class="modal-overlay"
-          @click.self="$store.services.cancelRestart()">
-          <div class="modal-box max-w-md">
-            <div class="modal-header">
-              <h3 class="font-semibold text-white">Confirm Restart</h3>
-            </div>
-            <div class="modal-body text-sm text-gray-300">
-              <p>Restart service <strong class="text-white" x-text="$store.services.confirmRestart.service"></strong>?</p>
-              <p class="text-gray-500 mt-1 text-xs">This will briefly interrupt the service.</p>
-            </div>
-            <div class="modal-footer">
-              <button class="btn btn-ghost" @click="$store.services.cancelRestart()">Cancel</button>
-              <button class="btn btn-warning" @click="$store.services.doRestart()"
-                :disabled="$store.services.confirmRestart.running">
-                <span x-show="$store.services.confirmRestart.running" class="spinner"></span>
-                <span x-text="$store.services.confirmRestart.running ? 'Restarting…' : 'Restart'"></span>
-              </button>
-            </div>
-          </div>
-        </div>
-      </div>
-
-      <!-- ======================================================
-           SYSTEM PAGE
-           ====================================================== -->
-      <div x-show="$store.app.page === 'system'" class="p-4 md:p-6 page-enter space-y-8">
-
-        <!-- System Info -->
-        <section>
-          <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3">System Info</h3>
-          <div x-show="$store.system.loading.info" class="flex items-center gap-2 text-gray-500 text-sm">
-            <div class="spinner"></div> Loading…
-          </div>
-          <div x-show="!$store.system.loading.info" class="grid grid-cols-2 sm:grid-cols-4 gap-3">
-            <div class="card text-center">
-              <div class="text-xs text-gray-500 mb-1">Uptime</div>
-              <div class="text-white font-medium text-sm" x-text="$store.system.info.uptime || '—'"></div>
-            </div>
-            <div class="card text-center">
-              <div class="text-xs text-gray-500 mb-1">Load (1m)</div>
-              <div class="text-white font-medium text-sm" x-text="$store.system.info.load_1 ?? $store.system.info.load_avg?.[0] ?? '—'"></div>
-            </div>
-            <div class="card text-center">
-              <div class="text-xs text-gray-500 mb-1">Load (5m)</div>
-              <div class="text-white font-medium text-sm" x-text="$store.system.info.load_5 ?? $store.system.info.load_avg?.[1] ?? '—'"></div>
-            </div>
-            <div class="card text-center">
-              <div class="text-xs text-gray-500 mb-1">Hostname</div>
-              <div class="text-white font-medium text-sm truncate" x-text="$store.system.info.hostname || '—'"></div>
-            </div>
-          </div>
-        </section>
-
-        <!-- Disk Usage -->
-        <section>
-          <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3">Disk Usage</h3>
-          <div x-show="$store.system.loading.disk" class="flex items-center gap-2 text-gray-500 text-sm">
-            <div class="spinner"></div> Loading…
-          </div>
-          <div x-show="!$store.system.loading.disk && !$store.system.disk.length"
-            class="text-gray-600 text-sm">No disk data</div>
-          <div x-show="!$store.system.loading.disk && $store.system.disk.length > 0" class="space-y-3">
-            <template x-for="(d, i) in $store.system.disk" :key="i">
-              <div class="card">
-                <div class="flex items-center justify-between mb-2">
-                  <div>
-                    <span class="text-white font-medium text-sm" x-text="d.mount || d.filesystem || '?'"></span>
-                    <span class="text-gray-500 text-xs ml-2" x-text="d.filesystem || d.device || ''"></span>
-                  </div>
-                  <span class="text-sm font-semibold"
-                    :class="(d.use_pct || d.pct || 0) >= 90 ? 'text-red-400' : (d.use_pct || d.pct || 0) >= 75 ? 'text-yellow-400' : 'text-green-400'"
-                    x-text="(d.use_pct || d.pct || 0) + '%'"></span>
-                </div>
-                <div class="progress-bar-track">
-                  <div class="progress-bar-fill"
-                    :class="$store.system.diskBarClass(d.use_pct || d.pct || 0)"
-                    :style="'width: ' + Math.min(d.use_pct || d.pct || 0, 100) + '%'"></div>
-                </div>
-                <div class="flex justify-between text-xs text-gray-500 mt-1">
-                  <span x-text="'Used: ' + $store.system.formatBytes(d.used)"></span>
-                  <span x-text="'Total: ' + $store.system.formatBytes(d.total || d.size)"></span>
-                </div>
-              </div>
-            </template>
-          </div>
-        </section>
-
-        <!-- Health Checks -->
-        <section>
-          <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3">Health Checks</h3>
-          <div x-show="$store.system.loading.health" class="flex items-center gap-2 text-gray-500 text-sm">
-            <div class="spinner"></div> Loading…
-          </div>
-          <div x-show="!$store.system.loading.health && !$store.system.health.length"
-            class="text-gray-600 text-sm">No health data</div>
-          <div x-show="!$store.system.loading.health && $store.system.health.length > 0" class="space-y-2">
-            <template x-for="(h, i) in $store.system.health" :key="i">
-              <div class="card flex items-center justify-between gap-3">
-                <div class="flex items-center gap-3">
-                  <div class="w-2 h-2 rounded-full flex-shrink-0"
-                    :class="h.ok || h.status === 'pass' ? 'bg-green-400' : 'bg-red-400'"></div>
-                  <span class="text-sm text-gray-300 font-medium" x-text="h.name || h.check"></span>
-                </div>
-                <div class="flex items-center gap-2">
-                  <span class="text-xs text-gray-500" x-text="h.detail || h.message || ''"></span>
-                  <span class="badge"
-                    :class="h.ok || h.status === 'pass' ? 'badge-green' : 'badge-red'"
-                    x-text="h.ok || h.status === 'pass' ? 'PASS' : 'FAIL'"></span>
-                </div>
-              </div>
-            </template>
-          </div>
-        </section>
-
-        <!-- Systemd Timers -->
-        <section>
-          <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3">Systemd Timers</h3>
-          <div x-show="$store.system.loading.timers" class="flex items-center gap-2 text-gray-500 text-sm">
-            <div class="spinner"></div> Loading…
-          </div>
-          <div x-show="!$store.system.loading.timers && !$store.system.timers.length"
-            class="text-gray-600 text-sm">No timer data</div>
-          <div x-show="!$store.system.loading.timers && $store.system.timers.length > 0" class="table-wrapper">
-            <table class="ops-table">
-              <thead>
-                <tr>
-                  <th>Timer</th>
-                  <th>Last Run</th>
-                  <th>Next Run</th>
-                  <th>Status</th>
-                </tr>
-              </thead>
-              <tbody>
-                <template x-for="(t, i) in $store.system.timers" :key="i">
-                  <tr>
-                    <td class="font-medium text-white" x-text="t.name || t.timer"></td>
-                    <td class="text-gray-400 text-xs mono" x-text="t.last || t.last_trigger || '—'"></td>
-                    <td class="text-gray-400 text-xs mono" x-text="t.next || t.next_elapse || '—'"></td>
-                    <td>
-                      <span class="badge"
-                        :class="t.active || t.status === 'active' ? 'badge-green' : 'badge-gray'"
-                        x-text="t.status || (t.active ? 'active' : 'inactive')"></span>
-                    </td>
-                  </tr>
-                </template>
-              </tbody>
-            </table>
-          </div>
-        </section>
-
-      </div>
-
-    </main>
+    <!-- Page content -->
+    <div id="page-content"></div>
   </div>
 </div>
 
+<!-- Toast Container -->
+<div id="toast-container" class="toast-container"></div>
+
+<!-- Log Modal -->
+<div id="log-modal" class="modal-overlay" style="display:none;" onclick="if(event.target===this)closeLogModal()">
+  <div class="modal-box" style="max-width:800px;">
+    <div class="modal-header">
+      <span id="log-modal-title" style="font-weight:600;color:#f3f4f6;">Container Logs</span>
+      <button onclick="closeLogModal()" style="background:none;border:none;color:#9ca3af;font-size:1.25rem;cursor:pointer;">&times;</button>
+    </div>
+    <div class="modal-body" style="padding:0;">
+      <div id="log-modal-content" class="terminal" style="max-height:60vh;border:none;border-radius:0;"></div>
+    </div>
+    <div class="modal-footer">
+      <button class="btn btn-ghost btn-sm" onclick="closeLogModal()">Close</button>
+      <button class="btn btn-primary btn-sm" id="log-refresh-btn" onclick="refreshLogs()">Refresh</button>
+    </div>
+  </div>
+</div>
+
+<script src="/static/js/app.js"></script>
 </body>
 </html>
diff --git a/static/js/app.js b/static/js/app.js
index 256b139..41fd842 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -1,15 +1,30 @@
-/* ============================================================
-   OPS Dashboard — Alpine.js Application Logic
-   ============================================================ */
-
 'use strict';
 
-// ----------------------------------------------------------------
-// Helpers
-// ----------------------------------------------------------------
+// ============================================================
+// OPS Dashboard — Vanilla JS Application
+// ============================================================
 
+// ---------------------------------------------------------------------------
+// State
+// ---------------------------------------------------------------------------
+let allServices = [];
+let currentPage = 'dashboard';
+let drillLevel = 0;        // 0=projects, 1=environments, 2=services
+let drillProject = null;
+let drillEnv = null;
+let refreshTimer = null;
+const REFRESH_INTERVAL = 30000;
+
+// Log modal state
+let logModalProject = null;
+let logModalEnv = null;
+let logModalService = null;
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
 function formatBytes(bytes) {
-  if (bytes == null || bytes === '') return '—';
+  if (bytes == null || bytes === '') return '\u2014';
   const n = Number(bytes);
   if (isNaN(n) || n === 0) return '0 B';
   const k = 1024;
@@ -19,755 +34,779 @@
 }
 
 function timeAgo(dateInput) {
-  if (!dateInput) return '—';
+  if (!dateInput) return '\u2014';
   const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
-  if (isNaN(date)) return '—';
+  if (isNaN(date)) return '\u2014';
   const secs = Math.floor((Date.now() - date.getTime()) / 1000);
-  if (secs < 60)     return secs + 's ago';
-  if (secs < 3600)   return Math.floor(secs / 60) + 'm ago';
-  if (secs < 86400)  return Math.floor(secs / 3600) + 'h ago';
+  if (secs < 60) return secs + 's ago';
+  if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
+  if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';
   return Math.floor(secs / 86400) + 'd ago';
 }
 
-function ageHours(dateInput) {
-  if (!dateInput) return 0;
-  const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
-  if (isNaN(date)) return 0;
-  return (Date.now() - date.getTime()) / 3600000;
-}
-
-function ageBadgeClass(dateInput) {
-  const h = ageHours(dateInput);
-  if (h >= 48) return 'badge-red';
-  if (h >= 24) return 'badge-yellow';
-  return 'badge-green';
-}
-
-function statusBadgeClass(status, health) {
-  const s = (status || '').toLowerCase();
-  const h = (health || '').toLowerCase();
-  if (s === 'running' && (h === 'healthy' || h === ''))  return 'badge-green';
-  if (s === 'running' && h === 'unhealthy')              return 'badge-red';
-  if (s === 'running' && h === 'starting')               return 'badge-yellow';
-  if (s === 'restarting' || h === 'starting')            return 'badge-yellow';
-  if (s === 'exited' || s === 'dead' || s === 'removed') return 'badge-red';
-  if (s === 'paused')                                    return 'badge-yellow';
-  return 'badge-gray';
+function escapeHtml(str) {
+  const div = document.createElement('div');
+  div.textContent = str;
+  return div.innerHTML;
 }
 
 function statusDotClass(status, health) {
-  const cls = statusBadgeClass(status, health);
-  return cls.replace('badge-', 'status-dot-');
+  const s = (status || '').toLowerCase();
+  const h = (health || '').toLowerCase();
+  if (s === 'up' && (h === 'healthy' || h === '')) return 'status-dot-green';
+  if (s === 'up' && h === 'unhealthy') return 'status-dot-red';
+  if (s === 'up' && h === 'starting') return 'status-dot-yellow';
+  if (s === 'down' || s === 'exited') return 'status-dot-red';
+  return 'status-dot-gray';
 }
 
-function diskBarClass(pct) {
-  if (pct >= 90) return 'disk-danger';
-  if (pct >= 75) return 'disk-warn';
+function badgeClass(status, health) {
+  const s = (status || '').toLowerCase();
+  const h = (health || '').toLowerCase();
+  if (s === 'up' && (h === 'healthy' || h === '')) return 'badge-green';
+  if (s === 'up' && h === 'unhealthy') return 'badge-red';
+  if (s === 'up' && h === 'starting') return 'badge-yellow';
+  if (s === 'down' || s === 'exited') return 'badge-red';
+  return 'badge-gray';
+}
+
+function diskColorClass(pct) {
+  const n = parseInt(pct);
+  if (isNaN(n)) return 'disk-ok';
+  if (n >= 90) return 'disk-danger';
+  if (n >= 75) return 'disk-warn';
   return 'disk-ok';
 }
 
-// ----------------------------------------------------------------
-// Auth Store
-// ----------------------------------------------------------------
-
-function authStore() {
-  return {
-    token: localStorage.getItem('ops_token') || '',
-    loginInput: '',
-    loginError: '',
-    loading: false,
-
-    get isAuthenticated() {
-      return !!this.token;
-    },
-
-    async login() {
-      this.loginError = '';
-      if (!this.loginInput.trim()) {
-        this.loginError = 'Please enter your access token.';
-        return;
-      }
-      this.loading = true;
-      try {
-        const res = await fetch('/api/status/', {
-          headers: { 'Authorization': 'Bearer ' + this.loginInput.trim() }
-        });
-        if (res.ok || res.status === 200) {
-          this.token = this.loginInput.trim();
-          localStorage.setItem('ops_token', this.token);
-          this.loginInput = '';
-          // Trigger page load
-          this.$dispatch('authenticated');
-        } else if (res.status === 401) {
-          this.loginError = 'Invalid token. Please try again.';
-        } else {
-          this.loginError = 'Server error (' + res.status + '). Please try again.';
-        }
-      } catch {
-        this.loginError = 'Could not reach the server.';
-      } finally {
-        this.loading = false;
-      }
-    },
-
-    logout() {
-      this.token = '';
-      localStorage.removeItem('ops_token');
-    }
-  };
+// ---------------------------------------------------------------------------
+// Auth
+// ---------------------------------------------------------------------------
+function getToken() {
+  return localStorage.getItem('ops_token');
 }
 
-// ----------------------------------------------------------------
+function doLogin() {
+  const input = document.getElementById('login-token');
+  const errEl = document.getElementById('login-error');
+  const token = input.value.trim();
+  if (!token) {
+    errEl.textContent = 'Please enter a token';
+    errEl.style.display = 'block';
+    return;
+  }
+  errEl.style.display = 'none';
+
+  // Validate token by calling the API
+  fetch('/api/status/', { headers: { 'Authorization': 'Bearer ' + token } })
+    .then(r => {
+      if (!r.ok) throw new Error('Invalid token');
+      return r.json();
+    })
+    .then(data => {
+      localStorage.setItem('ops_token', token);
+      allServices = data;
+      document.getElementById('login-overlay').style.display = 'none';
+      document.getElementById('app').style.display = 'flex';
+      showPage('dashboard');
+      startAutoRefresh();
+    })
+    .catch(() => {
+      errEl.textContent = 'Invalid token. Try again.';
+      errEl.style.display = 'block';
+    });
+}
+
+function doLogout() {
+  localStorage.removeItem('ops_token');
+  stopAutoRefresh();
+  document.getElementById('app').style.display = 'none';
+  document.getElementById('login-overlay').style.display = 'flex';
+  document.getElementById('login-token').value = '';
+}
+
+// ---------------------------------------------------------------------------
 // API Helper
-// ----------------------------------------------------------------
-
-function api(path, options = {}) {
-  const token = localStorage.getItem('ops_token') || '';
-  const headers = Object.assign({ 'Authorization': 'Bearer ' + token }, options.headers || {});
-  if (options.json) {
-    headers['Content-Type'] = 'application/json';
-    options.body = JSON.stringify(options.json);
-    delete options.json;
+// ---------------------------------------------------------------------------
+async function api(path, opts = {}) {
+  const token = getToken();
+  const headers = { ...(opts.headers || {}), 'Authorization': 'Bearer ' + token };
+  const resp = await fetch(path, { ...opts, headers });
+  if (resp.status === 401) {
+    doLogout();
+    throw new Error('Session expired');
   }
-  return fetch(path, Object.assign({}, options, { headers })).then(res => {
-    if (res.status === 401) {
-      localStorage.removeItem('ops_token');
-      window.dispatchEvent(new CustomEvent('unauthorized'));
-      throw new Error('Unauthorized');
-    }
-    return res;
+  if (!resp.ok) {
+    const body = await resp.text();
+    throw new Error(body || 'HTTP ' + resp.status);
+  }
+  const ct = resp.headers.get('content-type') || '';
+  if (ct.includes('json')) return resp.json();
+  return resp.text();
+}
+
+async function fetchStatus() {
+  allServices = await api('/api/status/');
+}
+
+// ---------------------------------------------------------------------------
+// Toast Notifications
+// ---------------------------------------------------------------------------
+function toast(message, type = 'info') {
+  const container = document.getElementById('toast-container');
+  const el = document.createElement('div');
+  el.className = 'toast toast-' + type;
+  el.innerHTML = `<span>${escapeHtml(message)}</span><span class="toast-dismiss" onclick="this.parentElement.remove()">&times;</span>`;
+  container.appendChild(el);
+  setTimeout(() => {
+    el.classList.add('toast-out');
+    setTimeout(() => el.remove(), 200);
+  }, 4000);
+}
+
+// ---------------------------------------------------------------------------
+// Sidebar & Navigation
+// ---------------------------------------------------------------------------
+function toggleSidebar() {
+  document.getElementById('sidebar').classList.toggle('open');
+  document.getElementById('mobile-overlay').classList.toggle('open');
+}
+
+function showPage(page) {
+  currentPage = page;
+  drillLevel = 0;
+  drillProject = null;
+  drillEnv = null;
+
+  // Update sidebar active
+  document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el => {
+    el.classList.toggle('active', el.dataset.page === page);
   });
+
+  // Close mobile sidebar
+  document.getElementById('sidebar').classList.remove('open');
+  document.getElementById('mobile-overlay').classList.remove('open');
+
+  renderPage();
 }
 
-// ----------------------------------------------------------------
-// Toast Store
-// ----------------------------------------------------------------
+function renderPage() {
+  const content = document.getElementById('page-content');
+  content.innerHTML = '<div style="text-align:center;padding:3rem;"><div class="spinner spinner-lg"></div></div>';
 
-function toastStore() {
-  return {
-    toasts: [],
-    _counter: 0,
-
-    add(msg, type = 'info', duration = 4000) {
-      const id = ++this._counter;
-      this.toasts.push({ id, msg, type });
-      if (duration > 0) {
-        setTimeout(() => this.remove(id), duration);
-      }
-      return id;
-    },
-
-    remove(id) {
-      const idx = this.toasts.findIndex(t => t.id === id);
-      if (idx !== -1) this.toasts.splice(idx, 1);
-    },
-
-    success(msg) { return this.add(msg, 'toast-success'); },
-    error(msg)   { return this.add(msg, 'toast-error', 6000); },
-    warn(msg)    { return this.add(msg, 'toast-warning'); },
-    info(msg)    { return this.add(msg, 'toast-info'); },
-
-    iconFor(type) {
-      const icons = {
-        'toast-success': '✓',
-        'toast-error':   '✕',
-        'toast-warning': '⚠',
-        'toast-info':    'ℹ'
-      };
-      return icons[type] || 'ℹ';
-    }
-  };
-}
-
-// ----------------------------------------------------------------
-// App Root Store
-// ----------------------------------------------------------------
-
-function appStore() {
-  return {
-    page: 'dashboard',
-    sidebarOpen: false,
-    toast: null,
-
-    init() {
-      this.toast = Alpine.store('toast');
-      window.addEventListener('unauthorized', () => {
-        Alpine.store('auth').logout();
-      });
-      window.addEventListener('authenticated', () => {
-        this.loadPage('dashboard');
-      });
-    },
-
-    navigate(page) {
-      this.page = page;
-      this.sidebarOpen = false;
-      this.loadPage(page);
-    },
-
-    loadPage(page) {
-      const storeMap = {
-        dashboard: 'dashboard',
-        backups:   'backups',
-        restore:   'restore',
-        services:  'services',
-        system:    'system'
-      };
-      const storeName = storeMap[page];
-      if (storeName && Alpine.store(storeName) && Alpine.store(storeName).load) {
-        Alpine.store(storeName).load();
-      }
-    }
-  };
-}
-
-// ----------------------------------------------------------------
-// Dashboard Store
-// ----------------------------------------------------------------
-
-function dashboardStore() {
-  return {
-    projects: [],
-    loading: false,
-    error: null,
-    lastRefresh: null,
-    refreshInterval: null,
-    autoRefreshEnabled: true,
-    refreshing: false,
-
-    load() {
-      this.fetch();
-      this.startAutoRefresh();
-    },
-
-    async fetch() {
-      if (this.loading) return;
-      this.loading = true;
-      this.error = null;
-      try {
-        const res = await api('/api/status/');
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        const data = await res.json();
-        this.projects = this.groupByProject(data);
-        this.lastRefresh = new Date();
-      } catch (e) {
-        if (e.message !== 'Unauthorized') {
-          this.error = e.message || 'Failed to load status';
-        }
-      } finally {
-        this.loading = false;
-        this.refreshing = false;
-      }
-    },
-
-    groupByProject(data) {
-      // data may be array of containers or object keyed by project
-      let containers = Array.isArray(data) ? data : Object.values(data).flat();
-      const map = {};
-      for (const c of containers) {
-        const proj = c.project || 'Other';
-        if (!map[proj]) map[proj] = [];
-        map[proj].push(c);
-      }
-      return Object.entries(map).map(([name, services]) => ({ name, services }))
-        .sort((a, b) => a.name.localeCompare(b.name));
-    },
-
-    manualRefresh() {
-      this.refreshing = true;
-      this.fetch();
-    },
-
-    startAutoRefresh() {
-      this.stopAutoRefresh();
-      if (this.autoRefreshEnabled) {
-        this.refreshInterval = setInterval(() => this.fetch(), 30000);
-      }
-    },
-
-    stopAutoRefresh() {
-      if (this.refreshInterval) {
-        clearInterval(this.refreshInterval);
-        this.refreshInterval = null;
-      }
-    },
-
-    toggleAutoRefresh() {
-      this.autoRefreshEnabled = !this.autoRefreshEnabled;
-      if (this.autoRefreshEnabled) {
-        this.startAutoRefresh();
-      } else {
-        this.stopAutoRefresh();
-      }
-    },
-
-    destroy() {
-      this.stopAutoRefresh();
-    },
-
-    badgeClass: statusBadgeClass,
-    dotClass: statusDotClass,
-    timeAgo
-  };
-}
-
-// ----------------------------------------------------------------
-// Backups Store
-// ----------------------------------------------------------------
-
-function backupsStore() {
-  return {
-    local: [],
-    offsite: [],
-    loading: false,
-    loadingOffsite: false,
-    error: null,
-    ops: {},  // track per-row operation state: key -> { loading, done, error }
-
-    load() {
-      this.fetchLocal();
-      this.fetchOffsite();
-    },
-
-    async fetchLocal() {
-      this.loading = true;
-      this.error = null;
-      try {
-        const res = await api('/api/backups/');
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        const data = await res.json();
-        this.local = Array.isArray(data) ? data : Object.values(data).flat();
-      } catch (e) {
-        if (e.message !== 'Unauthorized') this.error = e.message;
-      } finally {
-        this.loading = false;
-      }
-    },
-
-    async fetchOffsite() {
-      this.loadingOffsite = true;
-      try {
-        const res = await api('/api/backups/offsite');
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        const data = await res.json();
-        this.offsite = Array.isArray(data) ? data : Object.values(data).flat();
-      } catch (e) {
-        if (e.message !== 'Unauthorized')
-          Alpine.store('toast').error('Offsite: ' + (e.message || 'Failed'));
-      } finally {
-        this.loadingOffsite = false;
-      }
-    },
-
-    opKey(project, env, action) {
-      return `${action}::${project}::${env}`;
-    },
-
-    isRunning(project, env, action) {
-      return !!(this.ops[this.opKey(project, env, action)]?.loading);
-    },
-
-    async backupNow(project, env) {
-      const key = this.opKey(project, env, 'backup');
-      this.ops = { ...this.ops, [key]: { loading: true } };
-      try {
-        const res = await api(`/api/backups/${project}/${env}`, { method: 'POST' });
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        Alpine.store('toast').success(`Backup started: ${project}/${env}`);
-        setTimeout(() => this.fetchLocal(), 2000);
-      } catch (e) {
-        if (e.message !== 'Unauthorized')
-          Alpine.store('toast').error(`Backup failed: ${e.message}`);
-      } finally {
-        this.ops = { ...this.ops, [key]: { loading: false } };
-      }
-    },
-
-    async uploadOffsite(project, env) {
-      const key = this.opKey(project, env, 'upload');
-      this.ops = { ...this.ops, [key]: { loading: true } };
-      try {
-        const res = await api(`/api/backups/offsite/upload/${project}/${env}`, { method: 'POST' });
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        Alpine.store('toast').success(`Upload started: ${project}/${env}`);
-        setTimeout(() => this.fetchOffsite(), 3000);
-      } catch (e) {
-        if (e.message !== 'Unauthorized')
-          Alpine.store('toast').error(`Upload failed: ${e.message}`);
-      } finally {
-        this.ops = { ...this.ops, [key]: { loading: false } };
-      }
-    },
-
-    retentionRunning: false,
-
-    async applyRetention() {
-      this.retentionRunning = true;
-      try {
-        const res = await api('/api/backups/offsite/retention', { method: 'POST' });
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        Alpine.store('toast').success('Retention policy applied');
-        setTimeout(() => this.fetchOffsite(), 2000);
-      } catch (e) {
-        if (e.message !== 'Unauthorized')
-          Alpine.store('toast').error(`Retention failed: ${e.message}`);
-      } finally {
-        this.retentionRunning = false;
-      }
-    },
-
-    ageBadge: ageBadgeClass,
-    timeAgo,
-    formatBytes
-  };
-}
-
-// ----------------------------------------------------------------
-// Restore Store
-// ----------------------------------------------------------------
-
-function restoreStore() {
-  return {
-    source: 'local',
-    project: '',
-    env: '',
-    dryRun: false,
-    confirming: false,
-    running: false,
-    output: [],
-    sseSource: null,
-    projects: [],
-    envs: [],
-    loadingProjects: false,
-    error: null,
-
-    load() {
-      this.loadProjectList();
-    },
-
-    async loadProjectList() {
-      this.loadingProjects = true;
-      try {
-        const endpoint = this.source === 'offsite' ? '/api/backups/offsite' : '/api/backups/';
-        const res = await api(endpoint);
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        const data = await res.json();
-        const items = Array.isArray(data) ? data : Object.values(data).flat();
-        const projSet = new Set(items.map(i => i.project).filter(Boolean));
-        this.projects = Array.from(projSet).sort();
-        this.project = this.projects[0] || '';
-        this.updateEnvs(items);
-      } catch (e) {
-        if (e.message !== 'Unauthorized') this.error = e.message;
-      } finally {
-        this.loadingProjects = false;
-      }
-    },
-
-    updateEnvs(items) {
-      if (!items) return;
-      const envSet = new Set(
-        items.filter(i => i.project === this.project).map(i => i.env).filter(Boolean)
-      );
-      this.envs = Array.from(envSet).sort();
-      this.env = this.envs[0] || '';
-    },
-
-    onSourceChange() {
-      this.project = '';
-      this.env = '';
-      this.envs = [];
-      this.loadProjectList();
-    },
-
-    onProjectChange() {
-      // Re-fetch envs for this project from the already loaded data
-      this.loadProjectList();
-    },
-
-    confirm() {
-      if (!this.project || !this.env) {
-        Alpine.store('toast').warn('Select project and environment first');
-        return;
-      }
-      this.confirming = true;
-    },
-
-    cancel() {
-      this.confirming = false;
-    },
-
-    async execute() {
-      this.confirming = false;
-      this.running = true;
-      this.output = [];
-
-      const params = new URLSearchParams({
-        source: this.source,
-        dry_run: this.dryRun ? '1' : '0'
-      });
-      const url = `/api/restore/${this.project}/${this.env}?${params}`;
-
-      try {
-        this.sseSource = new EventSource(url + '&token=' + encodeURIComponent(localStorage.getItem('ops_token') || ''));
-        this.sseSource.onmessage = (e) => {
-          try {
-            const msg = JSON.parse(e.data);
-            if (msg.done) {
-              this.sseSource.close();
-              this.sseSource = null;
-              this.running = false;
-              if (msg.success) {
-                Alpine.store('toast').success('Restore completed');
-              } else {
-                Alpine.store('toast').error('Restore finished with errors');
-              }
-              return;
-            }
-            const text = msg.line || e.data;
-            this.output.push({ text, cls: this.classifyLine(text) });
-          } catch {
-            this.output.push({ text: e.data, cls: this.classifyLine(e.data) });
-          }
-          this.$nextTick(() => {
-            const el = document.getElementById('restore-output');
-            if (el) el.scrollTop = el.scrollHeight;
-          });
-        };
-        this.sseSource.onerror = () => {
-          if (this.running) {
-            this.running = false;
-            if (this.sseSource) this.sseSource.close();
-            this.sseSource = null;
-          }
-        };
-      } catch (e) {
-        this.running = false;
-        Alpine.store('toast').error('Restore failed: ' + (e.message || 'Unknown error'));
-      }
-    },
-
-    classifyLine(text) {
-      const t = text.toLowerCase();
-      if (t.includes('error') || t.includes('fail') || t.includes('critical')) return 'line-error';
-      if (t.includes('warn'))  return 'line-warn';
-      if (t.includes('ok') || t.includes('success') || t.includes('done')) return 'line-ok';
-      if (t.startsWith('$') || t.startsWith('#') || t.startsWith('>')) return 'line-cmd';
-      return '';
-    },
-
-    abort() {
-      if (this.sseSource) {
-        this.sseSource.close();
-        this.sseSource = null;
-      }
-      this.running = false;
-      this.output.push({ text: '--- aborted by user ---', cls: 'line-warn' });
-    }
-  };
-}
-
-// ----------------------------------------------------------------
-// Services Store
-// ----------------------------------------------------------------
-
-function servicesStore() {
-  return {
-    projects: [],
-    loading: false,
-    error: null,
-    logModal: { open: false, title: '', lines: [], loading: false },
-    confirmRestart: { open: false, project: '', env: '', service: '', running: false },
-
-    load() {
-      this.fetch();
-    },
-
-    async fetch() {
-      this.loading = true;
-      this.error = null;
-      try {
-        const res = await api('/api/status/');
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        const data = await res.json();
-        this.projects = this.groupByProject(data);
-      } catch (e) {
-        if (e.message !== 'Unauthorized') this.error = e.message;
-      } finally {
-        this.loading = false;
-      }
-    },
-
-    groupByProject(data) {
-      let containers = Array.isArray(data) ? data : Object.values(data).flat();
-      const map = {};
-      for (const c of containers) {
-        const proj = c.project || 'Other';
-        if (!map[proj]) map[proj] = [];
-        map[proj].push(c);
-      }
-      return Object.entries(map).map(([name, services]) => ({ name, services }))
-        .sort((a, b) => a.name.localeCompare(b.name));
-    },
-
-    async viewLogs(project, env, service) {
-      this.logModal = {
-        open: true,
-        title: `${service} — logs`,
-        lines: [],
-        loading: true
-      };
-      try {
-        const res = await api(`/api/services/logs/${project}/${env}/${service}?lines=150`);
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        const data = await res.json();
-        this.logModal.lines = (data.logs || '').split('\n');
-      } catch (e) {
-        if (e.message !== 'Unauthorized')
-          this.logModal.lines = ['Error: ' + e.message];
-      } finally {
-        this.logModal.loading = false;
-        this.$nextTick(() => {
-          const el = document.getElementById('log-output');
-          if (el) el.scrollTop = el.scrollHeight;
-        });
-      }
-    },
-
-    closeLogs() {
-      this.logModal.open = false;
-    },
-
-    askRestart(project, env, service) {
-      this.confirmRestart = { open: true, project, env, service, running: false };
-    },
-
-    cancelRestart() {
-      this.confirmRestart.open = false;
-    },
-
-    async doRestart() {
-      const { project, env, service } = this.confirmRestart;
-      this.confirmRestart.running = true;
-      try {
-        const res = await api(`/api/services/restart/${project}/${env}/${service}`, { method: 'POST' });
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        Alpine.store('toast').success(`${service} restarted`);
-        this.confirmRestart.open = false;
-        setTimeout(() => this.fetch(), 2000);
-      } catch (e) {
-        if (e.message !== 'Unauthorized')
-          Alpine.store('toast').error(`Restart failed: ${e.message}`);
-      } finally {
-        this.confirmRestart.running = false;
-      }
-    },
-
-    badgeClass: statusBadgeClass,
-    dotClass: statusDotClass
-  };
-}
-
-// ----------------------------------------------------------------
-// System Store
-// ----------------------------------------------------------------
-
-function systemStore() {
-  return {
-    disk: [],
-    health: [],
-    timers: [],
-    info: {},
-    loading: { disk: false, health: false, timers: false, info: false },
-    error: null,
-
-    load() {
-      this.fetchDisk();
-      this.fetchHealth();
-      this.fetchTimers();
-      this.fetchInfo();
-    },
-
-    async fetchDisk() {
-      this.loading.disk = true;
-      try {
-        const res = await api('/api/system//disk');
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        const data = await res.json();
-        this.disk = Array.isArray(data) ? data : (data.filesystems || []);
-      } catch (e) {
-        if (e.message !== 'Unauthorized') this.error = e.message;
-      } finally {
-        this.loading.disk = false;
-      }
-    },
-
-    async fetchHealth() {
-      this.loading.health = true;
-      try {
-        const res = await api('/api/system//health');
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        const data = await res.json();
-        this.health = Array.isArray(data) ? data : (data.checks || []);
-      } catch (e) {
-        if (e.message !== 'Unauthorized') {}
-      } finally {
-        this.loading.health = false;
-      }
-    },
-
-    async fetchTimers() {
-      this.loading.timers = true;
-      try {
-        const res = await api('/api/system//timers');
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        const data = await res.json();
-        this.timers = Array.isArray(data) ? data : (data.timers || []);
-      } catch (e) {
-        if (e.message !== 'Unauthorized') {}
-      } finally {
-        this.loading.timers = false;
-      }
-    },
-
-    async fetchInfo() {
-      this.loading.info = true;
-      try {
-        const res = await api('/api/system//info');
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        this.info = await res.json();
-      } catch (e) {
-        if (e.message !== 'Unauthorized') {}
-      } finally {
-        this.loading.info = false;
-      }
-    },
-
-    diskBarClass,
-    formatBytes,
-    timeAgo
-  };
-}
-
-// ----------------------------------------------------------------
-// Alpine initialization
-// ----------------------------------------------------------------
-
-document.addEventListener('alpine:init', () => {
-  Alpine.store('auth',      authStore());
-  Alpine.store('toast',     toastStore());
-  Alpine.store('app',       appStore());
-  Alpine.store('dashboard', dashboardStore());
-  Alpine.store('backups',   backupsStore());
-  Alpine.store('restore',   restoreStore());
-  Alpine.store('services',  servicesStore());
-  Alpine.store('system',    systemStore());
-
-  // Init app store
-  Alpine.store('app').init();
-
-  // Load the dashboard if already authenticated
-  if (Alpine.store('auth').isAuthenticated) {
-    Alpine.store('dashboard').load();
+  switch (currentPage) {
+    case 'dashboard': renderDashboard(); break;
+    case 'services':  renderServicesFlat(); break;
+    case 'backups':   renderBackups(); break;
+    case 'system':    renderSystem(); break;
+    case 'restore':   renderRestore(); break;
+    default:          renderDashboard();
   }
-});
+}
+
+function refreshCurrentPage() {
+  showRefreshSpinner();
+  fetchStatus()
+    .then(() => renderPage())
+    .catch(e => toast('Refresh failed: ' + e.message, 'error'))
+    .finally(() => hideRefreshSpinner());
+}
+
+// ---------------------------------------------------------------------------
+// Auto-refresh
+// ---------------------------------------------------------------------------
+function startAutoRefresh() {
+  stopAutoRefresh();
+  refreshTimer = setInterval(() => {
+    fetchStatus()
+      .then(() => {
+        if (currentPage === 'dashboard' || currentPage === 'services') renderPage();
+      })
+      .catch(() => {});
+  }, REFRESH_INTERVAL);
+}
+
+function stopAutoRefresh() {
+  if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
+}
+
+function showRefreshSpinner() {
+  document.getElementById('refresh-indicator').classList.remove('paused');
+}
+function hideRefreshSpinner() {
+  document.getElementById('refresh-indicator').classList.add('paused');
+}
+
+// ---------------------------------------------------------------------------
+// Breadcrumbs
+// ---------------------------------------------------------------------------
+function updateBreadcrumbs() {
+  const bc = document.getElementById('breadcrumbs');
+  let html = '';
+
+  if (currentPage === 'dashboard') {
+    if (drillLevel === 0) {
+      html = '<span class="current">Dashboard</span>';
+    } else if (drillLevel === 1) {
+      html = '<a onclick="drillBack(0)">Dashboard</a><span class="sep">/</span><span class="current">' + escapeHtml(drillProject) + '</span>';
+    } else if (drillLevel === 2) {
+      html = '<a onclick="drillBack(0)">Dashboard</a><span class="sep">/</span><a onclick="drillBack(1)">' + escapeHtml(drillProject) + '</a><span class="sep">/</span><span class="current">' + escapeHtml(drillEnv) + '</span>';
+    }
+  } else {
+    const names = { services: 'Services', backups: 'Backups', system: 'System', restore: 'Restore' };
+    html = '<span class="current">' + (names[currentPage] || currentPage) + '</span>';
+  }
+
+  bc.innerHTML = html;
+}
+
+function drillBack(level) {
+  if (level === 0) {
+    drillLevel = 0;
+    drillProject = null;
+    drillEnv = null;
+  } else if (level === 1) {
+    drillLevel = 1;
+    drillEnv = null;
+  }
+  renderDashboard();
+}
+
+// ---------------------------------------------------------------------------
+// Dashboard — 3-level Drill
+// ---------------------------------------------------------------------------
+function renderDashboard() {
+  currentPage = 'dashboard';
+  if (drillLevel === 0) renderProjects();
+  else if (drillLevel === 1) renderEnvironments();
+  else if (drillLevel === 2) renderServices();
+  updateBreadcrumbs();
+}
+
+function renderProjects() {
+  const content = document.getElementById('page-content');
+  const projects = groupByProject(allServices);
+
+  // Summary stats
+  const totalUp = allServices.filter(s => s.status === 'Up').length;
+  const totalDown = allServices.length - totalUp;
+
+  let html = '<div class="page-enter" style="padding:0;">';
+
+  // Summary bar
+  html += '<div class="stat-grid" style="margin-bottom:1.5rem;">';
+  html += statCard('Projects', Object.keys(projects).length, '#3b82f6');
+  html += statCard('Services', allServices.length, '#8b5cf6');
+  html += statCard('Healthy', totalUp, '#10b981');
+  html += statCard('Down', totalDown, totalDown > 0 ? '#ef4444' : '#6b7280');
+  html += '</div>';
+
+  // Project cards
+  html += '<div class="project-grid">';
+  for (const [name, proj] of Object.entries(projects)) {
+    const upCount = proj.services.filter(s => s.status === 'Up').length;
+    const total = proj.services.length;
+    const allUp = upCount === total;
+    const envNames = [...new Set(proj.services.map(s => s.env))];
+
+    html += `<div class="card card-clickable" onclick="drillToProject('${escapeHtml(name)}')">
+      <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
+        <span class="status-dot ${allUp ? 'status-dot-green' : 'status-dot-red'}"></span>
+        <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${escapeHtml(name)}</span>
+        <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${total} services</span>
+      </div>
+      <div style="display:flex;flex-wrap:wrap;gap:0.375rem;margin-bottom:0.5rem;">
+        ${envNames.map(e => `<span class="badge badge-blue">${escapeHtml(e)}</span>`).join('')}
+      </div>
+      <div style="font-size:0.8125rem;color:#9ca3af;">${upCount}/${total} healthy</div>
+    </div>`;
+  }
+  html += '</div></div>';
+  content.innerHTML = html;
+}
+
+function renderEnvironments() {
+  const content = document.getElementById('page-content');
+  const projServices = allServices.filter(s => s.project === drillProject);
+  const envs = groupByEnv(projServices);
+
+  let html = '<div class="page-enter" style="padding:0;">';
+  html += '<div class="env-grid">';
+
+  for (const [envName, services] of Object.entries(envs)) {
+    const upCount = services.filter(s => s.status === 'Up').length;
+    const total = services.length;
+    const allUp = upCount === total;
+
+    html += `<div class="card card-clickable" onclick="drillToEnv('${escapeHtml(envName)}')">
+      <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
+        <span class="status-dot ${allUp ? 'status-dot-green' : 'status-dot-red'}"></span>
+        <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${escapeHtml(envName).toUpperCase()}</span>
+        <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${total} services</span>
+      </div>
+      <div style="display:flex;flex-wrap:wrap;gap:0.375rem;margin-bottom:0.5rem;">
+        ${services.map(s => `<span class="badge ${badgeClass(s.status, s.health)}">${escapeHtml(s.service)}</span>`).join('')}
+      </div>
+      <div style="font-size:0.8125rem;color:#9ca3af;">${upCount}/${total} healthy</div>
+    </div>`;
+  }
+
+  html += '</div></div>';
+  content.innerHTML = html;
+}
+
+function renderServices() {
+  const content = document.getElementById('page-content');
+  const services = allServices.filter(s => s.project === drillProject && s.env === drillEnv);
+
+  let html = '<div class="page-enter" style="padding:0;">';
+  html += '<div class="service-grid">';
+
+  for (const svc of services) {
+    html += serviceCard(svc);
+  }
+
+  html += '</div></div>';
+  content.innerHTML = html;
+}
+
+function drillToProject(name) {
+  drillProject = name;
+  drillLevel = 1;
+  renderDashboard();
+}
+
+function drillToEnv(name) {
+  drillEnv = name;
+  drillLevel = 2;
+  renderDashboard();
+}
+
+// ---------------------------------------------------------------------------
+// Service Card (shared component)
+// ---------------------------------------------------------------------------
+function serviceCard(svc) {
+  const proj = escapeHtml(svc.project);
+  const env = escapeHtml(svc.env);
+  const service = escapeHtml(svc.service);
+  const bc = badgeClass(svc.status, svc.health);
+  const dc = statusDotClass(svc.status, svc.health);
+
+  return `<div class="card">
+    <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">
+      <span class="status-dot ${dc}"></span>
+      <span style="font-weight:600;color:#f3f4f6;">${service}</span>
+      <span class="badge ${bc}" style="margin-left:auto;">${escapeHtml(svc.status)}</span>
+    </div>
+    <div style="font-size:0.8125rem;color:#9ca3af;margin-bottom:0.75rem;">
+      Health: ${escapeHtml(svc.health || 'n/a')} &middot; Uptime: ${escapeHtml(svc.uptime || 'n/a')}
+    </div>
+    <div style="display:flex;gap:0.5rem;flex-wrap:wrap;">
+      <button class="btn btn-ghost btn-xs" onclick="viewLogs('${proj}','${env}','${service}')">Logs</button>
+      <button class="btn btn-warning btn-xs" onclick="restartService('${proj}','${env}','${service}')">Restart</button>
+    </div>
+  </div>`;
+}
+
+function statCard(label, value, color) {
+  return `<div class="card" style="text-align:center;">
+    <div style="font-size:1.75rem;font-weight:700;color:${color};">${value}</div>
+    <div style="font-size:0.8125rem;color:#9ca3af;">${label}</div>
+  </div>`;
+}
+
+// ---------------------------------------------------------------------------
+// Services (flat list page)
+// ---------------------------------------------------------------------------
+function renderServicesFlat() {
+  updateBreadcrumbs();
+  const content = document.getElementById('page-content');
+
+  if (allServices.length === 0) {
+    content.innerHTML = '<div style="text-align:center;padding:3rem;color:#6b7280;">No services found.</div>';
+    return;
+  }
+
+  let html = '<div class="page-enter" style="padding:0;">';
+  html += '<div class="table-wrapper"><table class="ops-table">';
+  html += '<thead><tr><th>Project</th><th>Env</th><th>Service</th><th>Status</th><th>Health</th><th>Uptime</th><th>Actions</th></tr></thead>';
+  html += '<tbody>';
+
+  for (const svc of allServices) {
+    const bc = badgeClass(svc.status, svc.health);
+    const proj = escapeHtml(svc.project);
+    const env = escapeHtml(svc.env);
+    const service = escapeHtml(svc.service);
+
+    html += `<tr>
+      <td style="font-weight:500;">${proj}</td>
+      <td><span class="badge badge-blue">${env}</span></td>
+      <td class="mono">${service}</td>
+      <td><span class="badge ${bc}">${escapeHtml(svc.status)}</span></td>
+      <td>${escapeHtml(svc.health || 'n/a')}</td>
+      <td>${escapeHtml(svc.uptime || 'n/a')}</td>
+      <td style="white-space:nowrap;">
+        <button class="btn btn-ghost btn-xs" onclick="viewLogs('${proj}','${env}','${service}')">Logs</button>
+        <button class="btn btn-warning btn-xs" onclick="restartService('${proj}','${env}','${service}')">Restart</button>
+      </td>
+    </tr>`;
+  }
+
+  html += '</tbody></table></div></div>';
+  content.innerHTML = html;
+}
+
+// ---------------------------------------------------------------------------
+// Backups Page
+// ---------------------------------------------------------------------------
+async function renderBackups() {
+  updateBreadcrumbs();
+  const content = document.getElementById('page-content');
+
+  try {
+    const [local, offsite] = await Promise.all([
+      api('/api/backups/'),
+      api('/api/backups/offsite').catch(() => []),
+    ]);
+
+    let html = '<div class="page-enter" style="padding:0;">';
+
+    // Quick backup buttons
+    html += '<div style="margin-bottom:1.5rem;">';
+    html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Create Backup</h2>';
+    html += '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">';
+    for (const proj of ['mdf', 'seriousletter']) {
+      for (const env of ['dev', 'int', 'prod']) {
+        html += `<button class="btn btn-ghost btn-sm" onclick="createBackup('${proj}','${env}')">${proj}/${env}</button>`;
+      }
+    }
+    html += '</div></div>';
+
+    // Local backups
+    html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Local Backups</h2>';
+    if (local.length === 0) {
+      html += '<div class="card" style="color:#6b7280;">No local backups found.</div>';
+    } else {
+      html += '<div class="table-wrapper"><table class="ops-table">';
+      html += '<thead><tr><th>Project</th><th>Env</th><th>Date</th><th>Size</th><th>Files</th></tr></thead><tbody>';
+      for (const b of local) {
+        html += `<tr>
+          <td>${escapeHtml(b.project || '')}</td>
+          <td><span class="badge badge-blue">${escapeHtml(b.env || b.environment || '')}</span></td>
+          <td>${escapeHtml(b.date || b.timestamp || '')}</td>
+          <td>${escapeHtml(b.size || '')}</td>
+          <td class="mono" style="font-size:0.75rem;">${escapeHtml(b.file || b.files || '')}</td>
+        </tr>`;
+      }
+      html += '</tbody></table></div>';
+    }
+
+    // Offsite backups
+    html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin:1.5rem 0 0.75rem;">Offsite Backups</h2>';
+    if (offsite.length === 0) {
+      html += '<div class="card" style="color:#6b7280;">No offsite backups found.</div>';
+    } else {
+      html += '<div class="table-wrapper"><table class="ops-table">';
+      html += '<thead><tr><th>Project</th><th>Env</th><th>Date</th><th>Size</th></tr></thead><tbody>';
+      for (const b of offsite) {
+        html += `<tr>
+          <td>${escapeHtml(b.project || '')}</td>
+          <td><span class="badge badge-blue">${escapeHtml(b.env || b.environment || '')}</span></td>
+          <td>${escapeHtml(b.date || b.timestamp || '')}</td>
+          <td>${escapeHtml(b.size || '')}</td>
+        </tr>`;
+      }
+      html += '</tbody></table></div>';
+    }
+
+    html += '</div>';
+    content.innerHTML = html;
+  } catch (e) {
+    content.innerHTML = '<div class="card" style="color:#f87171;">Failed to load backups: ' + escapeHtml(e.message) + '</div>';
+  }
+}
+
+// ---------------------------------------------------------------------------
+// System Page
+// ---------------------------------------------------------------------------
+async function renderSystem() {
+  updateBreadcrumbs();
+  const content = document.getElementById('page-content');
+
+  try {
+    const [disk, health, timers, info] = await Promise.all([
+      api('/api/system/disk').catch(e => ({ filesystems: [], raw: e.message })),
+      api('/api/system/health').catch(e => ({ checks: [], raw: e.message })),
+      api('/api/system/timers').catch(e => ({ timers: [], raw: e.message })),
+      api('/api/system/info').catch(e => ({ uptime: 'error', load: 'error' })),
+    ]);
+
+    let html = '<div class="page-enter" style="padding:0;">';
+
+    // System info bar
+    html += '<div class="stat-grid" style="margin-bottom:1.5rem;">';
+    html += statCard('Uptime', info.uptime || 'n/a', '#3b82f6');
+    html += statCard('Load', info.load || 'n/a', '#8b5cf6');
+    html += '</div>';
+
+    // Disk usage
+    html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Disk Usage</h2>';
+    if (disk.filesystems && disk.filesystems.length > 0) {
+      html += '<div style="display:grid;gap:0.75rem;margin-bottom:1.5rem;">';
+      for (const fs of disk.filesystems) {
+        const pct = parseInt(fs.use_percent) || 0;
+        html += `<div class="card">
+          <div style="display:flex;justify-content:space-between;margin-bottom:0.5rem;">
+            <span class="mono" style="font-size:0.8125rem;">${escapeHtml(fs.mount || fs.filesystem)}</span>
+            <span style="font-size:0.8125rem;color:#9ca3af;">${escapeHtml(fs.used)} / ${escapeHtml(fs.size)} (${escapeHtml(fs.use_percent)})</span>
+          </div>
+          <div class="progress-bar-track">
+            <div class="progress-bar-fill ${diskColorClass(fs.use_percent)}" style="width:${pct}%;"></div>
+          </div>
+        </div>`;
+      }
+      html += '</div>';
+    } else {
+      html += '<div class="card" style="color:#6b7280;">No disk data available.</div>';
+    }
+
+    // Health checks
+    html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Health Checks</h2>';
+    if (health.checks && health.checks.length > 0) {
+      html += '<div style="display:grid;gap:0.5rem;margin-bottom:1.5rem;">';
+      for (const c of health.checks) {
+        const st = (c.status || '').toUpperCase();
+        const cls = st === 'OK' ? 'badge-green' : st === 'FAIL' ? 'badge-red' : 'badge-gray';
+        html += `<div class="card" style="display:flex;align-items:center;gap:0.75rem;padding:0.75rem 1rem;">
+          <span class="badge ${cls}">${escapeHtml(st)}</span>
+          <span style="font-size:0.875rem;">${escapeHtml(c.check)}</span>
+        </div>`;
+      }
+      html += '</div>';
+    } else {
+      html += '<div class="card" style="color:#6b7280;">No health check data.</div>';
+    }
+
+    // Timers
+    html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Systemd Timers</h2>';
+    if (timers.timers && timers.timers.length > 0) {
+      html += '<div class="table-wrapper"><table class="ops-table">';
+      html += '<thead><tr><th>Unit</th><th>Next</th><th>Left</th><th>Last</th><th>Passed</th></tr></thead><tbody>';
+      for (const t of timers.timers) {
+        html += `<tr>
+          <td class="mono">${escapeHtml(t.unit)}</td>
+          <td>${escapeHtml(t.next)}</td>
+          <td>${escapeHtml(t.left)}</td>
+          <td>${escapeHtml(t.last)}</td>
+          <td>${escapeHtml(t.passed)}</td>
+        </tr>`;
+      }
+      html += '</tbody></table></div>';
+    } else {
+      html += '<div class="card" style="color:#6b7280;">No timers found.</div>';
+    }
+
+    html += '</div>';
+    content.innerHTML = html;
+  } catch (e) {
+    content.innerHTML = '<div class="card" style="color:#f87171;">Failed to load system info: ' + escapeHtml(e.message) + '</div>';
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Restore Page
+// ---------------------------------------------------------------------------
+function renderRestore() {
+  updateBreadcrumbs();
+  const content = document.getElementById('page-content');
+
+  let html = '<div class="page-enter" style="padding:0;">';
+  html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Restore Backup</h2>';
+  html += '<div class="card" style="max-width:480px;">';
+
+  html += '<div style="margin-bottom:1rem;">';
+  html += '<label class="form-label">Project</label>';
+  html += '<select id="restore-project" class="form-select"><option value="mdf">mdf</option><option value="seriousletter">seriousletter</option></select>';
+  html += '</div>';
+
+  html += '<div style="margin-bottom:1rem;">';
+  html += '<label class="form-label">Environment</label>';
+  html += '<select id="restore-env" class="form-select"><option value="dev">dev</option><option value="int">int</option><option value="prod">prod</option></select>';
+  html += '</div>';
+
+  html += '<div style="margin-bottom:1rem;">';
+  html += '<label class="form-label">Source</label>';
+  html += '<select id="restore-source" class="form-select"><option value="local">Local</option><option value="offsite">Offsite</option></select>';
+  html += '</div>';
+
+  html += '<div style="margin-bottom:1rem;">';
+  html += '<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;color:#9ca3af;">';
+  html += '<input type="checkbox" id="restore-dry" checked> Dry run (preview only)';
+  html += '</label>';
+  html += '</div>';
+
+  html += '<button class="btn btn-danger" onclick="startRestore()">Start Restore</button>';
+  html += '</div>';
+
+  html += '<div id="restore-output" style="display:none;margin-top:1rem;">';
+  html += '<h3 style="font-size:1rem;font-weight:600;color:#f3f4f6;margin-bottom:0.5rem;">Output</h3>';
+  html += '<div id="restore-terminal" class="terminal" style="max-height:400px;"></div>';
+  html += '</div>';
+
+  html += '</div>';
+  content.innerHTML = html;
+}
+
+async function startRestore() {
+  const project = document.getElementById('restore-project').value;
+  const env = document.getElementById('restore-env').value;
+  const source = document.getElementById('restore-source').value;
+  const dryRun = document.getElementById('restore-dry').checked;
+
+  if (!confirm(`Restore ${project}/${env} from ${source}${dryRun ? ' (dry run)' : ''}? This may overwrite data.`)) return;
+
+  const outputDiv = document.getElementById('restore-output');
+  const terminal = document.getElementById('restore-terminal');
+  outputDiv.style.display = 'block';
+  terminal.textContent = 'Starting restore...\n';
+
+  const url = `/api/restore/${project}/${env}?source=${source}&dry_run=${dryRun}&token=${encodeURIComponent(getToken())}`;
+  const evtSource = new EventSource(url);
+
+  evtSource.onmessage = function(e) {
+    const data = JSON.parse(e.data);
+    if (data.done) {
+      evtSource.close();
+      terminal.textContent += data.success ? '\n--- Restore complete ---\n' : '\n--- Restore FAILED ---\n';
+      toast(data.success ? 'Restore completed' : 'Restore failed', data.success ? 'success' : 'error');
+      return;
+    }
+    if (data.line) {
+      terminal.textContent += data.line + '\n';
+      terminal.scrollTop = terminal.scrollHeight;
+    }
+  };
+
+  evtSource.onerror = function() {
+    evtSource.close();
+    terminal.textContent += '\n--- Connection lost ---\n';
+    toast('Restore connection lost', 'error');
+  };
+}
+
+// ---------------------------------------------------------------------------
+// Service Actions
+// ---------------------------------------------------------------------------
+async function restartService(project, env, service) {
+  if (!confirm(`Restart ${service} in ${project}/${env}?`)) return;
+
+  toast('Restarting ' + service + '...', 'info');
+  try {
+    const result = await api(`/api/services/restart/${project}/${env}/${service}`, { method: 'POST' });
+    toast(result.message || 'Restarted successfully', 'success');
+    setTimeout(() => refreshCurrentPage(), 3000);
+  } catch (e) {
+    toast('Restart failed: ' + e.message, 'error');
+  }
+}
+
+async function viewLogs(project, env, service) {
+  logModalProject = project;
+  logModalEnv = env;
+  logModalService = service;
+
+  document.getElementById('log-modal-title').textContent = `Logs: ${project}/${env}/${service}`;
+  document.getElementById('log-modal-content').textContent = 'Loading...';
+  document.getElementById('log-modal').style.display = 'flex';
+
+  await refreshLogs();
+}
+
+async function refreshLogs() {
+  if (!logModalProject) return;
+  try {
+    const data = await api(`/api/services/logs/${logModalProject}/${logModalEnv}/${logModalService}?lines=200`);
+    const terminal = document.getElementById('log-modal-content');
+    terminal.textContent = data.logs || 'No logs available.';
+    terminal.scrollTop = terminal.scrollHeight;
+  } catch (e) {
+    document.getElementById('log-modal-content').textContent = 'Error loading logs: ' + e.message;
+  }
+}
+
+function closeLogModal() {
+  document.getElementById('log-modal').style.display = 'none';
+  logModalProject = null;
+  logModalEnv = null;
+  logModalService = null;
+}
+
+// ---------------------------------------------------------------------------
+// Backup Actions
+// ---------------------------------------------------------------------------
+async function createBackup(project, env) {
+  if (!confirm(`Create backup for ${project}/${env}?`)) return;
+  toast('Creating backup for ' + project + '/' + env + '...', 'info');
+  try {
+    await api(`/api/backups/${project}/${env}`, { method: 'POST' });
+    toast('Backup created for ' + project + '/' + env, 'success');
+    if (currentPage === 'backups') renderBackups();
+  } catch (e) {
+    toast('Backup failed: ' + e.message, 'error');
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Data Grouping
+// ---------------------------------------------------------------------------
+function groupByProject(services) {
+  const map = {};
+  for (const s of services) {
+    const key = s.project || 'other';
+    if (!map[key]) map[key] = { name: key, services: [] };
+    map[key].services.push(s);
+  }
+  return map;
+}
+
+function groupByEnv(services) {
+  const map = {};
+  for (const s of services) {
+    const key = s.env || 'default';
+    if (!map[key]) map[key] = [];
+    map[key].push(s);
+  }
+  return map;
+}
+
+// ---------------------------------------------------------------------------
+// Init
+// ---------------------------------------------------------------------------
+(function init() {
+  const token = getToken();
+  if (token) {
+    // Validate and load
+    fetch('/api/status/', { headers: { 'Authorization': 'Bearer ' + token } })
+      .then(r => {
+        if (!r.ok) throw new Error('Invalid token');
+        return r.json();
+      })
+      .then(data => {
+        allServices = data;
+        document.getElementById('login-overlay').style.display = 'none';
+        document.getElementById('app').style.display = 'flex';
+        showPage('dashboard');
+        startAutoRefresh();
+      })
+      .catch(() => {
+        localStorage.removeItem('ops_token');
+        document.getElementById('login-overlay').style.display = 'flex';
+      });
+  }
+
+  // ESC to close modals
+  document.addEventListener('keydown', e => {
+    if (e.key === 'Escape') {
+      closeLogModal();
+    }
+  });
+})();

--
Gitblit v1.3.1