From ed26def7d76ac011075c11e8c1679ed1f7a08abc Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sat, 21 Feb 2026 16:48:40 +0100
Subject: [PATCH] feat: Clickable stat tiles, view toggle, CPU/memory metrics, restore fix

---
 static/index.html          |   43 +
 Dockerfile                 |    6 
 static/js/app.js           |  864 ++++++++++++++++-------------------
 app/routers/services.py    |  126 ++++
 app/routers/system.py      |  191 ++++---
 app/app/routers/system.py  |  191 ++++---
 app/routers/restore.py     |    9 
 app/app/routers/restore.py |    9 
 8 files changed, 764 insertions(+), 675 deletions(-)

diff --git a/Dockerfile b/Dockerfile
index 6f3d9aa..645cb9a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -21,9 +21,9 @@
 COPY app/ ./app/
 COPY static/ ./static/
 
-
-# Ensure locale support for π in paths
-RUN apt-get update && apt-get install -y --no-install-recommends locales && \
+# Locale support for paths with special chars + libexpat for host venv compatibility
+RUN apt-get update && \
+    apt-get install -y --no-install-recommends locales libexpat1 && \
     echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen && \
     rm -rf /var/lib/apt/lists/*
 ENV LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
diff --git a/app/app/routers/restore.py b/app/app/routers/restore.py
index 5c0cda6..b487952 100644
--- a/app/app/routers/restore.py
+++ b/app/app/routers/restore.py
@@ -22,15 +22,14 @@
     source: str,
     dry_run: bool,
 ) -> AsyncGenerator[str, None]:
-    """
-    Async generator that drives the restore workflow and yields SSE events.
-    """
+    """Async generator that drives the restore workflow and yields SSE events."""
     base_args = ["restore", project, env]
     if dry_run:
         base_args.append("--dry-run")
 
     if source == "offsite":
-        download_args = ["offsite", "download", project, env]
+        # ops offsite restore <project> <env> — downloads from offsite storage
+        download_args = ["offsite", "restore", project, env]
         yield _sse_line({"line": f"Downloading {project}/{env} from offsite...", "timestamp": _now()})
 
         download_ok = True
@@ -69,7 +68,7 @@
     """
     Restore a backup for the given project/env.
 
-    Uses Server-Sent Events (SSE) to stream real-time progress to the client.
+    Uses Server-Sent Events (SSE) to stream real-time progress.
     Parameters are passed as query strings since EventSource only supports GET.
     """
     return StreamingResponse(
diff --git a/app/app/routers/system.py b/app/app/routers/system.py
index 0dcf545..a9f15ce 100644
--- a/app/app/routers/system.py
+++ b/app/app/routers/system.py
@@ -1,3 +1,5 @@
+import asyncio
+import os
 import re
 from typing import Any
 
@@ -14,98 +16,116 @@
 # ---------------------------------------------------------------------------
 
 def _parse_disk_output(raw: str) -> list[dict[str, str]]:
-    """
-    Parse df-style output into a list of filesystem dicts.
-    Handles both the standard df header and custom ops output.
-    """
+    """Parse df-style output into a list of filesystem dicts."""
     filesystems: list[dict[str, str]] = []
     lines = raw.strip().splitlines()
-
     if not lines:
         return filesystems
 
-    # Skip header line if present
     data_lines = lines[1:] if re.match(r"(?i)filesystem", lines[0]) else lines
 
     for line in data_lines:
         parts = line.split()
         if len(parts) >= 5:
-            filesystems.append(
-                {
-                    "filesystem": parts[0],
-                    "size": parts[1],
-                    "used": parts[2],
-                    "available": parts[3],
-                    "use_percent": parts[4],
-                    "mount": parts[5] if len(parts) > 5 else "",
-                }
-            )
-
+            filesystems.append({
+                "filesystem": parts[0],
+                "size": parts[1],
+                "used": parts[2],
+                "available": parts[3],
+                "use_percent": parts[4],
+                "mount": parts[5] if len(parts) > 5 else "",
+            })
     return filesystems
 
 
 def _parse_health_output(raw: str) -> list[dict[str, str]]:
-    """
-    Parse health check output into a list of check result dicts.
-    Expects lines like: "[OK] Database connection" or "[FAIL] Disk space"
-    Also handles plain text lines.
-    """
+    """Parse health check output into check result dicts."""
     checks: list[dict[str, str]] = []
     for line in raw.strip().splitlines():
         line = line.strip()
         if not line:
             continue
-
         match = re.match(r"^\[(\w+)\]\s*(.+)$", line)
         if match:
             checks.append({"status": match.group(1), "check": match.group(2)})
         else:
-            # Treat unstructured lines as informational
             checks.append({"status": "INFO", "check": line})
-
     return checks
 
 
 def _parse_timers_output(raw: str) -> list[dict[str, str]]:
-    """
-    Parse `systemctl list-timers` output into structured timer dicts.
-    Header: NEXT LEFT LAST PASSED UNIT ACTIVATES
-    """
+    """Parse `systemctl list-timers` output into timer dicts."""
     timers: list[dict[str, str]] = []
     lines = raw.strip().splitlines()
-
     if not lines:
         return timers
 
-    # Skip header
     header_idx = 0
     for i, line in enumerate(lines):
         if re.match(r"(?i)next\s+left", line):
             header_idx = i
             break
 
-    for line in lines[header_idx + 1 :]:
+    for line in lines[header_idx + 1:]:
         line = line.strip()
         if not line or line.startswith("timers listed") or line.startswith("To show"):
             continue
-
-        # systemctl list-timers columns are variable-width; split carefully
         parts = re.split(r"\s{2,}", line)
         if len(parts) >= 5:
-            timers.append(
-                {
-                    "next": parts[0],
-                    "left": parts[1],
-                    "last": parts[2],
-                    "passed": parts[3],
-                    "unit": parts[4],
-                    "activates": parts[5] if len(parts) > 5 else "",
-                }
-            )
+            timers.append({
+                "next": parts[0], "left": parts[1], "last": parts[2],
+                "passed": parts[3], "unit": parts[4],
+                "activates": parts[5] if len(parts) > 5 else "",
+            })
         elif parts:
             timers.append({"unit": parts[0], "next": "", "left": "", "last": "", "passed": "", "activates": ""})
-
     return timers
+
+
+def _read_memory() -> dict[str, Any]:
+    """Read memory and swap from /proc/meminfo."""
+    info: dict[str, int] = {}
+    try:
+        with open("/proc/meminfo") as f:
+            for line in f:
+                parts = line.split()
+                if len(parts) >= 2:
+                    key = parts[0].rstrip(":")
+                    info[key] = int(parts[1]) * 1024  # kB → bytes
+    except Exception:
+        return {}
+
+    mem_total = info.get("MemTotal", 0)
+    mem_available = info.get("MemAvailable", 0)
+    mem_used = mem_total - mem_available
+    swap_total = info.get("SwapTotal", 0)
+    swap_free = info.get("SwapFree", 0)
+    swap_used = swap_total - swap_free
+
+    return {
+        "memory": {
+            "total": mem_total,
+            "used": mem_used,
+            "available": mem_available,
+            "percent": round(mem_used / mem_total * 100, 1) if mem_total else 0,
+        },
+        "swap": {
+            "total": swap_total,
+            "used": swap_used,
+            "free": swap_free,
+            "percent": round(swap_used / swap_total * 100, 1) if swap_total else 0,
+        },
+    }
+
+
+def _read_cpu_stat() -> tuple[int, int]:
+    """Read idle and total jiffies from /proc/stat."""
+    with open("/proc/stat") as f:
+        line = f.readline()
+    parts = line.split()
+    values = [int(x) for x in parts[1:]]
+    idle = values[3] + (values[4] if len(values) > 4 else 0)  # idle + iowait
+    return idle, sum(values)
 
 
 # ---------------------------------------------------------------------------
@@ -116,21 +136,15 @@
 async def disk_usage(
     _: str = Depends(verify_token),
 ) -> dict[str, Any]:
-    """
-    Returns disk usage via `ops disk` (or falls back to `df -h`).
-    """
+    """Returns disk usage via `ops disk` (fallback: `df -h`)."""
     result = await run_ops(["disk"])
     raw = result["output"]
 
     if not result["success"] or not raw.strip():
-        # Fallback to df
         fallback = await run_command(["df", "-h"])
         raw = fallback["output"]
         if not fallback["success"]:
-            raise HTTPException(
-                status_code=500,
-                detail=f"Failed to get disk usage: {result['error']}",
-            )
+            raise HTTPException(status_code=500, detail=f"Failed to get disk usage: {result['error']}")
 
     return {
         "filesystems": _parse_disk_output(raw),
@@ -142,20 +156,13 @@
 async def health_check(
     _: str = Depends(verify_token),
 ) -> dict[str, Any]:
-    """
-    Returns health check results via `ops health`.
-    """
+    """Returns health check results via `ops health`."""
     result = await run_ops(["health"])
     if not result["success"] and not result["output"].strip():
-        raise HTTPException(
-            status_code=500,
-            detail=f"Failed to run health checks: {result['error']}",
-        )
-
-    raw = result["output"]
+        raise HTTPException(status_code=500, detail=f"Failed to run health checks: {result['error']}")
     return {
-        "checks": _parse_health_output(raw),
-        "raw": raw,
+        "checks": _parse_health_output(result["output"]),
+        "raw": result["output"],
     }
 
 
@@ -163,35 +170,30 @@
 async def list_timers(
     _: str = Depends(verify_token),
 ) -> dict[str, Any]:
-    """
-    Lists systemd timers via `systemctl list-timers --no-pager`.
-    """
+    """Lists systemd timers."""
     result = await run_command(["systemctl", "list-timers", "--no-pager"])
     if not result["success"] and not result["output"].strip():
-        raise HTTPException(
-            status_code=500,
-            detail=f"Failed to list timers: {result['error']}",
-        )
-
-    raw = result["output"]
+        raise HTTPException(status_code=500, detail=f"Failed to list timers: {result['error']}")
     return {
-        "timers": _parse_timers_output(raw),
-        "raw": raw,
+        "timers": _parse_timers_output(result["output"]),
+        "raw": result["output"],
     }
 
 
-@router.get("/info", summary="Basic system information")
+@router.get("/info", summary="System information with CPU/memory")
 async def system_info(
     _: str = Depends(verify_token),
 ) -> dict[str, Any]:
     """
-    Returns system uptime and load average.
-    Reads from /proc/uptime and /proc/loadavg; falls back to `uptime` command.
+    Returns system uptime, load average, CPU usage, memory, and swap.
+
+    CPU usage is measured over a 0.5s window from /proc/stat.
+    Memory/swap are read from /proc/meminfo.
     """
     uptime_str = ""
     load_str = ""
 
-    # Try /proc first (Linux)
+    # Uptime
     try:
         with open("/proc/uptime") as f:
             seconds_up = float(f.read().split()[0])
@@ -199,22 +201,43 @@
             hours = int((seconds_up % 86400) // 3600)
             minutes = int((seconds_up % 3600) // 60)
             uptime_str = f"{days}d {hours}h {minutes}m"
-    except (FileNotFoundError, ValueError):
+    except Exception:
         pass
 
+    # Load average
     try:
         with open("/proc/loadavg") as f:
             parts = f.read().split()
             load_str = f"{parts[0]}, {parts[1]}, {parts[2]}"
-    except (FileNotFoundError, ValueError):
+    except Exception:
         pass
 
-    # Fallback: use `uptime` command (works on macOS too)
+    # CPU usage (two samples, 0.5s apart)
+    cpu_info: dict[str, Any] = {}
+    try:
+        idle1, total1 = _read_cpu_stat()
+        await asyncio.sleep(0.5)
+        idle2, total2 = _read_cpu_stat()
+        total_delta = total2 - total1
+        if total_delta > 0:
+            usage = round((1 - (idle2 - idle1) / total_delta) * 100, 1)
+        else:
+            usage = 0.0
+        cpu_info = {
+            "usage_percent": usage,
+            "cores": os.cpu_count() or 1,
+        }
+    except Exception:
+        pass
+
+    # Memory + Swap
+    mem_info = _read_memory()
+
+    # Fallback for uptime/load if /proc wasn't available
     if not uptime_str or not load_str:
         result = await run_command(["uptime"])
         if result["success"]:
             raw = result["output"].strip()
-            # Parse "up X days, Y:Z,  N users,  load average: a, b, c"
             up_match = re.search(r"up\s+(.+?),\s+\d+\s+user", raw)
             if up_match:
                 uptime_str = uptime_str or up_match.group(1).strip()
@@ -225,4 +248,6 @@
     return {
         "uptime": uptime_str or "unavailable",
         "load": load_str or "unavailable",
+        "cpu": cpu_info or None,
+        **mem_info,
     }
diff --git a/app/routers/restore.py b/app/routers/restore.py
index 5c0cda6..b487952 100644
--- a/app/routers/restore.py
+++ b/app/routers/restore.py
@@ -22,15 +22,14 @@
     source: str,
     dry_run: bool,
 ) -> AsyncGenerator[str, None]:
-    """
-    Async generator that drives the restore workflow and yields SSE events.
-    """
+    """Async generator that drives the restore workflow and yields SSE events."""
     base_args = ["restore", project, env]
     if dry_run:
         base_args.append("--dry-run")
 
     if source == "offsite":
-        download_args = ["offsite", "download", project, env]
+        # ops offsite restore <project> <env> — downloads from offsite storage
+        download_args = ["offsite", "restore", project, env]
         yield _sse_line({"line": f"Downloading {project}/{env} from offsite...", "timestamp": _now()})
 
         download_ok = True
@@ -69,7 +68,7 @@
     """
     Restore a backup for the given project/env.
 
-    Uses Server-Sent Events (SSE) to stream real-time progress to the client.
+    Uses Server-Sent Events (SSE) to stream real-time progress.
     Parameters are passed as query strings since EventSource only supports GET.
     """
     return StreamingResponse(
diff --git a/app/routers/services.py b/app/routers/services.py
index a23bade..7cdad19 100644
--- a/app/routers/services.py
+++ b/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/app/routers/system.py b/app/routers/system.py
index 0dcf545..a9f15ce 100644
--- a/app/routers/system.py
+++ b/app/routers/system.py
@@ -1,3 +1,5 @@
+import asyncio
+import os
 import re
 from typing import Any
 
@@ -14,98 +16,116 @@
 # ---------------------------------------------------------------------------
 
 def _parse_disk_output(raw: str) -> list[dict[str, str]]:
-    """
-    Parse df-style output into a list of filesystem dicts.
-    Handles both the standard df header and custom ops output.
-    """
+    """Parse df-style output into a list of filesystem dicts."""
     filesystems: list[dict[str, str]] = []
     lines = raw.strip().splitlines()
-
     if not lines:
         return filesystems
 
-    # Skip header line if present
     data_lines = lines[1:] if re.match(r"(?i)filesystem", lines[0]) else lines
 
     for line in data_lines:
         parts = line.split()
         if len(parts) >= 5:
-            filesystems.append(
-                {
-                    "filesystem": parts[0],
-                    "size": parts[1],
-                    "used": parts[2],
-                    "available": parts[3],
-                    "use_percent": parts[4],
-                    "mount": parts[5] if len(parts) > 5 else "",
-                }
-            )
-
+            filesystems.append({
+                "filesystem": parts[0],
+                "size": parts[1],
+                "used": parts[2],
+                "available": parts[3],
+                "use_percent": parts[4],
+                "mount": parts[5] if len(parts) > 5 else "",
+            })
     return filesystems
 
 
 def _parse_health_output(raw: str) -> list[dict[str, str]]:
-    """
-    Parse health check output into a list of check result dicts.
-    Expects lines like: "[OK] Database connection" or "[FAIL] Disk space"
-    Also handles plain text lines.
-    """
+    """Parse health check output into check result dicts."""
     checks: list[dict[str, str]] = []
     for line in raw.strip().splitlines():
         line = line.strip()
         if not line:
             continue
-
         match = re.match(r"^\[(\w+)\]\s*(.+)$", line)
         if match:
             checks.append({"status": match.group(1), "check": match.group(2)})
         else:
-            # Treat unstructured lines as informational
             checks.append({"status": "INFO", "check": line})
-
     return checks
 
 
 def _parse_timers_output(raw: str) -> list[dict[str, str]]:
-    """
-    Parse `systemctl list-timers` output into structured timer dicts.
-    Header: NEXT LEFT LAST PASSED UNIT ACTIVATES
-    """
+    """Parse `systemctl list-timers` output into timer dicts."""
     timers: list[dict[str, str]] = []
     lines = raw.strip().splitlines()
-
     if not lines:
         return timers
 
-    # Skip header
     header_idx = 0
     for i, line in enumerate(lines):
         if re.match(r"(?i)next\s+left", line):
             header_idx = i
             break
 
-    for line in lines[header_idx + 1 :]:
+    for line in lines[header_idx + 1:]:
         line = line.strip()
         if not line or line.startswith("timers listed") or line.startswith("To show"):
             continue
-
-        # systemctl list-timers columns are variable-width; split carefully
         parts = re.split(r"\s{2,}", line)
         if len(parts) >= 5:
-            timers.append(
-                {
-                    "next": parts[0],
-                    "left": parts[1],
-                    "last": parts[2],
-                    "passed": parts[3],
-                    "unit": parts[4],
-                    "activates": parts[5] if len(parts) > 5 else "",
-                }
-            )
+            timers.append({
+                "next": parts[0], "left": parts[1], "last": parts[2],
+                "passed": parts[3], "unit": parts[4],
+                "activates": parts[5] if len(parts) > 5 else "",
+            })
         elif parts:
             timers.append({"unit": parts[0], "next": "", "left": "", "last": "", "passed": "", "activates": ""})
-
     return timers
+
+
+def _read_memory() -> dict[str, Any]:
+    """Read memory and swap from /proc/meminfo."""
+    info: dict[str, int] = {}
+    try:
+        with open("/proc/meminfo") as f:
+            for line in f:
+                parts = line.split()
+                if len(parts) >= 2:
+                    key = parts[0].rstrip(":")
+                    info[key] = int(parts[1]) * 1024  # kB → bytes
+    except Exception:
+        return {}
+
+    mem_total = info.get("MemTotal", 0)
+    mem_available = info.get("MemAvailable", 0)
+    mem_used = mem_total - mem_available
+    swap_total = info.get("SwapTotal", 0)
+    swap_free = info.get("SwapFree", 0)
+    swap_used = swap_total - swap_free
+
+    return {
+        "memory": {
+            "total": mem_total,
+            "used": mem_used,
+            "available": mem_available,
+            "percent": round(mem_used / mem_total * 100, 1) if mem_total else 0,
+        },
+        "swap": {
+            "total": swap_total,
+            "used": swap_used,
+            "free": swap_free,
+            "percent": round(swap_used / swap_total * 100, 1) if swap_total else 0,
+        },
+    }
+
+
+def _read_cpu_stat() -> tuple[int, int]:
+    """Read idle and total jiffies from /proc/stat."""
+    with open("/proc/stat") as f:
+        line = f.readline()
+    parts = line.split()
+    values = [int(x) for x in parts[1:]]
+    idle = values[3] + (values[4] if len(values) > 4 else 0)  # idle + iowait
+    return idle, sum(values)
 
 
 # ---------------------------------------------------------------------------
@@ -116,21 +136,15 @@
 async def disk_usage(
     _: str = Depends(verify_token),
 ) -> dict[str, Any]:
-    """
-    Returns disk usage via `ops disk` (or falls back to `df -h`).
-    """
+    """Returns disk usage via `ops disk` (fallback: `df -h`)."""
     result = await run_ops(["disk"])
     raw = result["output"]
 
     if not result["success"] or not raw.strip():
-        # Fallback to df
         fallback = await run_command(["df", "-h"])
         raw = fallback["output"]
         if not fallback["success"]:
-            raise HTTPException(
-                status_code=500,
-                detail=f"Failed to get disk usage: {result['error']}",
-            )
+            raise HTTPException(status_code=500, detail=f"Failed to get disk usage: {result['error']}")
 
     return {
         "filesystems": _parse_disk_output(raw),
@@ -142,20 +156,13 @@
 async def health_check(
     _: str = Depends(verify_token),
 ) -> dict[str, Any]:
-    """
-    Returns health check results via `ops health`.
-    """
+    """Returns health check results via `ops health`."""
     result = await run_ops(["health"])
     if not result["success"] and not result["output"].strip():
-        raise HTTPException(
-            status_code=500,
-            detail=f"Failed to run health checks: {result['error']}",
-        )
-
-    raw = result["output"]
+        raise HTTPException(status_code=500, detail=f"Failed to run health checks: {result['error']}")
     return {
-        "checks": _parse_health_output(raw),
-        "raw": raw,
+        "checks": _parse_health_output(result["output"]),
+        "raw": result["output"],
     }
 
 
@@ -163,35 +170,30 @@
 async def list_timers(
     _: str = Depends(verify_token),
 ) -> dict[str, Any]:
-    """
-    Lists systemd timers via `systemctl list-timers --no-pager`.
-    """
+    """Lists systemd timers."""
     result = await run_command(["systemctl", "list-timers", "--no-pager"])
     if not result["success"] and not result["output"].strip():
-        raise HTTPException(
-            status_code=500,
-            detail=f"Failed to list timers: {result['error']}",
-        )
-
-    raw = result["output"]
+        raise HTTPException(status_code=500, detail=f"Failed to list timers: {result['error']}")
     return {
-        "timers": _parse_timers_output(raw),
-        "raw": raw,
+        "timers": _parse_timers_output(result["output"]),
+        "raw": result["output"],
     }
 
 
-@router.get("/info", summary="Basic system information")
+@router.get("/info", summary="System information with CPU/memory")
 async def system_info(
     _: str = Depends(verify_token),
 ) -> dict[str, Any]:
     """
-    Returns system uptime and load average.
-    Reads from /proc/uptime and /proc/loadavg; falls back to `uptime` command.
+    Returns system uptime, load average, CPU usage, memory, and swap.
+
+    CPU usage is measured over a 0.5s window from /proc/stat.
+    Memory/swap are read from /proc/meminfo.
     """
     uptime_str = ""
     load_str = ""
 
-    # Try /proc first (Linux)
+    # Uptime
     try:
         with open("/proc/uptime") as f:
             seconds_up = float(f.read().split()[0])
@@ -199,22 +201,43 @@
             hours = int((seconds_up % 86400) // 3600)
             minutes = int((seconds_up % 3600) // 60)
             uptime_str = f"{days}d {hours}h {minutes}m"
-    except (FileNotFoundError, ValueError):
+    except Exception:
         pass
 
+    # Load average
     try:
         with open("/proc/loadavg") as f:
             parts = f.read().split()
             load_str = f"{parts[0]}, {parts[1]}, {parts[2]}"
-    except (FileNotFoundError, ValueError):
+    except Exception:
         pass
 
-    # Fallback: use `uptime` command (works on macOS too)
+    # CPU usage (two samples, 0.5s apart)
+    cpu_info: dict[str, Any] = {}
+    try:
+        idle1, total1 = _read_cpu_stat()
+        await asyncio.sleep(0.5)
+        idle2, total2 = _read_cpu_stat()
+        total_delta = total2 - total1
+        if total_delta > 0:
+            usage = round((1 - (idle2 - idle1) / total_delta) * 100, 1)
+        else:
+            usage = 0.0
+        cpu_info = {
+            "usage_percent": usage,
+            "cores": os.cpu_count() or 1,
+        }
+    except Exception:
+        pass
+
+    # Memory + Swap
+    mem_info = _read_memory()
+
+    # Fallback for uptime/load if /proc wasn't available
     if not uptime_str or not load_str:
         result = await run_command(["uptime"])
         if result["success"]:
             raw = result["output"].strip()
-            # Parse "up X days, Y:Z,  N users,  load average: a, b, c"
             up_match = re.search(r"up\s+(.+?),\s+\d+\s+user", raw)
             if up_match:
                 uptime_str = uptime_str or up_match.group(1).strip()
@@ -225,4 +248,6 @@
     return {
         "uptime": uptime_str or "unavailable",
         "load": load_str or "unavailable",
+        "cpu": cpu_info or None,
+        **mem_info,
     }
diff --git a/static/index.html b/static/index.html
index d39e965..a043346 100644
--- a/static/index.html
+++ b/static/index.html
@@ -13,7 +13,7 @@
     #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 { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; color: #9ca3af; flex-wrap: wrap; }
     .breadcrumb a { color: #60a5fa; cursor: pointer; text-decoration: none; }
     .breadcrumb a:hover { text-decoration: underline; }
     .breadcrumb .sep { color: #4b5563; }
@@ -22,19 +22,28 @@
     .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; }
+    .grid-auto { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
+    .grid-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.75rem; }
+    .grid-metrics { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; }
     .card-clickable { cursor: pointer; transition: border-color 0.2s, transform 0.15s; }
     .card-clickable:hover { border-color: #60a5fa; transform: translateY(-1px); }
+    .stat-tile { cursor: pointer; transition: border-color 0.2s, transform 0.1s; }
+    .stat-tile:hover { border-color: #60a5fa; transform: translateY(-2px); }
+    .filter-badge { display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.25rem 0.625rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; background: rgba(59,130,246,0.15); color: #60a5fa; border: 1px solid rgba(59,130,246,0.3); }
+    .filter-badge button { background: none; border: none; color: #60a5fa; cursor: pointer; font-size: 0.875rem; padding: 0; line-height: 1; }
+    .filter-badge button:hover { color: #f87171; }
+    .view-toggle { display: flex; background: #1f2937; border-radius: 0.375rem; overflow: hidden; border: 1px solid #374151; }
+    .view-toggle button { background: none; border: none; color: #6b7280; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; display: flex; align-items: center; gap: 0.25rem; }
+    .view-toggle button.active { background: rgba(59,130,246,0.2); color: #60a5fa; }
+    .view-toggle button:hover:not(.active) { color: #d1d5db; }
     .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; }
+      .grid-auto { grid-template-columns: 1fr; }
+      .grid-stats { grid-template-columns: repeat(2, 1fr); }
     }
   </style>
 </head>
@@ -53,7 +62,6 @@
 
 <!-- App Shell -->
 <div id="app" style="display:none;">
-  <!-- Mobile overlay -->
   <div id="mobile-overlay" class="mobile-overlay" onclick="toggleSidebar()"></div>
 
   <!-- Sidebar -->
@@ -66,10 +74,6 @@
       <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
-      </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
       </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>
@@ -91,17 +95,26 @@
 
   <!-- 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 id="view-toggle-wrap" style="display:none;">
+        <div class="view-toggle">
+          <button id="btn-view-cards" class="active" onclick="setViewMode('cards')" title="Card view">
+            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
+            Cards
+          </button>
+          <button id="btn-view-table" onclick="setViewMode('table')" title="Table view">
+            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
+            Table
+          </button>
+        </div>
+      </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>
     </div>
-
-    <!-- Page content -->
     <div id="page-content"></div>
   </div>
 </div>
@@ -121,7 +134,7 @@
     </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>
+      <button class="btn btn-primary btn-sm" onclick="refreshLogs()">Refresh</button>
     </div>
   </div>
 </div>
diff --git a/static/js/app.js b/static/js/app.js
index 41fd842..0415eb7 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -1,7 +1,7 @@
 'use strict';
 
 // ============================================================
-// OPS Dashboard — Vanilla JS Application
+// OPS Dashboard — Vanilla JS Application (v3)
 // ============================================================
 
 // ---------------------------------------------------------------------------
@@ -9,99 +9,95 @@
 // ---------------------------------------------------------------------------
 let allServices = [];
 let currentPage = 'dashboard';
-let drillLevel = 0;        // 0=projects, 1=environments, 2=services
+let viewMode = 'cards';           // 'cards' | 'table'
+let tableFilter = null;           // null | 'healthy' | 'down' | 'project:name' | 'env:name'
+let tableFilterLabel = '';
+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;
+let logCtx = { project: null, env: null, service: null };
 
 // ---------------------------------------------------------------------------
 // Helpers
 // ---------------------------------------------------------------------------
-function formatBytes(bytes) {
-  if (bytes == null || bytes === '') return '\u2014';
-  const n = Number(bytes);
+function fmtBytes(b) {
+  if (b == null) return '\u2014';
+  const n = Number(b);
   if (isNaN(n) || n === 0) return '0 B';
-  const k = 1024;
-  const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+  const k = 1024, s = ['B', 'KB', 'MB', 'GB', 'TB'];
   const i = Math.floor(Math.log(Math.abs(n)) / Math.log(k));
-  return (n / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1) + ' ' + sizes[i];
+  return (n / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1) + ' ' + s[i];
 }
 
-function timeAgo(dateInput) {
-  if (!dateInput) return '\u2014';
-  const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
-  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';
-  return Math.floor(secs / 86400) + 'd ago';
+function esc(str) {
+  const d = document.createElement('div');
+  d.textContent = str;
+  return d.innerHTML;
 }
 
-function escapeHtml(str) {
-  const div = document.createElement('div');
-  div.textContent = str;
-  return div.innerHTML;
-}
-
-function statusDotClass(status, health) {
-  const s = (status || '').toLowerCase();
-  const h = (health || '').toLowerCase();
-  if (s === 'up' && (h === 'healthy' || h === '')) return 'status-dot-green';
+function dotClass(status, health) {
+  const s = (status || '').toLowerCase(), 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 badgeClass(status, health) {
-  const s = (status || '').toLowerCase();
-  const h = (health || '').toLowerCase();
-  if (s === 'up' && (h === 'healthy' || h === '')) return 'badge-green';
+function badgeCls(status, health) {
+  const s = (status || '').toLowerCase(), 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) {
+function diskColor(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';
 }
 
+function isHealthy(svc) {
+  return svc.status === 'Up' && (svc.health === 'healthy' || !svc.health);
+}
+
+function isDown(svc) { return !isHealthy(svc); }
+
+function filterServices(list) {
+  if (!tableFilter) return list;
+  if (tableFilter === 'healthy') return list.filter(isHealthy);
+  if (tableFilter === 'down') return list.filter(isDown);
+  if (tableFilter.startsWith('project:')) {
+    const p = tableFilter.slice(8);
+    return list.filter(s => s.project === p);
+  }
+  if (tableFilter.startsWith('env:')) {
+    const e = tableFilter.slice(4);
+    return list.filter(s => s.env === e);
+  }
+  return list;
+}
+
 // ---------------------------------------------------------------------------
 // Auth
 // ---------------------------------------------------------------------------
-function getToken() {
-  return localStorage.getItem('ops_token');
-}
+function getToken() { return localStorage.getItem('ops_token'); }
 
 function doLogin() {
   const input = document.getElementById('login-token');
-  const errEl = document.getElementById('login-error');
+  const err = 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
+  if (!token) { err.textContent = 'Please enter a token'; err.style.display = 'block'; return; }
+  err.style.display = 'none';
   fetch('/api/status/', { headers: { 'Authorization': 'Bearer ' + token } })
-    .then(r => {
-      if (!r.ok) throw new Error('Invalid token');
-      return r.json();
-    })
+    .then(r => { if (!r.ok) throw new Error(); return r.json(); })
     .then(data => {
       localStorage.setItem('ops_token', token);
       allServices = data;
@@ -110,10 +106,7 @@
       showPage('dashboard');
       startAutoRefresh();
     })
-    .catch(() => {
-      errEl.textContent = 'Invalid token. Try again.';
-      errEl.style.display = 'block';
-    });
+    .catch(() => { err.textContent = 'Invalid token.'; err.style.display = 'block'; });
 }
 
 function doLogout() {
@@ -125,46 +118,34 @@
 }
 
 // ---------------------------------------------------------------------------
-// API Helper
+// API
 // ---------------------------------------------------------------------------
 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');
-  }
-  if (!resp.ok) {
-    const body = await resp.text();
-    throw new Error(body || 'HTTP ' + resp.status);
-  }
+  if (resp.status === 401) { doLogout(); throw new Error('Session expired'); }
+  if (!resp.ok) { const b = await resp.text(); throw new Error(b || 'HTTP ' + resp.status); }
   const ct = resp.headers.get('content-type') || '';
-  if (ct.includes('json')) return resp.json();
-  return resp.text();
+  return ct.includes('json') ? resp.json() : resp.text();
 }
 
-async function fetchStatus() {
-  allServices = await api('/api/status/');
-}
+async function fetchStatus() { allServices = await api('/api/status/'); }
 
 // ---------------------------------------------------------------------------
-// Toast Notifications
+// Toast
 // ---------------------------------------------------------------------------
-function toast(message, type = 'info') {
-  const container = document.getElementById('toast-container');
+function toast(msg, type = 'info') {
+  const c = 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);
+  el.innerHTML = `<span>${esc(msg)}</span><span class="toast-dismiss" onclick="this.parentElement.remove()">&times;</span>`;
+  c.appendChild(el);
+  setTimeout(() => { el.classList.add('toast-out'); setTimeout(() => el.remove(), 200); }, 4000);
 }
 
 // ---------------------------------------------------------------------------
-// Sidebar & Navigation
+// Navigation
 // ---------------------------------------------------------------------------
 function toggleSidebar() {
   document.getElementById('sidebar').classList.toggle('open');
@@ -173,16 +154,11 @@
 
 function showPage(page) {
   currentPage = page;
-  drillLevel = 0;
-  drillProject = null;
-  drillEnv = null;
+  drillLevel = 0; drillProject = null; drillEnv = null;
+  if (page !== 'dashboard') { viewMode = 'cards'; tableFilter = null; tableFilterLabel = ''; }
 
-  // Update sidebar active
-  document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el => {
-    el.classList.toggle('active', el.dataset.page === page);
-  });
-
-  // Close mobile sidebar
+  document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el =>
+    el.classList.toggle('active', el.dataset.page === page));
   document.getElementById('sidebar').classList.remove('open');
   document.getElementById('mobile-overlay').classList.remove('open');
 
@@ -190,12 +166,12 @@
 }
 
 function renderPage() {
-  const content = document.getElementById('page-content');
-  content.innerHTML = '<div style="text-align:center;padding:3rem;"><div class="spinner spinner-lg"></div></div>';
+  const c = document.getElementById('page-content');
+  c.innerHTML = '<div style="text-align:center;padding:3rem;"><div class="spinner spinner-lg"></div></div>';
+  updateViewToggle();
 
   switch (currentPage) {
     case 'dashboard': renderDashboard(); break;
-    case 'services':  renderServicesFlat(); break;
     case 'backups':   renderBackups(); break;
     case 'system':    renderSystem(); break;
     case 'restore':   renderRestore(); break;
@@ -204,11 +180,44 @@
 }
 
 function refreshCurrentPage() {
-  showRefreshSpinner();
-  fetchStatus()
-    .then(() => renderPage())
-    .catch(e => toast('Refresh failed: ' + e.message, 'error'))
-    .finally(() => hideRefreshSpinner());
+  showSpin();
+  fetchStatus().then(() => renderPage()).catch(e => toast('Refresh failed: ' + e.message, 'error')).finally(hideSpin);
+}
+
+// ---------------------------------------------------------------------------
+// View Mode & Filters
+// ---------------------------------------------------------------------------
+function setViewMode(mode) {
+  viewMode = mode;
+  if (mode === 'cards') { tableFilter = null; tableFilterLabel = ''; }
+  updateViewToggle();
+  renderDashboard();
+}
+
+function setTableFilter(filter, label) {
+  tableFilter = filter;
+  tableFilterLabel = label || filter;
+  viewMode = 'table';
+  updateViewToggle();
+  renderDashboard();
+}
+
+function clearFilter() {
+  tableFilter = null; tableFilterLabel = '';
+  renderDashboard();
+}
+
+function updateViewToggle() {
+  const wrap = document.getElementById('view-toggle-wrap');
+  const btnCards = document.getElementById('btn-view-cards');
+  const btnTable = document.getElementById('btn-view-table');
+  if (currentPage === 'dashboard') {
+    wrap.style.display = '';
+    btnCards.classList.toggle('active', viewMode === 'cards');
+    btnTable.classList.toggle('active', viewMode === 'table');
+  } else {
+    wrap.style.display = 'none';
+  }
 }
 
 // ---------------------------------------------------------------------------
@@ -217,321 +226,298 @@
 function startAutoRefresh() {
   stopAutoRefresh();
   refreshTimer = setInterval(() => {
-    fetchStatus()
-      .then(() => {
-        if (currentPage === 'dashboard' || currentPage === 'services') renderPage();
-      })
-      .catch(() => {});
+    fetchStatus().then(() => { if (currentPage === 'dashboard') 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');
-}
+function stopAutoRefresh() { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } }
+function showSpin() { document.getElementById('refresh-indicator').classList.remove('paused'); }
+function hideSpin() { document.getElementById('refresh-indicator').classList.add('paused'); }
 
 // ---------------------------------------------------------------------------
 // Breadcrumbs
 // ---------------------------------------------------------------------------
 function updateBreadcrumbs() {
   const bc = document.getElementById('breadcrumbs');
-  let html = '';
+  let h = '';
 
   if (currentPage === 'dashboard') {
-    if (drillLevel === 0) {
-      html = '<span class="current">Dashboard</span>';
+    if (viewMode === 'table') {
+      h = '<a onclick="setViewMode(\'cards\')">Dashboard</a><span class="sep">/</span>';
+      h += '<span class="current">All Services</span>';
+      if (tableFilter) {
+        h += '&nbsp;<span class="filter-badge">' + esc(tableFilterLabel) +
+             ' <button onclick="clearFilter()">&times;</button></span>';
+      }
+    } else if (drillLevel === 0) {
+      h = '<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>';
+      h = '<a onclick="drillBack(0)">Dashboard</a><span class="sep">/</span><span class="current">' + esc(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>';
+      h = '<a onclick="drillBack(0)">Dashboard</a><span class="sep">/</span><a onclick="drillBack(1)">' + esc(drillProject) + '</a><span class="sep">/</span><span class="current">' + esc(drillEnv) + '</span>';
     }
   } else {
-    const names = { services: 'Services', backups: 'Backups', system: 'System', restore: 'Restore' };
-    html = '<span class="current">' + (names[currentPage] || currentPage) + '</span>';
+    const names = { backups: 'Backups', system: 'System', restore: 'Restore' };
+    h = '<span class="current">' + (names[currentPage] || currentPage) + '</span>';
   }
-
-  bc.innerHTML = html;
+  bc.innerHTML = h;
 }
 
 function drillBack(level) {
-  if (level === 0) {
-    drillLevel = 0;
-    drillProject = null;
-    drillEnv = null;
-  } else if (level === 1) {
-    drillLevel = 1;
-    drillEnv = null;
-  }
+  if (level === 0) { drillLevel = 0; drillProject = null; drillEnv = null; }
+  else if (level === 1) { drillLevel = 1; drillEnv = null; }
   renderDashboard();
 }
 
 // ---------------------------------------------------------------------------
-// Dashboard — 3-level Drill
+// Dashboard — Cards + Table modes
 // ---------------------------------------------------------------------------
 function renderDashboard() {
   currentPage = 'dashboard';
-  if (drillLevel === 0) renderProjects();
-  else if (drillLevel === 1) renderEnvironments();
-  else if (drillLevel === 2) renderServices();
+  if (viewMode === 'table') { renderDashboardTable(); }
+  else if (drillLevel === 0) { renderProjects(); }
+  else if (drillLevel === 1) { renderEnvironments(); }
+  else { renderDrillServices(); }
   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 c = document.getElementById('page-content');
+  const projects = groupBy(allServices, 'project');
+  const totalUp = allServices.filter(isHealthy).length;
   const totalDown = allServices.length - totalUp;
 
-  let html = '<div class="page-enter" style="padding:0;">';
+  let h = '<div class="page-enter">';
 
-  // 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>';
+  // Stat tiles — clickable
+  h += '<div class="grid-stats" style="margin-bottom:1.5rem;">';
+  h += statTile('Projects', Object.keys(projects).length, '#3b82f6');
+  h += statTile('Services', allServices.length, '#8b5cf6', "setViewMode('table')");
+  h += statTile('Healthy', totalUp, '#10b981', "setTableFilter('healthy','Healthy')");
+  h += statTile('Down', totalDown, totalDown > 0 ? '#ef4444' : '#6b7280', totalDown > 0 ? "setTableFilter('down','Down')" : null);
+  h += '</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)}')">
+  h += '<div class="grid-auto">';
+  for (const [name, svcs] of Object.entries(projects)) {
+    const up = svcs.filter(isHealthy).length;
+    const total = svcs.length;
+    const envs = [...new Set(svcs.map(s => s.env))];
+    h += `<div class="card card-clickable" onclick="drillToProject('${esc(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>
+        <span class="status-dot ${up === total ? 'status-dot-green' : 'status-dot-red'}"></span>
+        <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${esc(name)}</span>
+        <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${total} svc</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('')}
+        ${envs.map(e => `<span class="badge badge-blue">${esc(e)}</span>`).join('')}
       </div>
-      <div style="font-size:0.8125rem;color:#9ca3af;">${upCount}/${total} healthy</div>
+      <div style="font-size:0.8125rem;color:#9ca3af;">${up}/${total} healthy</div>
     </div>`;
   }
-  html += '</div></div>';
-  content.innerHTML = html;
+  h += '</div></div>';
+  c.innerHTML = h;
 }
 
 function renderEnvironments() {
-  const content = document.getElementById('page-content');
-  const projServices = allServices.filter(s => s.project === drillProject);
-  const envs = groupByEnv(projServices);
+  const c = document.getElementById('page-content');
+  const envs = groupBy(allServices.filter(s => s.project === drillProject), 'env');
 
-  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)}')">
+  let h = '<div class="page-enter"><div class="grid-auto">';
+  for (const [envName, svcs] of Object.entries(envs)) {
+    const up = svcs.filter(isHealthy).length;
+    const total = svcs.length;
+    h += `<div class="card card-clickable" onclick="drillToEnv('${esc(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>
+        <span class="status-dot ${up === total ? 'status-dot-green' : 'status-dot-red'}"></span>
+        <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${esc(envName).toUpperCase()}</span>
+        <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${total} svc</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('')}
+        ${svcs.map(s => `<span class="badge ${badgeCls(s.status, s.health)}">${esc(s.service)}</span>`).join('')}
       </div>
-      <div style="font-size:0.8125rem;color:#9ca3af;">${upCount}/${total} healthy</div>
+      <div style="font-size:0.8125rem;color:#9ca3af;">${up}/${total} healthy</div>
     </div>`;
   }
-
-  html += '</div></div>';
-  content.innerHTML = html;
+  h += '</div></div>';
+  c.innerHTML = h;
 }
 
-function renderServices() {
-  const content = document.getElementById('page-content');
-  const services = allServices.filter(s => s.project === drillProject && s.env === drillEnv);
+function renderDrillServices() {
+  const c = document.getElementById('page-content');
+  const svcs = allServices.filter(s => s.project === drillProject && s.env === drillEnv);
+  let h = '<div class="page-enter"><div class="grid-auto">';
+  for (const svc of svcs) h += serviceCard(svc);
+  h += '</div></div>';
+  c.innerHTML = h;
+}
 
-  let html = '<div class="page-enter" style="padding:0;">';
-  html += '<div class="service-grid">';
+function drillToProject(name) { drillProject = name; drillLevel = 1; renderDashboard(); }
+function drillToEnv(name) { drillEnv = name; drillLevel = 2; renderDashboard(); }
 
-  for (const svc of services) {
-    html += serviceCard(svc);
+// ---------------------------------------------------------------------------
+// Dashboard — Table View
+// ---------------------------------------------------------------------------
+function renderDashboardTable() {
+  const c = document.getElementById('page-content');
+  const svcs = filterServices(allServices);
+
+  let h = '<div class="page-enter">';
+
+  // Quick filter row
+  h += '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-bottom:1rem;">';
+  h += filterBtn('All', null);
+  h += filterBtn('Healthy', 'healthy');
+  h += filterBtn('Down', 'down');
+  h += '<span style="color:#374151;">|</span>';
+  const projects = [...new Set(allServices.map(s => s.project))].sort();
+  for (const p of projects) {
+    h += filterBtn(p, 'project:' + p);
+  }
+  h += '</div>';
+
+  // Table
+  if (svcs.length === 0) {
+    h += '<div class="card" style="text-align:center;color:#6b7280;padding:2rem;">No services match this filter.</div>';
+  } else {
+    h += '<div class="table-wrapper"><table class="ops-table">';
+    h += '<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><tbody>';
+    for (const svc of svcs) {
+      h += `<tr>
+        <td><a style="color:#60a5fa;cursor:pointer;" onclick="setTableFilter('project:${esc(svc.project)}','${esc(svc.project)}')">${esc(svc.project)}</a></td>
+        <td><span class="badge badge-blue">${esc(svc.env)}</span></td>
+        <td class="mono">${esc(svc.service)}</td>
+        <td><span class="badge ${badgeCls(svc.status, svc.health)}">${esc(svc.status)}</span></td>
+        <td>${esc(svc.health || 'n/a')}</td>
+        <td>${esc(svc.uptime || 'n/a')}</td>
+        <td style="white-space:nowrap;">
+          <button class="btn btn-ghost btn-xs" onclick="viewLogs('${esc(svc.project)}','${esc(svc.env)}','${esc(svc.service)}')">Logs</button>
+          <button class="btn btn-warning btn-xs" onclick="restartService('${esc(svc.project)}','${esc(svc.env)}','${esc(svc.service)}')">Restart</button>
+        </td>
+      </tr>`;
+    }
+    h += '</tbody></table></div>';
   }
 
-  html += '</div></div>';
-  content.innerHTML = html;
-}
-
-function drillToProject(name) {
-  drillProject = name;
-  drillLevel = 1;
-  renderDashboard();
-}
-
-function drillToEnv(name) {
-  drillEnv = name;
-  drillLevel = 2;
-  renderDashboard();
+  h += '</div>';
+  c.innerHTML = h;
 }
 
 // ---------------------------------------------------------------------------
-// Service Card (shared component)
+// Shared Components
 // ---------------------------------------------------------------------------
 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);
-
+  const p = esc(svc.project), e = esc(svc.env), s = esc(svc.service);
   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>
+      <span class="status-dot ${dotClass(svc.status, svc.health)}"></span>
+      <span style="font-weight:600;color:#f3f4f6;">${s}</span>
+      <span class="badge ${badgeCls(svc.status, svc.health)}" style="margin-left:auto;">${esc(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')}
+      Health: ${esc(svc.health || 'n/a')} &middot; Uptime: ${esc(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 style="display:flex;gap:0.5rem;">
+      <button class="btn btn-ghost btn-xs" onclick="viewLogs('${p}','${e}','${s}')">Logs</button>
+      <button class="btn btn-warning btn-xs" onclick="restartService('${p}','${e}','${s}')">Restart</button>
     </div>
   </div>`;
 }
 
-function statCard(label, value, color) {
-  return `<div class="card" style="text-align:center;">
+function statTile(label, value, color, onclick) {
+  const click = onclick ? ` onclick="${onclick}"` : '';
+  const cls = onclick ? ' stat-tile' : '';
+  return `<div class="card${cls}" style="text-align:center;"${click}>
     <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;
+function filterBtn(label, filter) {
+  const active = tableFilter === filter;
+  const cls = active ? 'btn btn-primary btn-xs' : 'btn btn-ghost btn-xs';
+  if (filter === null) {
+    return `<button class="${cls}" onclick="tableFilter=null;tableFilterLabel='';renderDashboard()">${label}</button>`;
   }
+  return `<button class="${cls}" onclick="setTableFilter('${filter}','${label}')">${label}</button>`;
+}
 
-  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;
+function metricBar(label, used, total, unit, color) {
+  if (!total || total === 0) return '';
+  const pct = Math.round(used / total * 100);
+  const cls = pct >= 90 ? 'disk-danger' : pct >= 75 ? 'disk-warn' : color || 'disk-ok';
+  return `<div class="card">
+    <div style="display:flex;justify-content:space-between;margin-bottom:0.5rem;">
+      <span style="font-weight:500;color:#f3f4f6;">${label}</span>
+      <span style="font-size:0.8125rem;color:#9ca3af;">${fmtBytes(used)} / ${fmtBytes(total)} (${pct}%)</span>
+    </div>
+    <div class="progress-bar-track">
+      <div class="progress-bar-fill ${cls}" style="width:${pct}%;"></div>
+    </div>
+  </div>`;
 }
 
 // ---------------------------------------------------------------------------
-// Backups Page
+// Backups
 // ---------------------------------------------------------------------------
 async function renderBackups() {
   updateBreadcrumbs();
-  const content = document.getElementById('page-content');
-
+  const c = 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;">';
+    let h = '<div class="page-enter">';
 
     // 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>`;
+    h += '<div style="margin-bottom:1.5rem;">';
+    h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Create Backup</h2>';
+    h += '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;">';
+    for (const p of ['mdf', 'seriousletter']) {
+      for (const e of ['dev', 'int', 'prod']) {
+        h += `<button class="btn btn-ghost btn-sm" onclick="createBackup('${p}','${e}')">${p}/${e}</button>`;
       }
     }
-    html += '</div></div>';
+    h += '</div></div>';
 
-    // Local backups
-    html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Local Backups</h2>';
+    // Local
+    h += '<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>';
+      h += '<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>';
+      h += '<div class="table-wrapper"><table class="ops-table"><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>`;
+        h += `<tr><td>${esc(b.project||'')}</td><td><span class="badge badge-blue">${esc(b.env||b.environment||'')}</span></td><td>${esc(b.date||b.timestamp||'')}</td><td>${esc(b.size||'')}</td><td class="mono" style="font-size:0.75rem;">${esc(b.file||b.files||'')}</td></tr>`;
       }
-      html += '</tbody></table></div>';
+      h += '</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>';
+    // Offsite
+    h += '<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>';
+      h += '<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>';
+      h += '<div class="table-wrapper"><table class="ops-table"><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>`;
+        h += `<tr><td>${esc(b.project||'')}</td><td><span class="badge badge-blue">${esc(b.env||b.environment||'')}</span></td><td>${esc(b.date||b.timestamp||'')}</td><td>${esc(b.size||'')}</td></tr>`;
       }
-      html += '</tbody></table></div>';
+      h += '</tbody></table></div>';
     }
 
-    html += '</div>';
-    content.innerHTML = html;
+    h += '</div>';
+    c.innerHTML = h;
   } catch (e) {
-    content.innerHTML = '<div class="card" style="color:#f87171;">Failed to load backups: ' + escapeHtml(e.message) + '</div>';
+    c.innerHTML = '<div class="card" style="color:#f87171;">Failed to load backups: ' + esc(e.message) + '</div>';
   }
 }
 
 // ---------------------------------------------------------------------------
-// System Page
+// System
 // ---------------------------------------------------------------------------
 async function renderSystem() {
   updateBreadcrumbs();
-  const content = document.getElementById('page-content');
-
+  const c = document.getElementById('page-content');
   try {
     const [disk, health, timers, info] = await Promise.all([
       api('/api/system/disk').catch(e => ({ filesystems: [], raw: e.message })),
@@ -540,120 +526,119 @@
       api('/api/system/info').catch(e => ({ uptime: 'error', load: 'error' })),
     ]);
 
-    let html = '<div class="page-enter" style="padding:0;">';
+    let h = '<div class="page-enter">';
 
-    // 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>';
+    // Resource metrics (CPU, Memory, Swap)
+    h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Resources</h2>';
+    h += '<div class="grid-metrics" style="margin-bottom:1.5rem;">';
 
-    // 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) {
+    if (info.cpu) {
+      const cpu = info.cpu;
+      const cpuPct = cpu.usage_percent || 0;
+      const cpuCls = cpuPct >= 90 ? 'disk-danger' : cpuPct >= 75 ? 'disk-warn' : 'disk-ok';
+      h += `<div class="card">
+        <div style="display:flex;justify-content:space-between;margin-bottom:0.5rem;">
+          <span style="font-weight:500;color:#f3f4f6;">CPU</span>
+          <span style="font-size:0.8125rem;color:#9ca3af;">${cpuPct}% (${cpu.cores} cores)</span>
+        </div>
+        <div class="progress-bar-track">
+          <div class="progress-bar-fill ${cpuCls}" style="width:${cpuPct}%;"></div>
+        </div>
+      </div>`;
+    }
+
+    if (info.memory) {
+      h += metricBar('Memory', info.memory.used, info.memory.total);
+    }
+
+    if (info.swap && info.swap.total > 0) {
+      h += metricBar('Swap', info.swap.used, info.swap.total);
+    }
+
+    h += '</div>';
+
+    // Quick stats row
+    h += '<div class="grid-stats" style="margin-bottom:1.5rem;">';
+    h += statTile('Uptime', info.uptime || 'n/a', '#3b82f6');
+    h += statTile('Load', info.load || 'n/a', '#8b5cf6');
+    h += '</div>';
+
+    // Disk usage — only real filesystems
+    h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Disk Usage</h2>';
+    const realFs = (disk.filesystems || []).filter(f => f.filesystem && f.filesystem.startsWith('/dev'));
+    if (realFs.length > 0) {
+      h += '<div class="grid-metrics" style="margin-bottom:1.5rem;">';
+      for (const fs of realFs) {
         const pct = parseInt(fs.use_percent) || 0;
-        html += `<div class="card">
+        h += `<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>
+            <span class="mono" style="font-size:0.8125rem;">${esc(fs.mount || fs.filesystem)}</span>
+            <span style="font-size:0.8125rem;color:#9ca3af;">${esc(fs.used)} / ${esc(fs.size)} (${esc(fs.use_percent)})</span>
           </div>
           <div class="progress-bar-track">
-            <div class="progress-bar-fill ${diskColorClass(fs.use_percent)}" style="width:${pct}%;"></div>
+            <div class="progress-bar-fill ${diskColor(fs.use_percent)}" style="width:${pct}%;"></div>
           </div>
         </div>`;
       }
-      html += '</div>';
+      h += '</div>';
     } else {
-      html += '<div class="card" style="color:#6b7280;">No disk data available.</div>';
+      h += '<div class="card" style="color:#6b7280;">No disk data.</div>';
     }
 
     // Health checks
-    html += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Health Checks</h2>';
+    h += '<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();
+      h += '<div style="display:grid;gap:0.5rem;margin-bottom:1.5rem;">';
+      for (const ck of health.checks) {
+        const st = (ck.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>
+        h += `<div class="card" style="display:flex;align-items:center;gap:0.75rem;padding:0.75rem 1rem;">
+          <span class="badge ${cls}">${esc(st)}</span>
+          <span style="font-size:0.875rem;">${esc(ck.check)}</span>
         </div>`;
       }
-      html += '</div>';
+      h += '</div>';
     } else {
-      html += '<div class="card" style="color:#6b7280;">No health check data.</div>';
+      h += '<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>';
+    h += '<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>';
+      h += '<div class="table-wrapper"><table class="ops-table"><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>`;
+        h += `<tr><td class="mono">${esc(t.unit)}</td><td>${esc(t.next)}</td><td>${esc(t.left)}</td><td>${esc(t.last)}</td><td>${esc(t.passed)}</td></tr>`;
       }
-      html += '</tbody></table></div>';
+      h += '</tbody></table></div>';
     } else {
-      html += '<div class="card" style="color:#6b7280;">No timers found.</div>';
+      h += '<div class="card" style="color:#6b7280;">No timers found.</div>';
     }
 
-    html += '</div>';
-    content.innerHTML = html;
+    h += '</div>';
+    c.innerHTML = h;
   } catch (e) {
-    content.innerHTML = '<div class="card" style="color:#f87171;">Failed to load system info: ' + escapeHtml(e.message) + '</div>';
+    c.innerHTML = '<div class="card" style="color:#f87171;">Failed to load system info: ' + esc(e.message) + '</div>';
   }
 }
 
 // ---------------------------------------------------------------------------
-// Restore Page
+// Restore
 // ---------------------------------------------------------------------------
 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;
+  const c = document.getElementById('page-content');
+  let h = '<div class="page-enter">';
+  h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Restore Backup</h2>';
+  h += '<div class="card" style="max-width:480px;">';
+  h += '<div style="margin-bottom:1rem;"><label class="form-label">Project</label><select id="restore-project" class="form-select"><option value="mdf">mdf</option><option value="seriousletter">seriousletter</option></select></div>';
+  h += '<div style="margin-bottom:1rem;"><label class="form-label">Environment</label><select id="restore-env" class="form-select"><option value="dev">dev</option><option value="int">int</option><option value="prod">prod</option></select></div>';
+  h += '<div style="margin-bottom:1rem;"><label class="form-label">Source</label><select id="restore-source" class="form-select"><option value="local">Local</option><option value="offsite">Offsite</option></select></div>';
+  h += '<div style="margin-bottom:1rem;"><label style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;color:#9ca3af;"><input type="checkbox" id="restore-dry" checked> Dry run (preview only)</label></div>';
+  h += '<button class="btn btn-danger" onclick="startRestore()">Start Restore</button>';
+  h += '</div>';
+  h += '<div id="restore-output" style="display:none;margin-top:1rem;"><h3 style="font-size:1rem;font-weight:600;color:#f3f4f6;margin-bottom:0.5rem;">Output</h3><div id="restore-terminal" class="terminal" style="max-height:400px;"></div></div>';
+  h += '</div>';
+  c.innerHTML = h;
 }
 
 async function startRestore() {
@@ -661,36 +646,26 @@
   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)' : ''}?`)) return;
 
-  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 out = document.getElementById('restore-output');
+  const term = document.getElementById('restore-terminal');
+  out.style.display = 'block';
+  term.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');
+  const es = new EventSource(url);
+  es.onmessage = function(e) {
+    const d = JSON.parse(e.data);
+    if (d.done) {
+      es.close();
+      term.textContent += d.success ? '\n--- Restore complete ---\n' : '\n--- Restore FAILED ---\n';
+      toast(d.success ? 'Restore completed' : 'Restore failed', d.success ? 'success' : 'error');
       return;
     }
-    if (data.line) {
-      terminal.textContent += data.line + '\n';
-      terminal.scrollTop = terminal.scrollHeight;
-    }
+    if (d.line) { term.textContent += d.line + '\n'; term.scrollTop = term.scrollHeight; }
   };
-
-  evtSource.onerror = function() {
-    evtSource.close();
-    terminal.textContent += '\n--- Connection lost ---\n';
-    toast('Restore connection lost', 'error');
-  };
+  es.onerror = function() { es.close(); term.textContent += '\n--- Connection lost ---\n'; toast('Connection lost', 'error'); };
 }
 
 // ---------------------------------------------------------------------------
@@ -698,84 +673,54 @@
 // ---------------------------------------------------------------------------
 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');
-  }
+    const r = await api(`/api/services/restart/${project}/${env}/${service}`, { method: 'POST' });
+    toast(r.message || 'Restarted', 'success');
+    setTimeout(refreshCurrentPage, 3000);
+  } catch (e) { toast('Restart failed: ' + e.message, 'error'); }
 }
 
 async function viewLogs(project, env, service) {
-  logModalProject = project;
-  logModalEnv = env;
-  logModalService = service;
-
+  logCtx = { project, env, 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;
+  if (!logCtx.project) 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;
-  }
+    const d = await api(`/api/services/logs/${logCtx.project}/${logCtx.env}/${logCtx.service}?lines=200`);
+    const t = document.getElementById('log-modal-content');
+    t.textContent = d.logs || 'No logs available.';
+    t.scrollTop = t.scrollHeight;
+  } catch (e) { document.getElementById('log-modal-content').textContent = 'Error: ' + e.message; }
 }
 
 function closeLogModal() {
   document.getElementById('log-modal').style.display = 'none';
-  logModalProject = null;
-  logModalEnv = null;
-  logModalService = null;
+  logCtx = { project: null, env: null, service: null };
 }
 
-// ---------------------------------------------------------------------------
-// Backup Actions
-// ---------------------------------------------------------------------------
 async function createBackup(project, env) {
   if (!confirm(`Create backup for ${project}/${env}?`)) return;
-  toast('Creating backup for ' + project + '/' + env + '...', 'info');
+  toast('Creating backup...', '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');
-  }
+  } catch (e) { toast('Backup failed: ' + e.message, 'error'); }
 }
 
 // ---------------------------------------------------------------------------
-// Data Grouping
+// Utilities
 // ---------------------------------------------------------------------------
-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;
+function groupBy(arr, key) {
+  const m = {};
+  for (const item of arr) { const k = item[key] || 'other'; (m[k] = m[k] || []).push(item); }
+  return m;
 }
 
 // ---------------------------------------------------------------------------
@@ -784,12 +729,8 @@
 (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(r => { if (!r.ok) throw new Error(); return r.json(); })
       .then(data => {
         allServices = data;
         document.getElementById('login-overlay').style.display = 'none';
@@ -797,16 +738,7 @@
         showPage('dashboard');
         startAutoRefresh();
       })
-      .catch(() => {
-        localStorage.removeItem('ops_token');
-        document.getElementById('login-overlay').style.display = 'flex';
-      });
+      .catch(() => { localStorage.removeItem('ops_token'); });
   }
-
-  // ESC to close modals
-  document.addEventListener('keydown', e => {
-    if (e.key === 'Escape') {
-      closeLogModal();
-    }
-  });
+  document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLogModal(); });
 })();

--
Gitblit v1.3.1