From 7d94ec0d18b46893e23680cf8438109a34cc2a10 Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Sun, 22 Feb 2026 16:55:03 +0100
Subject: [PATCH] feat: promote/sync/rebuild UI, operations page, bidirectional sync, lifecycle ops

---
 static/index.html        |   89 ++
 app/routers/system.py    |   91 +-
 app/main.py              |   80 +
 app/routers/registry.py  |   40 
 app/routers/promote.py   |   72 +
 app/routers/restore.py   |   20 
 static/js/app.js         | 1215 ++++++++++++++++++++++++++---
 docker-compose.yml       |    1 
 app/routers/sync_data.py |   76 +
 app/routers/backups.py   |   68 +
 app/routers/rebuild.py   |  526 ++++++++++++
 static/css/style.css     |  124 +++
 app/ops_runner.py        |   31 
 13 files changed, 2,234 insertions(+), 199 deletions(-)

diff --git a/app/main.py b/app/main.py
index 5a6e260..8b702ec 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,12 +1,16 @@
+import hashlib
 import logging
 from contextlib import asynccontextmanager
 from pathlib import Path
 
-from fastapi import FastAPI
+from fastapi import FastAPI, Request
 from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import HTMLResponse
 from fastapi.staticfiles import StaticFiles
+from starlette.datastructures import MutableHeaders
+from starlette.types import ASGIApp, Receive, Scope, Send
 
-from app.routers import backups, restore, services, status, system
+from app.routers import backups, promote, rebuild, registry, restore, services, status, sync_data, system
 
 logging.basicConfig(
     level=logging.INFO,
@@ -15,6 +19,13 @@
 logger = logging.getLogger(__name__)
 
 _STATIC_DIR = Path(__file__).parent.parent / "static"
+
+
+def _content_hash(filepath: Path) -> str:
+    """Return first 12 chars of SHA-256 hex digest of a file's contents."""
+    if not filepath.exists():
+        return "0"
+    return hashlib.sha256(filepath.read_bytes()).hexdigest()[:12]
 
 
 @asynccontextmanager
@@ -49,13 +60,72 @@
 app.include_router(restore.router, prefix="/api/restore", tags=["restore"])
 app.include_router(services.router, prefix="/api/services", tags=["services"])
 app.include_router(system.router, prefix="/api/system", tags=["system"])
+app.include_router(promote.router, prefix="/api/promote", tags=["promote"])
+app.include_router(sync_data.router, prefix="/api/sync", tags=["sync"])
+app.include_router(registry.router, prefix="/api/registry", tags=["registry"])
+app.include_router(rebuild.router, prefix="/api/rebuild", tags=["rebuild"])
 
 # ---------------------------------------------------------------------------
-# Static files – serve CSS/JS at /static and HTML at /
-# Mount /static first for explicit asset paths, then / for SPA fallback
+# Index route — serves index.html with content-hashed asset URLs.
+# The hash changes automatically when JS/CSS files change, so browsers
+# always fetch fresh assets after a deploy. No manual version bumping.
+# ---------------------------------------------------------------------------
+_INDEX_HTML = _STATIC_DIR / "index.html"
+
+
+@app.get("/", response_class=HTMLResponse)
+async def serve_index(request: Request):
+    """Serve index.html with auto-computed content hashes for cache busting."""
+    js_hash = _content_hash(_STATIC_DIR / "js" / "app.js")
+    css_hash = _content_hash(_STATIC_DIR / "css" / "style.css")
+    html = _INDEX_HTML.read_text()
+    # Replace any existing ?v=XX or ?h=XX cache busters with content hash
+    import re
+    html = re.sub(r'/static/js/app\.js\?[^"]*', f'/static/js/app.js?h={js_hash}', html)
+    html = re.sub(r'/static/css/style\.css\?[^"]*', f'/static/css/style.css?h={css_hash}', html)
+    return HTMLResponse(content=html, headers={"Cache-Control": "no-cache, must-revalidate"})
+
+
+# ---------------------------------------------------------------------------
+# Static files – JS/CSS at /static (cached aggressively — hash busts cache)
 # ---------------------------------------------------------------------------
 if _STATIC_DIR.exists():
     app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static-assets")
-    app.mount("/", StaticFiles(directory=str(_STATIC_DIR), html=True), name="static")
 else:
     logger.warning("Static directory not found at %s – frontend will not be served", _STATIC_DIR)
+
+
+# ---------------------------------------------------------------------------
+# Cache-Control ASGI wrapper
+# - JS/CSS with ?h= hash: cached forever (immutable, 1 year)
+# - Everything else: standard headers
+# ---------------------------------------------------------------------------
+class CacheControlWrapper:
+    def __init__(self, inner: ASGIApp):
+        self.inner = inner
+
+    async def __call__(self, scope: Scope, receive: Receive, send: Send):
+        if scope["type"] != "http":
+            await self.inner(scope, receive, send)
+            return
+
+        path = scope.get("path", "")
+        qs = scope.get("query_string", b"").decode()
+        has_content_hash = "h=" in qs
+
+        async def send_with_cache_headers(message):
+            if message["type"] == "http.response.start":
+                headers = MutableHeaders(scope=message)
+                ct = headers.get("content-type", "")
+                if has_content_hash and any(t in ct for t in ("javascript", "text/css")):
+                    # Content-hashed assets: cache forever, hash changes on any edit
+                    headers["cache-control"] = "public, max-age=31536000, immutable"
+                elif any(t in ct for t in ("text/html", "javascript", "text/css")):
+                    headers["cache-control"] = "no-cache, must-revalidate"
+            await send(message)
+
+        await self.inner(scope, receive, send_with_cache_headers)
+
+
+# Wrap the finished FastAPI app — Uvicorn resolves app.main:app to this
+app = CacheControlWrapper(app)
diff --git a/app/ops_runner.py b/app/ops_runner.py
index 226fdaa..d9a460a 100644
--- a/app/ops_runner.py
+++ b/app/ops_runner.py
@@ -39,10 +39,11 @@
         data = json.loads(result["output"])
         return {"success": True, "data": data, "error": ""}
     except json.JSONDecodeError as exc:
+        raw = result["output"][:500]
         return {
             "success": False,
             "data": None,
-            "error": f"Failed to parse JSON: {exc}\nRaw: {result['output'][:500]}",
+            "error": f"Failed to parse JSON: {exc}\nRaw: {raw}",
         }
 
 
@@ -72,12 +73,40 @@
     return await _run_exec(_NSENTER_PREFIX + [OPS_CLI] + args, timeout=timeout)
 
 
+async def run_ops_host_json(args: list[str], timeout: int = _DEFAULT_TIMEOUT) -> dict:
+    """Run the ops CLI on the host via nsenter with --json and return parsed JSON."""
+    result = await run_ops_host(args + ["--json"], timeout=timeout)
+    if not result["success"]:
+        return {"success": False, "data": None, "error": result["error"] or result["output"]}
+    try:
+        data = json.loads(result["output"])
+        return {"success": True, "data": data, "error": ""}
+    except json.JSONDecodeError as exc:
+        raw = result["output"][:500]
+        return {
+            "success": False,
+            "data": None,
+            "error": f"Failed to parse JSON: {exc}\nRaw: {raw}",
+        }
+
+
 async def stream_ops_host(args: list[str], timeout: int = _DEFAULT_TIMEOUT) -> AsyncGenerator[str, None]:
     """Stream ops CLI output from the host via nsenter."""
     async for line in _stream_exec(_NSENTER_PREFIX + [OPS_CLI] + args, timeout=timeout):
         yield line
 
 
+async def run_command_host(args: list[str], timeout: int = _DEFAULT_TIMEOUT) -> dict:
+    """Run an arbitrary command on the host via nsenter."""
+    return await _run_exec(_NSENTER_PREFIX + args, timeout=timeout)
+
+
+async def stream_command_host(args: list[str], timeout: int = _DEFAULT_TIMEOUT) -> AsyncGenerator[str, None]:
+    """Stream arbitrary command output from the host via nsenter."""
+    async for line in _stream_exec(_NSENTER_PREFIX + args, timeout=timeout):
+        yield line
+
+
 # ---------------------------------------------------------------------------
 # Internal helpers
 # ---------------------------------------------------------------------------
diff --git a/app/routers/backups.py b/app/routers/backups.py
index d1cf26e..de5a15c 100644
--- a/app/routers/backups.py
+++ b/app/routers/backups.py
@@ -1,9 +1,9 @@
 from typing import Any
 
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Query
 
 from app.auth import verify_token
-from app.ops_runner import run_ops, run_ops_json, run_ops_host, _BACKUP_TIMEOUT
+from app.ops_runner import run_ops, run_ops_json, run_ops_host, run_ops_host_json, run_command_host, _BACKUP_TIMEOUT
 
 router = APIRouter()
 
@@ -33,9 +33,22 @@
     _: str = Depends(verify_token),
 ) -> list[dict[str, Any]]:
     """Returns a list of offsite backup records."""
+    # Get project list from registry
+    import yaml
+    registry_path = "/opt/infrastructure/servers/hetzner-vps/registry.yaml"
+    try:
+        with open(registry_path) as f:
+            registry = yaml.safe_load(f)
+        projects = [
+            name for name, cfg in registry.get("projects", {}).items()
+            if cfg.get("backup_dir") and not cfg.get("infrastructure") and not cfg.get("static")
+        ]
+    except Exception:
+        projects = ["mdf", "seriousletter"]  # Fallback
+
     all_backups = []
-    for project in ["mdf", "seriousletter"]:
-        result = await run_ops_json(["offsite", "list", project])
+    for project in projects:
+        result = await run_ops_host_json(["offsite", "list", project])
         if result["success"] and isinstance(result["data"], list):
             for b in result["data"]:
                 b["project"] = project
@@ -99,3 +112,50 @@
             detail=f"Retention policy failed: {result['error'] or result['output']}",
         )
     return {"success": True, "output": result["output"]}
+
+
+@router.delete("/{project}/{env}/{name}", summary="Delete a backup")
+async def delete_backup(
+    project: str,
+    env: str,
+    name: str,
+    target: str = Query("local", regex="^(local|offsite|both)$"),
+    _: str = Depends(verify_token),
+) -> dict[str, Any]:
+    """
+    Delete a backup from local storage, offsite, or both.
+
+    Query param `target`: local | offsite | both (default: local).
+    """
+    if "/" in name or "\\" in name or ".." in name:
+        raise HTTPException(status_code=400, detail="Invalid backup name")
+
+    results = {"local": None, "offsite": None}
+
+    # Delete local
+    if target in ("local", "both"):
+        backup_path = f"/opt/data/backups/{project}/{env}/{name}"
+        check = await run_command_host(["test", "-f", backup_path])
+        if check["success"]:
+            result = await run_command_host(["rm", backup_path])
+            results["local"] = "ok" if result["success"] else "failed"
+        else:
+            results["local"] = "not_found"
+
+    # Delete offsite
+    if target in ("offsite", "both"):
+        result = await run_command_host([
+            "/opt/data/\u03c0/bin/python3", "-c",
+            f"import sys; sys.path.insert(0, '/opt/data/scripts'); "
+            f"from offsite import delete; "
+            f"ok = delete('{name}', '{project}', '{env}', quiet=True); "
+            f"sys.exit(0 if ok else 1)"
+        ])
+        results["offsite"] = "ok" if result["success"] else "failed"
+
+    # Check if anything succeeded
+    any_ok = "ok" in results.values()
+    if not any_ok:
+        raise HTTPException(status_code=500, detail=f"Delete failed: {results}")
+
+    return {"success": True, "project": project, "env": env, "name": name, "results": results}
diff --git a/app/routers/promote.py b/app/routers/promote.py
new file mode 100644
index 0000000..3d0d389
--- /dev/null
+++ b/app/routers/promote.py
@@ -0,0 +1,72 @@
+import json
+from datetime import datetime, timezone
+from typing import AsyncGenerator, Literal
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.responses import StreamingResponse
+
+from app.auth import verify_token
+from app.ops_runner import _BACKUP_TIMEOUT, stream_ops_host
+
+router = APIRouter()
+
+# Only adjacent-environment promote paths are allowed (code flows up)
+_VALID_PROMOTE_PAIRS = {("dev", "int"), ("int", "prod")}
+
+
+def _sse_line(payload: dict) -> str:
+    return f"data: {json.dumps(payload)}\n\n"
+
+
+def _now() -> str:
+    return datetime.now(timezone.utc).isoformat()
+
+
+async def _promote_generator(
+    project: str,
+    from_env: str,
+    to_env: str,
+    dry_run: bool,
+) -> AsyncGenerator[str, None]:
+    """Stream promote output via SSE."""
+    args = ["promote", project, from_env, to_env]
+    if dry_run:
+        args.append("--dry-run")
+    args.append("--yes")
+
+    label = f"Promoting {project}: {from_env} -> {to_env}"
+    if dry_run:
+        label += "  (dry run)"
+    yield _sse_line({"line": label, "timestamp": _now()})
+
+    success = True
+    async for line in stream_ops_host(args, timeout=_BACKUP_TIMEOUT):
+        yield _sse_line({"line": line, "timestamp": _now()})
+        if line.startswith("[error]") or "failed" in line.lower():
+            success = False
+
+    yield _sse_line({"done": True, "success": success})
+
+
+@router.get("/{project}/{from_env}/{to_env}", summary="Promote code with real-time output")
+async def promote_code(
+    project: str,
+    from_env: str,
+    to_env: str,
+    dry_run: bool = Query(default=False, alias="dry_run"),
+    _: str = Depends(verify_token),
+) -> StreamingResponse:
+    """Promote code forward (dev->int, int->prod) with SSE streaming."""
+    if (from_env, to_env) not in _VALID_PROMOTE_PAIRS:
+        raise HTTPException(
+            status_code=400,
+            detail=f"Invalid promote path '{from_env} -> {to_env}'. Only adjacent pairs are allowed: dev->int, int->prod.",
+        )
+    return StreamingResponse(
+        _promote_generator(project, from_env, to_env, dry_run),
+        media_type="text/event-stream",
+        headers={
+            "Cache-Control": "no-cache",
+            "X-Accel-Buffering": "no",
+        },
+    )
diff --git a/app/routers/rebuild.py b/app/routers/rebuild.py
new file mode 100644
index 0000000..d4873a4
--- /dev/null
+++ b/app/routers/rebuild.py
@@ -0,0 +1,526 @@
+"""
+Container lifecycle operations via Coolify API + SSH.
+
+Three operations:
+  restart  – docker restart {containers} via SSH (no Coolify, no image pruning)
+  rebuild  – Coolify stop → docker build → Coolify start
+  recreate – Coolify stop → wipe data → docker build → Coolify start → show backups banner
+"""
+import json
+import os
+import urllib.request
+import urllib.error
+from datetime import datetime, timezone
+from typing import AsyncGenerator
+
+import yaml
+from fastapi import APIRouter, Depends, Query
+from fastapi.responses import StreamingResponse
+
+from app.auth import verify_token
+from app.ops_runner import (
+    _BACKUP_TIMEOUT,
+    run_command,
+    run_command_host,
+    stream_command_host,
+)
+
+router = APIRouter()
+
+# ---------------------------------------------------------------------------
+# Configuration
+# ---------------------------------------------------------------------------
+
+_REGISTRY_PATH = os.environ.get(
+    "REGISTRY_PATH",
+    "/opt/infrastructure/servers/hetzner-vps/registry.yaml",
+)
+
+_COOLIFY_BASE = os.environ.get(
+    "COOLIFY_BASE_URL",
+    "https://cockpit.tekmidian.com/api/v1",
+)
+
+_COOLIFY_TOKEN = os.environ.get(
+    "COOLIFY_API_TOKEN",
+    "3|f1fa8ee5791440ddd37e6cecafd964c8cd734dd4a8891180c424efad6bfdb7f5",
+)
+
+_COOLIFY_TIMEOUT = 30   # seconds for API calls
+_POLL_INTERVAL  = 5    # seconds between container status polls
+_POLL_MAX_WAIT  = 180  # max seconds to wait for containers to stop/start
+
+
+# ---------------------------------------------------------------------------
+# Registry helpers
+# ---------------------------------------------------------------------------
+
+def _load_registry() -> dict:
+    with open(_REGISTRY_PATH) as f:
+        return yaml.safe_load(f) or {}
+
+
+def _project_cfg(project: str) -> dict:
+    reg = _load_registry()
+    projects = reg.get("projects", {})
+    if project not in projects:
+        raise ValueError(f"Unknown project '{project}'")
+    return projects[project]
+
+
+def _coolify_uuid(project: str, env: str) -> str:
+    cfg = _project_cfg(project)
+    uuids = cfg.get("coolify_uuids", {})
+    uuid = uuids.get(env)
+    if not uuid:
+        raise ValueError(
+            f"No coolify_uuid configured for {project}/{env} in registry.yaml"
+        )
+    return uuid
+
+
+def _data_dir(project: str, env: str) -> str:
+    cfg = _project_cfg(project)
+    template = cfg.get("data_dir", "")
+    if not template:
+        raise ValueError(f"No data_dir configured for {project} in registry.yaml")
+    return template.replace("{env}", env)
+
+
+def _build_cfg(project: str, env: str) -> dict | None:
+    """Return build config or None if the project uses registry-only images."""
+    cfg = _project_cfg(project)
+    build = cfg.get("build", {})
+    if build.get("no_local_image"):
+        return None
+    ctx_template = build.get("build_context", "")
+    if not ctx_template:
+        return None
+    return {
+        "build_context": ctx_template.replace("{env}", env),
+        "image_name": build.get("image_name", project),
+        "env": env,
+    }
+
+
+# ---------------------------------------------------------------------------
+# SSE helpers
+# ---------------------------------------------------------------------------
+
+def _sse(payload: dict) -> str:
+    return f"data: {json.dumps(payload)}\n\n"
+
+
+def _now() -> str:
+    return datetime.now(timezone.utc).isoformat()
+
+
+def _line(text: str) -> str:
+    return _sse({"line": text, "timestamp": _now()})
+
+
+def _done(success: bool, project: str, env: str, action: str) -> str:
+    return _sse({
+        "done": True,
+        "success": success,
+        "project": project,
+        "env": env,
+        "action": action,
+    })
+
+
+# ---------------------------------------------------------------------------
+# Coolify API (synchronous — called from async context via run_in_executor)
+# ---------------------------------------------------------------------------
+
+def _coolify_request(method: str, path: str) -> dict:
+    """Make a Coolify API request. Returns parsed JSON body."""
+    url = f"{_COOLIFY_BASE}{path}"
+    req = urllib.request.Request(
+        url,
+        method=method,
+        headers={
+            "Authorization": f"Bearer {_COOLIFY_TOKEN}",
+            "Content-Type": "application/json",
+            "Accept": "application/json",
+        },
+    )
+    try:
+        with urllib.request.urlopen(req, timeout=_COOLIFY_TIMEOUT) as resp:
+            body = resp.read()
+            return json.loads(body) if body else {}
+    except urllib.error.HTTPError as exc:
+        body = exc.read()
+        raise RuntimeError(
+            f"Coolify API {method} {path} returned HTTP {exc.code}: {body.decode(errors='replace')[:500]}"
+        ) from exc
+    except Exception as exc:
+        raise RuntimeError(f"Coolify API call failed: {exc}") from exc
+
+
+async def _coolify_action(action: str, uuid: str) -> dict:
+    """Call a Coolify service action endpoint (stop/start/restart)."""
+    import asyncio
+    loop = asyncio.get_event_loop()
+    return await loop.run_in_executor(
+        None, _coolify_request, "POST", f"/services/{uuid}/{action}"
+    )
+
+
+# ---------------------------------------------------------------------------
+# Container polling helpers
+# ---------------------------------------------------------------------------
+
+async def _find_containers_for_service(project: str, env: str) -> list[str]:
+    """
+    Find all running Docker containers belonging to a project/env.
+    Uses the registry name_prefix and matches {env}-{prefix}-* pattern.
+    """
+    cfg = _project_cfg(project)
+    prefix = cfg.get("name_prefix", project)
+    name_pattern = f"{env}-{prefix}-"
+
+    result = await run_command(
+        ["docker", "ps", "--filter", f"name={name_pattern}", "--format", "{{.Names}}"],
+        timeout=15,
+    )
+    containers = []
+    if result["success"]:
+        for name in result["output"].strip().splitlines():
+            name = name.strip()
+            if name and name.startswith(name_pattern):
+                containers.append(name)
+    return containers
+
+
+async def _poll_until_stopped(
+    project: str,
+    env: str,
+    max_wait: int = _POLL_MAX_WAIT,
+) -> bool:
+    """Poll until no containers for project/env are running. Returns True if stopped."""
+    import asyncio
+    cfg = _project_cfg(project)
+    prefix = cfg.get("name_prefix", project)
+    name_pattern = f"{env}-{prefix}-"
+    waited = 0
+    while waited < max_wait:
+        result = await run_command(
+            ["docker", "ps", "--filter", f"name={name_pattern}", "--format", "{{.Names}}"],
+            timeout=15,
+        )
+        running = [
+            n.strip()
+            for n in result["output"].strip().splitlines()
+            if n.strip().startswith(name_pattern)
+        ] if result["success"] else []
+        if not running:
+            return True
+        await asyncio.sleep(_POLL_INTERVAL)
+        waited += _POLL_INTERVAL
+    return False
+
+
+async def _poll_until_running(
+    project: str,
+    env: str,
+    max_wait: int = _POLL_MAX_WAIT,
+) -> bool:
+    """Poll until at least one container for project/env is running. Returns True if up."""
+    import asyncio
+    cfg = _project_cfg(project)
+    prefix = cfg.get("name_prefix", project)
+    name_pattern = f"{env}-{prefix}-"
+    waited = 0
+    while waited < max_wait:
+        result = await run_command(
+            ["docker", "ps", "--filter", f"name={name_pattern}", "--format", "{{.Names}}"],
+            timeout=15,
+        )
+        running = [
+            n.strip()
+            for n in result["output"].strip().splitlines()
+            if n.strip().startswith(name_pattern)
+        ] if result["success"] else []
+        if running:
+            return True
+        await asyncio.sleep(_POLL_INTERVAL)
+        waited += _POLL_INTERVAL
+    return False
+
+
+# ---------------------------------------------------------------------------
+# Operation: Restart
+# ---------------------------------------------------------------------------
+
+async def _op_restart(project: str, env: str) -> AsyncGenerator[str, None]:
+    """
+    Restart: docker restart {containers} via SSH/nsenter.
+    No Coolify involvement — avoids the image-pruning stop/start cycle.
+    """
+    yield _line(f"[restart] Finding containers for {project}/{env}...")
+
+    try:
+        containers = await _find_containers_for_service(project, env)
+    except Exception as exc:
+        yield _line(f"[error] Registry lookup failed: {exc}")
+        yield _done(False, project, env, "restart")
+        return
+
+    if not containers:
+        yield _line(f"[error] No running containers found for {project}/{env}")
+        yield _done(False, project, env, "restart")
+        return
+
+    yield _line(f"[restart] Restarting {len(containers)} container(s): {', '.join(containers)}")
+
+    cmd = ["docker", "restart"] + containers
+    result = await run_command(cmd, timeout=120)
+
+    if result["output"].strip():
+        for line in result["output"].strip().splitlines():
+            yield _line(line)
+    if result["error"].strip():
+        for line in result["error"].strip().splitlines():
+            yield _line(f"[stderr] {line}")
+
+    if result["success"]:
+        yield _line(f"[restart] All containers restarted successfully.")
+        yield _done(True, project, env, "restart")
+    else:
+        yield _line(f"[error] docker restart failed (exit code non-zero)")
+        yield _done(False, project, env, "restart")
+
+
+# ---------------------------------------------------------------------------
+# Operation: Rebuild
+# ---------------------------------------------------------------------------
+
+async def _op_rebuild(project: str, env: str) -> AsyncGenerator[str, None]:
+    """
+    Rebuild: Coolify stop → docker build → Coolify start.
+    No data loss. For code/Dockerfile changes.
+    """
+    try:
+        uuid = _coolify_uuid(project, env)
+        build = _build_cfg(project, env)
+    except ValueError as exc:
+        yield _line(f"[error] Config error: {exc}")
+        yield _done(False, project, env, "rebuild")
+        return
+
+    # Step 1: Stop via Coolify
+    yield _line(f"[rebuild] Stopping {project}/{env} via Coolify (uuid={uuid})...")
+    try:
+        await _coolify_action("stop", uuid)
+        yield _line(f"[rebuild] Coolify stop queued. Waiting for containers to stop...")
+        # Step 2: Poll until stopped
+        stopped = await _poll_until_stopped(project, env)
+        if not stopped:
+            yield _line(f"[warn] Containers may still be running after {_POLL_MAX_WAIT}s — proceeding anyway")
+        else:
+            yield _line(f"[rebuild] All containers stopped.")
+    except RuntimeError as exc:
+        if "already stopped" in str(exc).lower():
+            yield _line(f"[rebuild] Service already stopped — continuing with build.")
+        else:
+            yield _line(f"[error] Coolify stop failed: {exc}")
+            yield _done(False, project, env, "rebuild")
+            return
+
+    # Step 3: Build image (if project uses local images)
+    if build:
+        ctx = build["build_context"]
+        image = f"{build['image_name']}:{env}"
+        yield _line(f"[rebuild] Building Docker image: {image}")
+        yield _line(f"[rebuild] Build context: {ctx}")
+
+        async for line in stream_command_host(
+            ["docker", "build", "-t", image, ctx],
+            timeout=_BACKUP_TIMEOUT,
+        ):
+            yield _line(line)
+    else:
+        yield _line(f"[rebuild] No local image build needed (registry images only).")
+
+    # Step 4: Start via Coolify
+    yield _line(f"[rebuild] Starting {project}/{env} via Coolify...")
+    try:
+        await _coolify_action("start", uuid)
+        yield _line(f"[rebuild] Coolify start queued. Waiting for containers...")
+    except RuntimeError as exc:
+        yield _line(f"[error] Coolify start failed: {exc}")
+        yield _done(False, project, env, "rebuild")
+        return
+
+    # Step 5: Poll until running
+    running = await _poll_until_running(project, env)
+    if running:
+        yield _line(f"[rebuild] Containers are up.")
+        yield _done(True, project, env, "rebuild")
+    else:
+        yield _line(f"[warn] Containers did not appear healthy within {_POLL_MAX_WAIT}s — check Coolify logs.")
+        yield _done(False, project, env, "rebuild")
+
+
+# ---------------------------------------------------------------------------
+# Operation: Recreate (Disaster Recovery)
+# ---------------------------------------------------------------------------
+
+async def _op_recreate(project: str, env: str) -> AsyncGenerator[str, None]:
+    """
+    Recreate: Coolify stop → wipe data → docker build → Coolify start.
+    DESTRUCTIVE — wipes all data volumes. Shows "Go to Backups" banner on success.
+    """
+    try:
+        uuid = _coolify_uuid(project, env)
+        build = _build_cfg(project, env)
+        data_dir = _data_dir(project, env)
+        cfg = _project_cfg(project)
+    except ValueError as exc:
+        yield _line(f"[error] Config error: {exc}")
+        yield _done(False, project, env, "recreate")
+        return
+
+    # Step 1: Stop via Coolify
+    yield _line(f"[recreate] Stopping {project}/{env} via Coolify (uuid={uuid})...")
+    try:
+        await _coolify_action("stop", uuid)
+        yield _line(f"[recreate] Coolify stop queued. Waiting for containers to stop...")
+        # Step 2: Poll until stopped
+        stopped = await _poll_until_stopped(project, env)
+        if not stopped:
+            yield _line(f"[warn] Containers may still be running after {_POLL_MAX_WAIT}s — proceeding anyway")
+        else:
+            yield _line(f"[recreate] All containers stopped.")
+    except RuntimeError as exc:
+        if "already stopped" in str(exc).lower():
+            yield _line(f"[recreate] Service already stopped — skipping stop step.")
+        else:
+            yield _line(f"[error] Coolify stop failed: {exc}")
+            yield _done(False, project, env, "recreate")
+            return
+
+    # Step 3: Wipe data volumes
+    yield _line(f"[recreate] WARNING: Wiping data directory: {data_dir}")
+    # Verify THIS env's containers are actually stopped before wiping
+    name_prefix = cfg.get("name_prefix", project)
+    # Use grep to AND-match both prefix and env (docker --filter uses OR for multiple name filters)
+    verify = await run_command_host(
+        ["sh", "-c", f"docker ps --format '{{{{.Names}}}}' | grep '^{env}-{name_prefix}\\|^{name_prefix}-{env}' || true"],
+        timeout=30,
+    )
+    running = verify["output"].strip()
+    if running:
+        yield _line(f"[error] Containers still running for {project}/{env}:")
+        for line in running.splitlines():
+            yield _line(f"  {line}")
+        yield _done(False, project, env, "recreate")
+        return
+
+    wipe_result = await run_command_host(
+        ["sh", "-c", f"rm -r {data_dir}/* 2>&1; echo EXIT_CODE=$?"],
+        timeout=120,
+    )
+    for line in (wipe_result["output"].strip() + "\n" + wipe_result["error"].strip()).strip().splitlines():
+        if line:
+            yield _line(line)
+    if "EXIT_CODE=0" in wipe_result["output"]:
+        yield _line(f"[recreate] Data directory wiped.")
+    else:
+        yield _line(f"[error] Wipe may have failed — check output above.")
+        yield _done(False, project, env, "recreate")
+        return
+
+    # Step 4: Build image (if project uses local images)
+    if build:
+        ctx = build["build_context"]
+        image = f"{build['image_name']}:{env}"
+        yield _line(f"[recreate] Building Docker image: {image}")
+        yield _line(f"[recreate] Build context: {ctx}")
+
+        async for line in stream_command_host(
+            ["docker", "build", "-t", image, ctx],
+            timeout=_BACKUP_TIMEOUT,
+        ):
+            yield _line(line)
+    else:
+        yield _line(f"[recreate] No local image build needed (registry images only).")
+
+    # Step 5: Start via Coolify
+    yield _line(f"[recreate] Starting {project}/{env} via Coolify...")
+    try:
+        await _coolify_action("start", uuid)
+        yield _line(f"[recreate] Coolify start queued. Waiting for containers...")
+    except RuntimeError as exc:
+        yield _line(f"[error] Coolify start failed: {exc}")
+        yield _done(False, project, env, "recreate")
+        return
+
+    # Step 6: Poll until running
+    running = await _poll_until_running(project, env)
+    if running:
+        yield _line(f"[recreate] Containers are up. Restore a backup to complete recovery.")
+        yield _done(True, project, env, "recreate")
+    else:
+        yield _line(f"[warn] Containers did not appear within {_POLL_MAX_WAIT}s — check Coolify logs.")
+        # Still return success=True so the "Go to Backups" banner appears
+        yield _done(True, project, env, "recreate")
+
+
+# ---------------------------------------------------------------------------
+# Dispatch wrapper
+# ---------------------------------------------------------------------------
+
+async def _op_generator(
+    project: str,
+    env: str,
+    action: str,
+) -> AsyncGenerator[str, None]:
+    """Route to the correct operation generator."""
+    if action == "restart":
+        async for chunk in _op_restart(project, env):
+            yield chunk
+    elif action == "rebuild":
+        async for chunk in _op_rebuild(project, env):
+            yield chunk
+    elif action == "recreate":
+        async for chunk in _op_recreate(project, env):
+            yield chunk
+    else:
+        yield _line(f"[error] Unknown action '{action}'. Valid: restart, rebuild, recreate")
+        yield _done(False, project, env, action)
+
+
+# ---------------------------------------------------------------------------
+# Endpoint
+# ---------------------------------------------------------------------------
+
+@router.get(
+    "/{project}/{env}",
+    summary="Container lifecycle operation with real-time SSE output",
+)
+async def lifecycle_op(
+    project: str,
+    env: str,
+    action: str = Query(
+        default="restart",
+        description="Operation: restart | rebuild | recreate",
+    ),
+    _: str = Depends(verify_token),
+) -> StreamingResponse:
+    """
+    Stream a container lifecycle operation via SSE.
+
+    - restart:  docker restart containers (safe, fast)
+    - rebuild:  stop via Coolify, rebuild image, start via Coolify
+    - recreate: stop, wipe data, rebuild image, start (destructive — DR only)
+    """
+    return StreamingResponse(
+        _op_generator(project, env, action),
+        media_type="text/event-stream",
+        headers={
+            "Cache-Control": "no-cache",
+            "X-Accel-Buffering": "no",
+        },
+    )
diff --git a/app/routers/registry.py b/app/routers/registry.py
new file mode 100644
index 0000000..99e8f20
--- /dev/null
+++ b/app/routers/registry.py
@@ -0,0 +1,40 @@
+import yaml
+from pathlib import Path
+from typing import Any
+
+from fastapi import APIRouter, Depends
+
+from app.auth import verify_token
+
+router = APIRouter()
+
+_REGISTRY_PATH = Path("/opt/infrastructure/servers/hetzner-vps/registry.yaml")
+
+
+def _load_registry() -> dict:
+    """Load and return the registry YAML."""
+    with open(_REGISTRY_PATH) as f:
+        return yaml.safe_load(f)
+
+
+@router.get("/", summary="Get project registry")
+async def get_registry(
+    _: str = Depends(verify_token),
+) -> dict[str, Any]:
+    """Return project list with environments, promote config, and domains."""
+    registry = _load_registry()
+    projects = {}
+
+    for name, cfg in registry.get("projects", {}).items():
+        projects[name] = {
+            "environments": cfg.get("environments", []),
+            "domains": cfg.get("domains", {}),
+            "promote": cfg.get("promote"),
+            "has_cli": bool(cfg.get("cli")),
+            "static": cfg.get("static", False),
+            "infrastructure": cfg.get("infrastructure", False),
+            "backup_dir": cfg.get("backup_dir"),
+            "has_coolify": bool(cfg.get("coolify_uuids")),
+        }
+
+    return {"projects": projects}
diff --git a/app/routers/restore.py b/app/routers/restore.py
index d03428e..fc1e60f 100644
--- a/app/routers/restore.py
+++ b/app/routers/restore.py
@@ -21,6 +21,8 @@
     env: str,
     source: str,
     dry_run: bool,
+    name: str | None = None,
+    mode: str = "full",
 ) -> AsyncGenerator[str, None]:
     """Async generator that drives the restore workflow and yields SSE events.
 
@@ -28,8 +30,20 @@
     that use host Python venvs incompatible with the container's Python.
     """
     base_args = ["restore", project, env]
+
+    # Pass the backup file path to avoid interactive selection prompt
+    if name:
+        backup_path = f"/opt/data/backups/{project}/{env}/{name}"
+        base_args.append(backup_path)
+
     if dry_run:
         base_args.append("--dry-run")
+
+    # Granular restore mode
+    if mode == "db":
+        base_args.append("--db-only")
+    elif mode == "wp":
+        base_args.append("--wp-only")
 
     if source == "offsite":
         # ops offsite restore <project> <env>
@@ -67,6 +81,8 @@
     env: str,
     source: Literal["local", "offsite"] = Query(default="local"),
     dry_run: bool = Query(default=False, alias="dry_run"),
+    name: str | None = Query(default=None),
+    mode: Literal["full", "db", "wp"] = Query(default="full"),
     _: str = Depends(verify_token),
 ) -> StreamingResponse:
     """
@@ -74,9 +90,11 @@
 
     Uses Server-Sent Events (SSE) to stream real-time progress.
     Runs on the host via nsenter for Python venv compatibility.
+
+    Modes: full (default), db (database only), wp (wp-content only).
     """
     return StreamingResponse(
-        _restore_generator(project, env, source, dry_run),
+        _restore_generator(project, env, source, dry_run, name, mode),
         media_type="text/event-stream",
         headers={
             "Cache-Control": "no-cache",
diff --git a/app/routers/sync_data.py b/app/routers/sync_data.py
new file mode 100644
index 0000000..46f1089
--- /dev/null
+++ b/app/routers/sync_data.py
@@ -0,0 +1,76 @@
+import json
+from datetime import datetime, timezone
+from typing import AsyncGenerator, Literal
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.responses import StreamingResponse
+
+from app.auth import verify_token
+from app.ops_runner import _BACKUP_TIMEOUT, stream_ops_host
+
+router = APIRouter()
+
+# Only adjacent-environment sync paths are allowed (data flows down)
+_VALID_SYNC_PAIRS = {("prod", "int"), ("int", "dev")}
+
+
+def _sse_line(payload: dict) -> str:
+    return f"data: {json.dumps(payload)}\n\n"
+
+
+def _now() -> str:
+    return datetime.now(timezone.utc).isoformat()
+
+
+async def _sync_generator(
+    project: str,
+    from_env: str,
+    to_env: str,
+    db_only: bool,
+    uploads_only: bool,
+) -> AsyncGenerator[str, None]:
+    """Stream sync output via SSE."""
+    args = ["sync", project, "--from", from_env, "--to", to_env, "--yes"]
+    if db_only:
+        args.append("--db-only")
+    if uploads_only:
+        args.append("--uploads-only")
+
+    mode = "db-only" if db_only else ("uploads-only" if uploads_only else "full")
+    yield _sse_line({
+        "line": f"Syncing {project}: {from_env} -> {to_env} ({mode})...",
+        "timestamp": _now(),
+    })
+
+    success = True
+    async for line in stream_ops_host(args, timeout=_BACKUP_TIMEOUT):
+        yield _sse_line({"line": line, "timestamp": _now()})
+        if line.startswith("[error]") or "failed" in line.lower():
+            success = False
+
+    yield _sse_line({"done": True, "success": success})
+
+
+@router.get("/{project}", summary="Sync data with real-time output")
+async def sync_data(
+    project: str,
+    from_env: str = Query(default="prod", alias="from"),
+    to_env: str = Query(default="int", alias="to"),
+    db_only: bool = Query(default=False),
+    uploads_only: bool = Query(default=False),
+    _: str = Depends(verify_token),
+) -> StreamingResponse:
+    """Sync data backward (prod->int, int->dev) with SSE streaming."""
+    if (from_env, to_env) not in _VALID_SYNC_PAIRS:
+        raise HTTPException(
+            status_code=400,
+            detail=f"Invalid sync path '{from_env} -> {to_env}'. Only adjacent pairs are allowed: prod->int, int->dev.",
+        )
+    return StreamingResponse(
+        _sync_generator(project, from_env, to_env, db_only, uploads_only),
+        media_type="text/event-stream",
+        headers={
+            "Cache-Control": "no-cache",
+            "X-Accel-Buffering": "no",
+        },
+    )
diff --git a/app/routers/system.py b/app/routers/system.py
index a9f15ce..27a8e16 100644
--- a/app/routers/system.py
+++ b/app/routers/system.py
@@ -6,7 +6,7 @@
 from fastapi import APIRouter, Depends, HTTPException
 
 from app.auth import verify_token
-from app.ops_runner import run_command, run_ops
+from app.ops_runner import run_command, run_command_host, run_ops, run_ops_host
 
 router = APIRouter()
 
@@ -54,31 +54,19 @@
 
 
 def _parse_timers_output(raw: str) -> list[dict[str, str]]:
-    """Parse `systemctl list-timers` output into timer dicts."""
+    """Parse `systemctl list-timers` by anchoring on timestamp and .timer patterns."""
     timers: list[dict[str, str]] = []
-    lines = raw.strip().splitlines()
-    if not lines:
-        return timers
-
-    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:]:
-        line = line.strip()
-        if not line or line.startswith("timers listed") or line.startswith("To show"):
-            continue
-        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 "",
-            })
-        elif parts:
-            timers.append({"unit": parts[0], "next": "", "left": "", "last": "", "passed": "", "activates": ""})
+    # Timestamp pattern: "Day YYYY-MM-DD HH:MM:SS TZ" or "-"
+    ts = r"(?:\w{3} \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \w+|-)"
+    timer_re = re.compile(
+        rf"^(?P<next>{ts})\s+(?P<left>.+?)\s+"
+        rf"(?P<last>{ts})\s+(?P<passed>.+?)\s+"
+        r"(?P<unit>\S+\.timer)\s+(?P<activates>\S+)"
+    )
+    for line in raw.strip().splitlines():
+        m = timer_re.match(line)
+        if m:
+            timers.append({k: v.strip() for k, v in m.groupdict().items()})
     return timers
 
 
@@ -156,8 +144,8 @@
 async def health_check(
     _: str = Depends(verify_token),
 ) -> dict[str, Any]:
-    """Returns health check results via `ops health`."""
-    result = await run_ops(["health"])
+    """Returns health check results via `ops health` on the host."""
+    result = await run_ops_host(["health"])
     if not result["success"] and not result["output"].strip():
         raise HTTPException(status_code=500, detail=f"Failed to run health checks: {result['error']}")
     return {
@@ -170,8 +158,8 @@
 async def list_timers(
     _: str = Depends(verify_token),
 ) -> dict[str, Any]:
-    """Lists systemd timers."""
-    result = await run_command(["systemctl", "list-timers", "--no-pager"])
+    """Lists systemd timers via nsenter on the host."""
+    result = await run_command_host(["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']}")
     return {
@@ -185,13 +173,12 @@
     _: str = Depends(verify_token),
 ) -> dict[str, Any]:
     """
-    Returns system uptime, load average, CPU usage, memory, and swap.
+    Returns system uptime, 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 = ""
 
     # Uptime
     try:
@@ -201,14 +188,6 @@
             hours = int((seconds_up % 86400) // 3600)
             minutes = int((seconds_up % 3600) // 60)
             uptime_str = f"{days}d {hours}h {minutes}m"
-    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 Exception:
         pass
 
@@ -233,21 +212,41 @@
     # Memory + Swap
     mem_info = _read_memory()
 
-    # Fallback for uptime/load if /proc wasn't available
-    if not uptime_str or not load_str:
+    # Container count
+    containers_str = ""
+    try:
+        result = await run_command(["docker", "ps", "--format", "{{.State}}"])
+        if result["success"]:
+            states = [s for s in result["output"].strip().splitlines() if s]
+            running = sum(1 for s in states if s == "running")
+            containers_str = f"{running}/{len(states)}"
+    except Exception:
+        pass
+
+    # Process count
+    processes = 0
+    try:
+        with open("/proc/loadavg") as f:
+            parts = f.read().split()
+            if len(parts) >= 4:
+                # /proc/loadavg field 4 is "running/total" processes
+                processes = int(parts[3].split("/")[1])
+    except Exception:
+        pass
+
+    # Fallback for uptime if /proc wasn't available
+    if not uptime_str:
         result = await run_command(["uptime"])
         if result["success"]:
             raw = result["output"].strip()
             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()
-            load_match = re.search(r"load average[s]?:\s*(.+)$", raw, re.IGNORECASE)
-            if load_match:
-                load_str = load_str or load_match.group(1).strip()
+                uptime_str = up_match.group(1).strip()
 
     return {
         "uptime": uptime_str or "unavailable",
-        "load": load_str or "unavailable",
         "cpu": cpu_info or None,
+        "containers": containers_str or "n/a",
+        "processes": processes or 0,
         **mem_info,
     }
diff --git a/docker-compose.yml b/docker-compose.yml
index 696f160..7999f59 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -8,6 +8,7 @@
       - /opt/infrastructure:/opt/infrastructure
       - /opt/data:/opt/data
       - /var/run/docker.sock:/var/run/docker.sock
+      - ./static:/app/static
     labels:
       - "traefik.enable=true"
       - "traefik.http.routers.ops-dashboard.rule=Host(`cockpit.tekmidian.com`)"
diff --git a/static/css/style.css b/static/css/style.css
index d3deff7..9b1a219 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -401,3 +401,127 @@
   overflow: hidden;
 }
 .mono { font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; }
+
+/* ---------- Backup Selection Bar ---------- */
+.selection-bar {
+  display: flex;
+  align-items: center;
+  gap: 1rem;
+  padding: 0.75rem 1.25rem;
+  margin-bottom: 0.75rem;
+  background: rgba(59,130,246,0.1);
+  border: 1px solid rgba(59,130,246,0.3);
+  border-radius: 0.5rem;
+  font-size: 0.875rem;
+  color: #93c5fd;
+  animation: fadeIn 0.15s ease-out;
+}
+.selection-bar span {
+  font-weight: 600;
+  margin-right: 0.5rem;
+}
+.selection-bar .btn {
+  margin-left: 0.5rem;
+}
+
+/* ---------- Backup Date Groups ---------- */
+.date-group {
+  margin-bottom: 0.75rem;
+}
+
+.date-group-header {
+  display: flex;
+  align-items: center;
+  gap: 0.75rem;
+  padding: 0.75rem 1rem;
+  background: #1f2937;
+  border: 1px solid #374151;
+  border-radius: 0.5rem;
+  cursor: pointer;
+  user-select: none;
+  transition: background 0.15s, border-color 0.15s;
+}
+.date-group-header:hover {
+  background: #263244;
+  border-color: #4b5563;
+}
+
+.date-group-header .chevron {
+  color: #6b7280;
+  font-size: 0.625rem;
+  transition: transform 0.2s ease;
+  flex-shrink: 0;
+  display: inline-block;
+}
+.date-group-header .chevron.open {
+  transform: rotate(90deg);
+  color: #60a5fa;
+}
+
+.date-group-title {
+  font-weight: 600;
+  color: #f3f4f6;
+  font-size: 0.9375rem;
+  flex: 1;
+}
+
+.date-group-meta {
+  font-size: 0.8125rem;
+  color: #9ca3af;
+  white-space: nowrap;
+}
+
+.date-group-size {
+  font-size: 0.8125rem;
+  color: #6b7280;
+  white-space: nowrap;
+}
+
+.date-group-body {
+  display: none;
+  margin-top: 0.25rem;
+  border-radius: 0 0 0.5rem 0.5rem;
+  overflow: hidden;
+}
+.date-group-body.open {
+  display: block;
+}
+
+/* ---------- Backup Location Badges ---------- */
+.badge-local {
+  background: rgba(16,185,129,0.12);
+  color: #34d399;
+  border: 1px solid rgba(52,211,153,0.25);
+}
+.badge-offsite {
+  background: rgba(139,92,246,0.12);
+  color: #a78bfa;
+  border: 1px solid rgba(167,139,250,0.25);
+}
+.badge-synced {
+  background: linear-gradient(90deg, rgba(16,185,129,0.15) 0%, rgba(139,92,246,0.15) 100%);
+  color: #a3e8d0;
+  border: 1px solid rgba(100,200,180,0.3);
+  text-transform: uppercase;
+  font-size: 0.7rem;
+  letter-spacing: 0.04em;
+}
+
+/* ---------- Restore modal info rows ---------- */
+.restore-info-row {
+  display: flex;
+  align-items: baseline;
+  gap: 0.75rem;
+  margin-bottom: 0.625rem;
+  font-size: 0.875rem;
+}
+.restore-info-label {
+  color: #9ca3af;
+  font-size: 0.8125rem;
+  font-weight: 500;
+  min-width: 5rem;
+}
+.restore-info-value {
+  color: #f3f4f6;
+  font-weight: 600;
+}
diff --git a/static/index.html b/static/index.html
index 7d7d251..d51b7bf 100644
--- a/static/index.html
+++ b/static/index.html
@@ -5,7 +5,7 @@
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>OPS Dashboard</title>
   <script src="https://cdn.tailwindcss.com"></script>
-  <link rel="stylesheet" href="/static/css/style.css">
+  <link rel="stylesheet" href="/static/css/style.css?v=10">
   <style>
     body { background: #0f172a; color: #e2e8f0; margin: 0; }
     #app { display: flex; min-height: 100vh; }
@@ -79,13 +79,13 @@
         <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="operations" onclick="showPage('operations')">
+        <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>
+        Operations
+      </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
-      </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>
     <div class="sidebar-footer" id="sidebar-footer">
@@ -139,6 +139,83 @@
   </div>
 </div>
 
-<script src="/static/js/app.js?v=4"></script>
+<!-- Restore Modal -->
+<div id="restore-modal" class="modal-overlay" style="display:none;" onclick="if(event.target===this)closeRestoreModal()">
+  <div class="modal-box" style="max-width:640px;">
+    <div class="modal-header">
+      <span style="font-weight:600;color:#f3f4f6;">Restore Backup</span>
+      <button onclick="closeRestoreModal()" style="background:none;border:none;color:#9ca3af;font-size:1.25rem;cursor:pointer;">&times;</button>
+    </div>
+    <div class="modal-body">
+      <!-- Info rows -->
+      <div class="restore-info-row">
+        <span class="restore-info-label">Target</span>
+        <span class="restore-info-value" id="restore-modal-project"></span>
+      </div>
+      <div class="restore-info-row" id="restore-source-row">
+        <span class="restore-info-label">Source</span>
+        <span class="restore-info-value" id="restore-modal-source"></span>
+      </div>
+      <div id="restore-source-selector" style="display:none;margin-bottom:0.625rem;">
+        <div style="display:flex;align-items:baseline;gap:0.75rem;font-size:0.875rem;">
+          <span class="restore-info-label">Source</span>
+          <label style="display:flex;align-items:center;gap:0.375rem;color:#d1d5db;cursor:pointer;">
+            <input type="radio" name="restore-source" value="local" checked style="accent-color:#3b82f6;"> Local
+          </label>
+          <label style="display:flex;align-items:center;gap:0.375rem;color:#d1d5db;cursor:pointer;">
+            <input type="radio" name="restore-source" value="offsite" style="accent-color:#3b82f6;"> Offsite
+          </label>
+        </div>
+      </div>
+      <div class="restore-info-row" style="margin-bottom:1rem;">
+        <span class="restore-info-label">Backup</span>
+        <span class="restore-info-value mono" id="restore-modal-name" style="font-size:0.8125rem;word-break:break-all;"></span>
+      </div>
+
+<!-- Restore mode selector -->      <div style="margin-bottom:1rem;">        <div style="font-size:0.8125rem;font-weight:500;color:#9ca3af;margin-bottom:0.5rem;">Restore Mode</div>        <div style="display:flex;gap:1rem;">          <label style="display:flex;align-items:center;gap:0.375rem;font-size:0.875rem;color:#d1d5db;cursor:pointer;">            <input type="radio" name="restore-mode" value="full" checked style="accent-color:#3b82f6;"> Full          </label>          <label style="display:flex;align-items:center;gap:0.375rem;font-size:0.875rem;color:#d1d5db;cursor:pointer;">            <input type="radio" name="restore-mode" value="db" style="accent-color:#3b82f6;"> Database only          </label>          <label style="display:flex;align-items:center;gap:0.375rem;font-size:0.875rem;color:#d1d5db;cursor:pointer;">            <input type="radio" name="restore-mode" value="wp" style="accent-color:#3b82f6;"> WP-Content only          </label>        </div>      </div>      <!-- Dry run checkbox -->      <label style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;color:#d1d5db;cursor:pointer;margin-bottom:1rem;">        <input type="checkbox" id="restore-dry-run" checked style="width:1rem;height:1rem;accent-color:#3b82f6;">        Dry run (preview only — no changes made)      </label>      <!-- Warning -->
+      <div style="background:rgba(220,38,38,0.1);border:1px solid rgba(220,38,38,0.3);border-radius:0.5rem;padding:0.75rem 1rem;font-size:0.8125rem;color:#fca5a5;margin-bottom:1rem;">
+        Warning: a real restore will stop services, replace data, and restart containers.
+        Always run a dry run first.
+      </div>
+
+      <!-- SSE output (shown after start) -->
+      <div id="restore-modal-output" style="display:none;">
+        <div style="font-size:0.8125rem;font-weight:500;color:#9ca3af;margin-bottom:0.375rem;">Output</div>
+        <div id="restore-modal-terminal" class="terminal" style="max-height:300px;"></div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <button class="btn btn-ghost btn-sm" onclick="closeRestoreModal()">Cancel</button>
+      <button id="restore-start-btn" class="btn btn-danger btn-sm" onclick="startRestore()">Start Restore</button>
+    </div>
+  </div>
+</div>
+
+<!-- Operations Modal -->
+<div id="ops-modal" class="modal-overlay" style="display:none;" onclick="if(event.target===this)closeOpsModal()">
+  <div class="modal-box" style="max-width:700px;">
+    <div class="modal-header">
+      <span id="ops-modal-title" style="font-weight:600;color:#f3f4f6;">Operation</span>
+      <button onclick="closeOpsModal()" style="background:none;border:none;color:#9ca3af;font-size:1.25rem;cursor:pointer;">&times;</button>
+    </div>
+    <div class="modal-body">
+      <div id="ops-modal-info" style="margin-bottom:1rem;"></div>
+      <label id="ops-dry-run-row" style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;color:#d1d5db;cursor:pointer;margin-bottom:1rem;">
+        <input type="checkbox" id="ops-dry-run" checked style="width:1rem;height:1rem;accent-color:#3b82f6;">
+        Dry run (preview only)
+      </label>
+      <div id="ops-modal-output" style="display:none;">
+        <div style="font-size:0.8125rem;font-weight:500;color:#9ca3af;margin-bottom:0.375rem;">Output</div>
+        <div id="ops-modal-terminal" class="terminal" style="max-height:350px;"></div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <button class="btn btn-ghost btn-sm" onclick="closeOpsModal()">Cancel</button>
+      <button id="ops-start-btn" class="btn btn-primary btn-sm" onclick="startOperation()">Start</button>
+    </div>
+  </div>
+</div>
+
+<script src="/static/js/app.js?v=12"></script>
 </body>
 </html>
diff --git a/static/js/app.js b/static/js/app.js
index 9facb3a..ee4c5e8 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -1,8 +1,8 @@
 'use strict';
-const APP_VERSION = 'v4-20260222';
+const APP_VERSION = 'v13-20260222';
 
 // ============================================================
-// OPS Dashboard — Vanilla JS Application (v4)
+// OPS Dashboard — Vanilla JS Application (v6)
 // ============================================================
 
 // ---------------------------------------------------------------------------
@@ -19,12 +19,25 @@
 let refreshTimer = null;
 const REFRESH_INTERVAL = 30000;
 
-// Backup filter state
-let backupFilterProject = null;  // null = all
-let backupFilterEnv = null;      // null = all
+// Backup drill-down state
+let backupDrillLevel = 0;      // 0=projects, 1=environments, 2=backup list
+let backupDrillProject = null;
+let backupDrillEnv = null;
+let cachedBackups = null;       // merged array, fetched once per page visit
 
 // Log modal state
 let logCtx = { project: null, env: null, service: null };
+
+// Restore modal state
+let restoreCtx = { project: null, env: null, source: null };
+let restoreEventSource = null;
+
+// Backup multi-select state
+let selectedBackups = new Set();
+// Operations state
+let opsEventSource = null;
+let opsCtx = { type: null, project: null, fromEnv: null, toEnv: null };
+let cachedRegistry = null;
 
 // ---------------------------------------------------------------------------
 // Helpers
@@ -109,7 +122,7 @@
       document.getElementById('login-overlay').style.display = 'none';
       document.getElementById('app').style.display = 'flex';
       const vEl = document.getElementById('app-version'); if (vEl && typeof APP_VERSION !== 'undefined') vEl.textContent = APP_VERSION;
-      showPage('dashboard');
+      navigateToHash();
       startAutoRefresh();
     })
     .catch(() => { err.textContent = 'Invalid token.'; err.style.display = 'block'; });
@@ -161,6 +174,8 @@
 function showPage(page) {
   currentPage = page;
   drillLevel = 0; drillProject = null; drillEnv = null;
+  backupDrillLevel = 0; backupDrillProject = null; backupDrillEnv = null;
+  cachedBackups = null;
   if (page !== 'dashboard') { viewMode = 'cards'; tableFilter = null; tableFilterLabel = ''; }
 
   document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el =>
@@ -169,6 +184,7 @@
   document.getElementById('mobile-overlay').classList.remove('open');
 
   renderPage();
+  pushHash();
 }
 
 function renderPage() {
@@ -180,13 +196,14 @@
     case 'dashboard': renderDashboard(); break;
     case 'backups':   renderBackups(); break;
     case 'system':    renderSystem(); break;
-    case 'restore':   renderRestore(); break;
+    case 'operations': renderOperations(); break;
     default:          renderDashboard();
   }
 }
 
 function refreshCurrentPage() {
   showSpin();
+  cachedBackups = null;
   fetchStatus().then(() => renderPage()).catch(e => toast('Refresh failed: ' + e.message, 'error')).finally(hideSpin);
 }
 
@@ -198,6 +215,7 @@
   if (mode === 'cards') { tableFilter = null; tableFilterLabel = ''; }
   updateViewToggle();
   renderDashboard();
+  pushHash();
 }
 
 function setTableFilter(filter, label) {
@@ -206,6 +224,7 @@
   viewMode = 'table';
   updateViewToggle();
   renderDashboard();
+  pushHash();
 }
 
 function clearFilter() {
@@ -261,9 +280,18 @@
     } else if (drillLevel === 2) {
       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 = { backups: 'Backups', system: 'System', restore: 'Restore' };
-    h = '<span class="current">' + (names[currentPage] || currentPage) + '</span>';
+  } else if (currentPage === 'backups') {
+    if (backupDrillLevel === 0) {
+      h = '<span class="current">Backups</span>';
+    } else if (backupDrillLevel === 1) {
+      h = '<a onclick="backupDrillBack(0)">Backups</a><span class="sep">/</span><span class="current">' + esc(backupDrillProject) + '</span>';
+    } else if (backupDrillLevel === 2) {
+      h = '<a onclick="backupDrillBack(0)">Backups</a><span class="sep">/</span><a onclick="backupDrillBack(1)">' + esc(backupDrillProject) + '</a><span class="sep">/</span><span class="current">' + esc(backupDrillEnv) + '</span>';
+    }
+  } else if (currentPage === 'system') {
+    h = '<span class="current">System</span>';
+  } else if (currentPage === 'operations') {
+    h = '<span class="current">Operations</span>';
   }
   bc.innerHTML = h;
 }
@@ -272,6 +300,7 @@
   if (level === 0) { drillLevel = 0; drillProject = null; drillEnv = null; }
   else if (level === 1) { drillLevel = 1; drillEnv = null; }
   renderDashboard();
+  pushHash();
 }
 
 // ---------------------------------------------------------------------------
@@ -357,8 +386,8 @@
   c.innerHTML = h;
 }
 
-function drillToProject(name) { drillProject = name; drillLevel = 1; renderDashboard(); }
-function drillToEnv(name) { drillEnv = name; drillLevel = 2; renderDashboard(); }
+function drillToProject(name) { drillProject = name; drillLevel = 1; renderDashboard(); pushHash(); }
+function drillToEnv(name) { drillEnv = name; drillLevel = 2; renderDashboard(); pushHash(); }
 
 // ---------------------------------------------------------------------------
 // Dashboard — Table View
@@ -463,7 +492,7 @@
 }
 
 // ---------------------------------------------------------------------------
-// Backups
+// Backups — helpers
 // ---------------------------------------------------------------------------
 function fmtBackupDate(raw) {
   if (!raw) return '\u2014';
@@ -474,96 +503,510 @@
   return raw;
 }
 
+// Parse YYYYMMDD_HHMMSS -> { dateKey: 'YYYY-MM-DD', timeStr: 'HH:MM' }
+function parseBackupDate(raw) {
+  if (!raw) return { dateKey: '', timeStr: '' };
+  const m = String(raw).match(/^(\d{4})(\d{2})(\d{2})[_T](\d{2})(\d{2})/);
+  if (m) return { dateKey: `${m[1]}-${m[2]}-${m[3]}`, timeStr: `${m[4]}:${m[5]}` };
+  return { dateKey: raw, timeStr: '' };
+}
+
+// Format a YYYY-MM-DD key into a friendly group header label
+function fmtGroupHeader(dateKey) {
+  if (!dateKey) return 'Unknown Date';
+  const d = new Date(dateKey + 'T00:00:00');
+  const today = new Date(); today.setHours(0, 0, 0, 0);
+  const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1);
+  const targetDay = new Date(dateKey + 'T00:00:00'); targetDay.setHours(0, 0, 0, 0);
+
+  const longFmt = d.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'short', day: 'numeric' });
+
+  if (targetDay.getTime() === today.getTime()) return 'Today \u2014 ' + longFmt;
+  if (targetDay.getTime() === yesterday.getTime()) return 'Yesterday \u2014 ' + longFmt;
+  return longFmt;
+}
+
+// Toggle a date group open/closed
+function toggleDateGroup(dateKey) {
+  const body = document.getElementById('dg-body-' + dateKey);
+  const chevron = document.getElementById('dg-chevron-' + dateKey);
+  if (!body) return;
+  const isOpen = body.classList.contains('open');
+  body.classList.toggle('open', !isOpen);
+  if (chevron) chevron.classList.toggle('open', !isOpen);
+}
+
+// ---------------------------------------------------------------------------
+// Backups — merge helper (dedup local+offsite by filename)
+// ---------------------------------------------------------------------------
+function mergeBackups(local, offsite) {
+  const byName = new Map();
+
+  for (const b of local) {
+    const name = b.name || b.file || '';
+    const key = name || (b.project + '/' + b.env + '/' + (b.date || b.timestamp));
+    byName.set(key, {
+      project: b.project || '',
+      env: b.env || b.environment || '',
+      name: name,
+      date: b.date || b.timestamp || '',
+      size_human: b.size_human || b.size || '',
+      size_bytes: Number(b.size || 0),
+      hasLocal: true,
+      hasOffsite: false,
+    });
+  }
+
+  for (const b of offsite) {
+    const name = b.name || '';
+    const key = name || (b.project + '/' + b.env + '/' + (b.date || ''));
+    if (byName.has(key)) {
+      byName.get(key).hasOffsite = true;
+    } else {
+      byName.set(key, {
+        project: b.project || '',
+        env: b.env || b.environment || '',
+        name: name,
+        date: b.date || '',
+        size_human: b.size || '',
+        size_bytes: Number(b.size_bytes || 0),
+        hasLocal: false,
+        hasOffsite: true,
+      });
+    }
+  }
+
+  return Array.from(byName.values());
+}
+
+// ---------------------------------------------------------------------------
+// Backups — main render (v7: drill-down)
+// ---------------------------------------------------------------------------
 async function renderBackups() {
   updateBreadcrumbs();
   const c = document.getElementById('page-content');
   try {
-    const [local, offsite] = await Promise.all([
-      api('/api/backups/'),
-      api('/api/backups/offsite').catch(() => []),
-    ]);
-
-    // Apply filters
-    const filteredLocal = local.filter(b => {
-      if (backupFilterProject && b.project !== backupFilterProject) return false;
-      if (backupFilterEnv && (b.env || b.environment || '') !== backupFilterEnv) return false;
-      return true;
-    });
-    const filteredOffsite = offsite.filter(b => {
-      if (backupFilterProject && b.project !== backupFilterProject) return false;
-      if (backupFilterEnv && (b.env || b.environment || '') !== backupFilterEnv) return false;
-      return true;
-    });
-
-    let h = '<div class="page-enter">';
-
-    // Quick backup buttons
-    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>`;
-      }
-    }
-    h += '</div></div>';
-
-    // Filter bar
-    const activeStyle = 'background:rgba(59,130,246,0.2);color:#60a5fa;';
-    h += '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;margin-bottom:1.5rem;padding:0.75rem 1rem;background:#1f2937;border-radius:0.5rem;">';
-    h += '<span style="color:#9ca3af;font-size:0.875rem;margin-right:0.25rem;">Project:</span>';
-    h += `<button class="btn btn-ghost btn-xs" style="${backupFilterProject === null ? activeStyle : ''}" onclick="setBackupFilter('project',null)">All</button>`;
-    h += `<button class="btn btn-ghost btn-xs" style="${backupFilterProject === 'mdf' ? activeStyle : ''}" onclick="setBackupFilter('project','mdf')">mdf</button>`;
-    h += `<button class="btn btn-ghost btn-xs" style="${backupFilterProject === 'seriousletter' ? activeStyle : ''}" onclick="setBackupFilter('project','seriousletter')">seriousletter</button>`;
-    h += '<span style="color:#374151;margin:0 0.25rem;">|</span>';
-    h += '<span style="color:#9ca3af;font-size:0.875rem;margin-right:0.25rem;">Env:</span>';
-    h += `<button class="btn btn-ghost btn-xs" style="${backupFilterEnv === null ? activeStyle : ''}" onclick="setBackupFilter('env',null)">All</button>`;
-    h += `<button class="btn btn-ghost btn-xs" style="${backupFilterEnv === 'dev' ? activeStyle : ''}" onclick="setBackupFilter('env','dev')">dev</button>`;
-    h += `<button class="btn btn-ghost btn-xs" style="${backupFilterEnv === 'int' ? activeStyle : ''}" onclick="setBackupFilter('env','int')">int</button>`;
-    h += `<button class="btn btn-ghost btn-xs" style="${backupFilterEnv === 'prod' ? activeStyle : ''}" onclick="setBackupFilter('env','prod')">prod</button>`;
-    h += '</div>';
-
-    // Local
-    h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Local Backups</h2>';
-    if (filteredLocal.length === 0) {
-      h += '<div class="card" style="color:#6b7280;">No local backups match the current filter.</div>';
-    } else {
-      h += '<div class="table-wrapper"><table class="ops-table"><thead><tr><th>Project</th><th>Env</th><th>File</th><th>Date</th><th>Size</th></tr></thead><tbody>';
-      for (const b of filteredLocal) {
-        h += `<tr>
-          <td>${esc(b.project||'')}</td>
-          <td><span class="badge badge-blue">${esc(b.env||b.environment||'')}</span></td>
-          <td class="mono" style="font-size:0.8125rem;">${esc(b.name||b.file||'')}</td>
-          <td>${esc(fmtBackupDate(b.date||b.timestamp||''))}</td>
-          <td>${esc(b.size_human||b.size||'')}</td>
-        </tr>`;
-      }
-      h += '</tbody></table></div>';
+    if (!cachedBackups) {
+      const [local, offsite] = await Promise.all([
+        api('/api/backups/'),
+        api('/api/backups/offsite').catch(() => []),
+      ]);
+      cachedBackups = mergeBackups(local, offsite);
     }
 
-    // Offsite
-    h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin:1.5rem 0 0.75rem;">Offsite Backups</h2>';
-    if (filteredOffsite.length === 0) {
-      h += '<div class="card" style="color:#6b7280;">No offsite backups match the current filter.</div>';
-    } else {
-      h += '<div class="table-wrapper"><table class="ops-table"><thead><tr><th>Project</th><th>Env</th><th>File</th><th>Date</th><th>Size</th></tr></thead><tbody>';
-      for (const b of filteredOffsite) {
-        h += `<tr>
-          <td>${esc(b.project||'')}</td>
-          <td><span class="badge badge-blue">${esc(b.env||b.environment||'')}</span></td>
-          <td class="mono" style="font-size:0.8125rem;">${esc(b.name||'')}</td>
-          <td>${esc(fmtBackupDate(b.date||''))}</td>
-          <td>${esc(b.size||'')}</td>
-        </tr>`;
-      }
-      h += '</tbody></table></div>';
-    }
-
-    h += '</div>';
-    c.innerHTML = h;
+    if (backupDrillLevel === 0) renderBackupProjects(c);
+    else if (backupDrillLevel === 1) renderBackupEnvironments(c);
+    else renderBackupList(c);
   } catch (e) {
     c.innerHTML = '<div class="card" style="color:#f87171;">Failed to load backups: ' + esc(e.message) + '</div>';
   }
+}
+
+// ---------------------------------------------------------------------------
+// Backups — Level 0: Project cards
+// ---------------------------------------------------------------------------
+function renderBackupProjects(c) {
+  const all = cachedBackups;
+  const localCount = all.filter(b => b.hasLocal).length;
+  const offsiteCount = all.filter(b => b.hasOffsite).length;
+  const syncedCount = all.filter(b => b.hasLocal && b.hasOffsite).length;
+  let latestTs = '';
+  for (const b of all) { if (b.date > latestTs) latestTs = b.date; }
+  const latestDisplay = latestTs ? fmtBackupDate(latestTs) : '\u2014';
+
+  let h = '<div class="page-enter">';
+
+  // Create Backup buttons
+  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>`;
+    }
+  }
+  h += '</div></div>';
+
+  // Global stat tiles
+  h += '<div class="grid-stats" style="margin-bottom:1.5rem;">';
+  h += statTile('Local', localCount, '#3b82f6');
+  h += statTile('Offsite', offsiteCount, '#8b5cf6');
+  h += statTile('Synced', syncedCount, '#10b981');
+  h += statTile('Latest', latestDisplay, '#f59e0b');
+  h += '</div>';
+
+  // Project cards
+  const projects = groupBy(all, 'project');
+  h += '<div class="grid-auto">';
+  for (const [name, backups] of Object.entries(projects)) {
+    const envs = [...new Set(backups.map(b => b.env))].sort();
+    let projLatest = '';
+    for (const b of backups) { if (b.date > projLatest) projLatest = b.date; }
+    const projSize = backups.reduce((acc, b) => acc + (b.size_bytes || 0), 0);
+
+    h += `<div class="card card-clickable" onclick="backupDrillToProject('${esc(name)}')">
+      <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
+        <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;">${backups.length} backup${backups.length !== 1 ? 's' : ''}</span>
+      </div>
+      <div style="display:flex;flex-wrap:wrap;gap:0.375rem;margin-bottom:0.5rem;">
+        ${envs.map(e => `<span class="badge badge-blue">${esc(e)}</span>`).join('')}
+      </div>
+      <div style="font-size:0.8125rem;color:#9ca3af;">
+        Latest: ${projLatest ? fmtBackupDate(projLatest) : '\u2014'}
+        ${projSize > 0 ? ' &middot; ' + fmtBytes(projSize) : ''}
+      </div>
+    </div>`;
+  }
+  h += '</div></div>';
+  c.innerHTML = h;
+}
+
+// ---------------------------------------------------------------------------
+// Backups — Level 1: Environment cards for a project
+// ---------------------------------------------------------------------------
+function renderBackupEnvironments(c) {
+  const projBackups = cachedBackups.filter(b => b.project === backupDrillProject);
+  const envGroups = groupBy(projBackups, 'env');
+  const envOrder = ['dev', 'int', 'prod'];
+  const sortedEnvs = Object.keys(envGroups).sort((a, b) => {
+    const ai = envOrder.indexOf(a), bi = envOrder.indexOf(b);
+    return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
+  });
+
+  let h = '<div class="page-enter"><div class="grid-auto">';
+  for (const envName of sortedEnvs) {
+    const backups = envGroups[envName];
+    const count = backups.length;
+    let envLatest = '';
+    for (const b of backups) { if (b.date > envLatest) envLatest = b.date; }
+    const envSize = backups.reduce((acc, b) => acc + (b.size_bytes || 0), 0);
+    const ep = esc(backupDrillProject), ee = esc(envName);
+
+    // Restore button logic
+    let restoreBtn = '';
+    if (count === 0) {
+      restoreBtn = `<button class="btn btn-danger btn-xs" disabled>Restore</button>`;
+    } else if (count === 1) {
+      const b = backups[0];
+      const src = b.hasLocal ? 'local' : 'offsite';
+      restoreBtn = `<button class="btn btn-danger btn-xs" onclick="event.stopPropagation();openRestoreModal('${ep}','${ee}','${src}','${esc(b.name)}')">Restore</button>`;
+    } else {
+      restoreBtn = `<button class="btn btn-danger btn-xs" onclick="event.stopPropagation();backupDrillToEnv('${ee}')">Restore (${count})</button>`;
+    }
+
+    h += `<div class="card card-clickable" onclick="backupDrillToEnv('${ee}')">
+      <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
+        <span style="font-weight:600;font-size:1.0625rem;color:#f3f4f6;">${ee.toUpperCase()}</span>
+        <span style="margin-left:auto;font-size:0.8125rem;color:#6b7280;">${count} backup${count !== 1 ? 's' : ''}</span>
+      </div>
+      <div style="font-size:0.8125rem;color:#9ca3af;margin-bottom:0.75rem;">
+        Latest: ${envLatest ? fmtBackupDate(envLatest) : '\u2014'}
+        ${envSize > 0 ? ' &middot; ' + fmtBytes(envSize) : ''}
+      </div>
+      <div style="display:flex;gap:0.5rem;">
+        <button class="btn btn-ghost btn-xs" onclick="event.stopPropagation();createBackup('${ep}','${ee}')">Create Backup</button>
+        ${restoreBtn}
+      </div>
+    </div>`;
+  }
+  h += '</div></div>';
+  c.innerHTML = h;
+}
+
+// ---------------------------------------------------------------------------
+// Backups — Level 2: Individual backups for project/env
+// ---------------------------------------------------------------------------
+function renderBackupList(c) {
+  const filtered = cachedBackups.filter(b => b.project === backupDrillProject && b.env === backupDrillEnv);
+
+  let h = '<div class="page-enter">';
+
+  // Selection action bar
+  h += `<div id="backup-selection-bar" class="selection-bar" style="display:${selectedBackups.size > 0 ? 'flex' : 'none'};">`;
+  h += `<span id="selection-count">${selectedBackups.size} selected</span>`;
+  h += `<button class="btn btn-danger btn-xs" onclick="deleteSelected()">Delete selected</button>`;
+  h += `<button class="btn btn-ghost btn-xs" onclick="clearSelection()">Clear</button>`;
+  h += `</div>`;
+
+  if (filtered.length === 0) {
+    h += '<div class="card" style="color:#6b7280;">No backups for ' + esc(backupDrillProject) + '/' + esc(backupDrillEnv) + '.</div>';
+  } else {
+    // Group by date key (YYYY-MM-DD), sort descending
+    const groups = {};
+    for (const b of filtered) {
+      const { dateKey, timeStr } = parseBackupDate(b.date);
+      b._dateKey = dateKey;
+      b._timeStr = timeStr;
+      if (!groups[dateKey]) groups[dateKey] = [];
+      groups[dateKey].push(b);
+    }
+
+    const sortedKeys = Object.keys(groups).sort().reverse();
+    const today = new Date(); today.setHours(0, 0, 0, 0);
+    const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1);
+
+    for (const dateKey of sortedKeys) {
+      const items = groups[dateKey].sort((a, b) => b.date.localeCompare(a.date));
+      const groupSizeBytes = items.reduce((acc, b) => acc + (b.size_bytes || 0), 0);
+      const headerLabel = fmtGroupHeader(dateKey);
+      const safeKey = backupDrillProject + backupDrillEnv + dateKey.replace(/-/g, '');
+
+      const targetDay = new Date(dateKey + 'T00:00:00'); targetDay.setHours(0, 0, 0, 0);
+      const isRecent = targetDay.getTime() >= yesterday.getTime();
+
+      h += `<div class="date-group">`;
+      h += `<div class="date-group-header" onclick="toggleDateGroup('${safeKey}')">`;
+      h += `<span class="chevron${isRecent ? ' open' : ''}" id="dg-chevron-${safeKey}">&#9654;</span>`;
+      h += `<span class="date-group-title">${esc(headerLabel)}</span>`;
+      h += `<span class="date-group-meta">${items.length} backup${items.length !== 1 ? 's' : ''}</span>`;
+      if (groupSizeBytes > 0) {
+        h += `<span class="date-group-size">${fmtBytes(groupSizeBytes)}</span>`;
+      }
+      h += `</div>`;
+
+      h += `<div class="date-group-body${isRecent ? ' open' : ''}" id="dg-body-${safeKey}">`;
+      h += `<div class="table-wrapper"><table class="ops-table">`;
+      h += `<thead><tr><th style="width:2rem;padding-left:0.75rem;"><input type="checkbox" onclick="toggleSelectAll(this)" style="accent-color:#3b82f6;cursor:pointer;"></th><th>Location</th><th>Time</th><th>Size</th><th>Actions</th></tr></thead><tbody>`;
+      for (const b of items) {
+        let locationBadge;
+        if (b.hasLocal && b.hasOffsite) {
+          locationBadge = '<span class="badge badge-synced">local + offsite</span>';
+        } else if (b.hasLocal) {
+          locationBadge = '<span class="badge badge-local">local</span>';
+        } else {
+          locationBadge = '<span class="badge badge-offsite">offsite</span>';
+        }
+
+        const restoreSource = b.hasLocal ? 'local' : 'offsite';
+        const checked = selectedBackups.has(b.name) ? ' checked' : '';
+        const deleteBtn = `<button class="btn btn-ghost btn-xs" style="color:#f87171;border-color:#7f1d1d;" onclick="deleteBackup('${esc(b.project)}','${esc(b.env)}','${esc(b.name)}',${b.hasLocal},${b.hasOffsite})">Delete</button>`;
+        const uploadBtn = (b.hasLocal && !b.hasOffsite)
+          ? `<button class="btn btn-ghost btn-xs" style="color:#a78bfa;border-color:rgba(167,139,250,0.25);" onclick="uploadOffsiteBackup('${esc(b.project)}','${esc(b.env)}')">Upload</button>`
+          : '';
+        h += `<tr>
+          <td style="padding-left:0.75rem;"><input type="checkbox" class="backup-cb" value="${esc(b.name)}"${checked} onclick="toggleBackupSelect('${esc(b.name)}')" style="accent-color:#3b82f6;cursor:pointer;"></td>
+          <td>${locationBadge}</td>
+          <td class="mono">${esc(b._timeStr || '\u2014')}</td>
+          <td>${esc(b.size_human || '\u2014')}</td>
+          <td style="white-space:nowrap;">
+            <button class="btn btn-danger btn-xs" onclick="openRestoreModal('${esc(b.project)}','${esc(b.env)}','${restoreSource}','${esc(b.name)}',${b.hasLocal},${b.hasOffsite})">Restore</button>
+            ${uploadBtn}
+            ${deleteBtn}
+          </td>
+        </tr>`;
+      }
+      h += `</tbody></table></div>`;
+      h += `</div>`;
+      h += `</div>`;
+    }
+  }
+
+  h += '</div>';
+  c.innerHTML = h;
+}
+
+// ---------------------------------------------------------------------------
+// Backups — Drill-down navigation
+// ---------------------------------------------------------------------------
+function backupDrillToProject(name) { backupDrillProject = name; backupDrillLevel = 1; selectedBackups.clear(); renderBackups(); pushHash(); }
+function backupDrillToEnv(name) { backupDrillEnv = name; backupDrillLevel = 2; selectedBackups.clear(); renderBackups(); pushHash(); }
+function backupDrillBack(level) {
+  if (level === 0) { backupDrillLevel = 0; backupDrillProject = null; backupDrillEnv = null; }
+  else if (level === 1) { backupDrillLevel = 1; backupDrillEnv = null; }
+  selectedBackups.clear();
+  renderBackups();
+  pushHash();
+}
+
+// ---------------------------------------------------------------------------
+// Backup Multi-Select
+// ---------------------------------------------------------------------------
+function toggleBackupSelect(name) {
+  if (selectedBackups.has(name)) selectedBackups.delete(name);
+  else selectedBackups.add(name);
+  updateSelectionBar();
+}
+
+function toggleSelectAll(masterCb) {
+  const table = masterCb.closest('table');
+  const cbs = table.querySelectorAll('.backup-cb');
+  if (masterCb.checked) {
+    cbs.forEach(cb => { cb.checked = true; selectedBackups.add(cb.value); });
+  } else {
+    cbs.forEach(cb => { cb.checked = false; selectedBackups.delete(cb.value); });
+  }
+  updateSelectionBar();
+}
+
+function clearSelection() {
+  selectedBackups.clear();
+  document.querySelectorAll('.backup-cb').forEach(cb => { cb.checked = false; });
+  document.querySelectorAll('th input[type="checkbox"]').forEach(cb => { cb.checked = false; });
+  updateSelectionBar();
+}
+
+function updateSelectionBar() {
+  const bar = document.getElementById('backup-selection-bar');
+  const count = document.getElementById('selection-count');
+  if (bar) {
+    bar.style.display = selectedBackups.size > 0 ? 'flex' : 'none';
+    if (count) count.textContent = selectedBackups.size + ' selected';
+  }
+}
+
+async function deleteSelected() {
+  const names = [...selectedBackups];
+  if (names.length === 0) return;
+  // Check if any selected backups have both locations
+  const anyBoth = cachedBackups && cachedBackups.some(b => names.includes(b.name) && b.hasLocal && b.hasOffsite);
+  let target = 'local';
+  if (anyBoth) {
+    target = await showDeleteTargetDialog(names.length + ' selected backup(s)');
+    if (!target) return;
+  } else {
+    // Determine if all are offsite-only
+    const allOffsite = cachedBackups && names.every(n => { const b = cachedBackups.find(x => x.name === n); return b && !b.hasLocal && b.hasOffsite; });
+    if (allOffsite) target = 'offsite';
+  }
+  const label = target === 'both' ? 'local + offsite' : target;
+  if (!confirm(`Delete ${names.length} backup${names.length > 1 ? 's' : ''} (${label})?\n\nThis cannot be undone.`)) return;
+  toast(`Deleting ${names.length} backups (${label})...`, 'info');
+  let ok = 0, fail = 0;
+  for (const name of names) {
+    try {
+      await api(`/api/backups/${encodeURIComponent(backupDrillProject)}/${encodeURIComponent(backupDrillEnv)}/${encodeURIComponent(name)}?target=${target}`, { method: 'DELETE' });
+      ok++;
+    } catch (_) { fail++; }
+  }
+  selectedBackups.clear();
+  cachedBackups = null;
+  toast(`Deleted ${ok}${fail > 0 ? ', ' + fail + ' failed' : ''}`, fail > 0 ? 'warning' : 'success');
+  if (currentPage === 'backups') renderBackups();
+}
+
+async function uploadOffsiteBackup(project, env) {
+  if (!confirm(`Upload latest ${project}/${env} backup to offsite storage?`)) return;
+  toast('Uploading to offsite...', 'info');
+  try {
+    await api(`/api/backups/offsite/upload/${encodeURIComponent(project)}/${encodeURIComponent(env)}`, { method: 'POST' });
+    toast('Offsite upload complete for ' + project + '/' + env, 'success');
+    cachedBackups = null;
+    if (currentPage === 'backups') renderBackups();
+  } catch (e) { toast('Upload failed: ' + e.message, 'error'); }
+}
+
+// ---------------------------------------------------------------------------
+// Restore Modal
+// ---------------------------------------------------------------------------
+function openRestoreModal(project, env, source, name, hasLocal, hasOffsite) {
+  restoreCtx = { project, env, source, name, hasLocal: !!hasLocal, hasOffsite: !!hasOffsite };
+
+  // Close any running event source
+  if (restoreEventSource) { restoreEventSource.close(); restoreEventSource = null; }
+
+  // Populate modal info
+  document.getElementById('restore-modal-project').textContent = project + '/' + env;
+  document.getElementById('restore-modal-name').textContent = name || '(latest)';
+  document.getElementById('restore-dry-run').checked = false;
+
+  // Source selector: show radios when both local+offsite, static text otherwise
+  const sourceRow = document.getElementById('restore-source-row');
+  const sourceSelector = document.getElementById('restore-source-selector');
+  if (hasLocal && hasOffsite) {
+    sourceRow.style.display = 'none';
+    sourceSelector.style.display = 'block';
+    document.querySelectorAll('input[name="restore-source"]').forEach(r => {
+      r.checked = r.value === source;
+    });
+  } else {
+    sourceRow.style.display = 'flex';
+    sourceSelector.style.display = 'none';
+    document.getElementById('restore-modal-source').textContent = source;
+  }
+
+  // Reset mode to "full"
+  const modeRadios = document.querySelectorAll('input[name="restore-mode"]');
+  modeRadios.forEach(r => { r.checked = r.value === 'full'; });
+
+  // Reset terminal
+  const term = document.getElementById('restore-modal-terminal');
+  term.textContent = '';
+  document.getElementById('restore-modal-output').style.display = 'none';
+
+  // Enable start button
+  const startBtn = document.getElementById('restore-start-btn');
+  startBtn.disabled = false;
+  startBtn.textContent = 'Start Restore';
+
+  document.getElementById('restore-modal').style.display = 'flex';
+}
+
+function closeRestoreModal() {
+  if (restoreEventSource) { restoreEventSource.close(); restoreEventSource = null; }
+  document.getElementById('restore-modal').style.display = 'none';
+  restoreCtx = { project: null, env: null, source: null, name: null };
+}
+
+function startRestore() {
+  const { project, env, hasLocal, hasOffsite } = restoreCtx;
+  if (!project || !env) return;
+
+  // Determine source: from radio if both available, otherwise from context
+  let source = restoreCtx.source;
+  if (hasLocal && hasOffsite) {
+    const srcEl = document.querySelector('input[name="restore-source"]:checked');
+    if (srcEl) source = srcEl.value;
+  }
+
+  const dryRun = document.getElementById('restore-dry-run').checked;
+  const startBtn = document.getElementById('restore-start-btn');
+
+  // Show terminal
+  const outputDiv = document.getElementById('restore-modal-output');
+  const term = document.getElementById('restore-modal-terminal');
+  outputDiv.style.display = 'block';
+  term.textContent = 'Starting restore...\n';
+
+  startBtn.disabled = true;
+  startBtn.textContent = dryRun ? 'Running preview...' : 'Restoring...';
+
+  const name = restoreCtx.name || '';
+  const modeEl = document.querySelector('input[name="restore-mode"]:checked');
+  const mode = modeEl ? modeEl.value : 'full';
+  const url = `/api/restore/${encodeURIComponent(project)}/${encodeURIComponent(env)}?source=${encodeURIComponent(source)}${dryRun ? '&dry_run=true' : ''}&token=${encodeURIComponent(getToken())}${name ? '&name=' + encodeURIComponent(name) : ''}&mode=${encodeURIComponent(mode)}`;
+  const es = new EventSource(url);
+  restoreEventSource = es;
+
+  es.onmessage = function(e) {
+    try {
+      const d = JSON.parse(e.data);
+      if (d.done) {
+        es.close();
+        restoreEventSource = null;
+        const msg = d.success ? '\n--- Restore complete ---\n' : '\n--- Restore FAILED ---\n';
+        term.textContent += msg;
+        term.scrollTop = term.scrollHeight;
+        toast(d.success ? 'Restore completed' : 'Restore failed', d.success ? 'success' : 'error');
+        startBtn.disabled = false;
+        startBtn.textContent = 'Start Restore';
+        return;
+      }
+      if (d.line) {
+        term.textContent += d.line + '\n';
+        term.scrollTop = term.scrollHeight;
+      }
+    } catch (_) {}
+  };
+
+  es.onerror = function() {
+    es.close();
+    restoreEventSource = null;
+    term.textContent += '\n--- Connection lost ---\n';
+    toast('Connection lost', 'error');
+    startBtn.disabled = false;
+    startBtn.textContent = 'Start Restore';
+  };
 }
 
 // ---------------------------------------------------------------------------
@@ -577,7 +1020,7 @@
       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' })),
+      api('/api/system/info').catch(e => ({ uptime: 'error' })),
     ]);
 
     let h = '<div class="page-enter">';
@@ -614,7 +1057,8 @@
     // 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 += statTile('Containers', info.containers || 'n/a', '#8b5cf6');
+    h += statTile('Processes', info.processes || '0', '#f59e0b');
     h += '</div>';
 
     // Disk usage — only real filesystems
@@ -676,50 +1120,412 @@
 }
 
 // ---------------------------------------------------------------------------
-// Restore
+// Operations Page
 // ---------------------------------------------------------------------------
-function renderRestore() {
+async function renderOperations() {
   updateBreadcrumbs();
   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>';
+
+  // Fetch registry if not cached
+  if (!cachedRegistry) {
+    try {
+      cachedRegistry = await api('/api/registry/');
+    } catch (e) {
+      c.innerHTML = '<div class="card" style="color:#f87171;">Failed to load registry: ' + esc(e.message) + '</div>';
+      return;
+    }
+  }
+
+  const projects = cachedRegistry.projects || {};
+
+  let h = '<div style="max-width:900px;">';
+
+  // Section: Promote Code (Forward)
+  h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Promote Code</h2>';
+  h += '<p style="font-size:0.8125rem;color:#9ca3af;margin-bottom:1rem;">Push code forward: dev &rarr; int &rarr; prod. Each project defines its own promotion type (git pull or rsync).</p>';
+  h += '<div class="grid-auto" style="margin-bottom:2rem;">';
+
+  for (const [name, cfg] of Object.entries(projects)) {
+    if (!cfg.promote || cfg.static || cfg.infrastructure) continue;
+    const pType = cfg.promote.type || 'unknown';
+    const envs = cfg.environments || [];
+    const typeBadge = pType === 'git'
+      ? '<span class="badge badge-blue" style="font-size:0.6875rem;">git</span>'
+      : '<span class="badge badge-purple" style="font-size:0.6875rem;">rsync</span>';
+
+    h += '<div class="card">';
+    h += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem;">';
+    h += '<span style="font-weight:600;color:#f3f4f6;">' + esc(name) + '</span>';
+    h += typeBadge;
+    h += '</div>';
+
+    const promotions = [];
+    if (envs.includes('dev') && envs.includes('int')) promotions.push(['dev', 'int']);
+    if (envs.includes('int') && envs.includes('prod')) promotions.push(['int', 'prod']);
+
+    if (promotions.length === 0) {
+      h += '<div style="font-size:0.8125rem;color:#6b7280;">No promotion paths available</div>';
+    } else {
+      h += '<div style="display:flex;flex-direction:column;gap:0.5rem;">';
+      for (const [from, to] of promotions) {
+        h += '<button class="btn btn-ghost btn-sm" style="justify-content:flex-start;" onclick="openOpsModal(&apos;promote&apos;,&apos;' + esc(name) + '&apos;,&apos;' + esc(from) + '&apos;,&apos;' + esc(to) + '&apos;)">';
+        h += '<span style="color:#60a5fa;">' + esc(from) + '</span>';
+        h += ' <span style="color:#6b7280;">&rarr;</span> ';
+        h += '<span style="color:#fbbf24;">' + esc(to) + '</span>';
+        h += '</button>';
+      }
+      h += '</div>';
+    }
+    h += '</div>';
+  }
   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>';
+
+  // Section: Sync Data (Backward)
+  h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.75rem;">Sync Data</h2>';
+  h += '<p style="font-size:0.8125rem;color:#9ca3af;margin-bottom:1rem;">Sync content between environments. Choose the direction when syncing.</p>';
+  h += '<div class="grid-auto" style="margin-bottom:2rem;">';
+
+  for (const [name, cfg] of Object.entries(projects)) {
+    if (!cfg.has_cli || cfg.static || cfg.infrastructure) continue;
+    const envs = cfg.environments || [];
+
+    h += '<div class="card">';
+    h += '<div style="margin-bottom:0.75rem;font-weight:600;color:#f3f4f6;">' + esc(name) + '</div>';
+
+    const syncPairs = [];
+    if (envs.includes('prod') && envs.includes('int')) syncPairs.push(['prod', 'int']);
+    if (envs.includes('int') && envs.includes('dev')) syncPairs.push(['int', 'dev']);
+
+    if (syncPairs.length === 0) {
+      h += '<div style="font-size:0.8125rem;color:#6b7280;">No sync paths available</div>';
+    } else {
+      h += '<div style="display:flex;flex-direction:column;gap:0.5rem;">';
+      for (const [a, b] of syncPairs) {
+        h += '<button class="btn btn-ghost btn-sm" style="justify-content:flex-start;" onclick="openSyncModal(&apos;' + esc(name) + '&apos;,&apos;' + esc(a) + '&apos;,&apos;' + esc(b) + '&apos;)">';
+        h += '<span style="color:#60a5fa;">' + esc(a) + '</span>';
+        h += ' <span style="color:#6b7280;">&harr;</span> ';
+        h += '<span style="color:#fbbf24;">' + esc(b) + '</span>';
+        h += '</button>';
+      }
+      h += '</div>';
+    }
+    h += '</div>';
+  }
   h += '</div>';
+
+  // Section: Container Lifecycle
+  h += '<h2 style="font-size:1.125rem;font-weight:600;color:#f3f4f6;margin-bottom:0.375rem;">Container Lifecycle</h2>';
+  h += '<p style="font-size:0.8125rem;color:#9ca3af;margin-bottom:1rem;">Manage container state via Coolify API. '
+    + '<span style="color:#6ee7b7;">Restart</span> is safe. '
+    + '<span style="color:#fbbf24;">Rebuild</span> refreshes the image. '
+    + '<span style="color:#f87171;">Recreate</span> wipes data (disaster recovery only).</p>';
+  h += '<div class="grid-auto" style="margin-bottom:2rem;">';
+
+  for (const [name, cfg] of Object.entries(projects)) {
+    if (cfg.static || cfg.infrastructure || !cfg.has_coolify) continue;
+    const envs = (cfg.environments || []).filter(e => e !== 'infra');
+    if (!envs.length) continue;
+
+    h += '<div class="card">';
+    h += '<div style="margin-bottom:0.75rem;font-weight:600;color:#f3f4f6;">' + esc(name) + '</div>';
+    h += '<div style="display:flex;flex-direction:column;gap:0.625rem;">';
+
+    for (const env of envs) {
+      h += '<div style="display:flex;align-items:center;gap:0.5rem;">';
+      // Environment label
+      h += '<span style="min-width:2.5rem;font-size:0.75rem;color:#9ca3af;font-weight:500;">' + esc(env) + '</span>';
+      // Restart (green)
+      h += '<button class="btn btn-ghost btn-xs" style="color:#6ee7b7;border-color:rgba(110,231,179,0.3);" '
+        + 'onclick="openLifecycleModal(&apos;restart&apos;,&apos;' + esc(name) + '&apos;,&apos;' + esc(env) + '&apos;)">'
+        + 'Restart</button>';
+      // Rebuild (yellow)
+      h += '<button class="btn btn-ghost btn-xs" style="color:#fbbf24;border-color:rgba(251,191,36,0.3);" '
+        + 'onclick="openLifecycleModal(&apos;rebuild&apos;,&apos;' + esc(name) + '&apos;,&apos;' + esc(env) + '&apos;)">'
+        + 'Rebuild</button>';
+      // Recreate (red)
+      h += '<button class="btn btn-ghost btn-xs" style="color:#f87171;border-color:rgba(248,113,113,0.3);" '
+        + 'onclick="openLifecycleModal(&apos;recreate&apos;,&apos;' + esc(name) + '&apos;,&apos;' + esc(env) + '&apos;)">'
+        + 'Recreate</button>';
+      h += '</div>';
+    }
+
+    h += '</div></div>';
+  }
+  h += '</div></div>';
+
   c.innerHTML = h;
 }
 
-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)' : ''}?`)) return;
+// ---------------------------------------------------------------------------
+// Operations Modal
+// ---------------------------------------------------------------------------
+function openSyncModal(project, envA, envB) {
+  // Show direction picker in the ops modal
+  opsCtx = { type: 'sync', project: project, fromEnv: envA, toEnv: envB };
 
-  const out = document.getElementById('restore-output');
-  const term = document.getElementById('restore-terminal');
-  out.style.display = 'block';
-  term.textContent = 'Starting restore...\n';
+  if (opsEventSource) { opsEventSource.close(); opsEventSource = null; }
 
-  const url = `/api/restore/${project}/${env}?source=${source}&dry_run=${dryRun}&token=${encodeURIComponent(getToken())}`;
+  const title = document.getElementById('ops-modal-title');
+  const info = document.getElementById('ops-modal-info');
+  const startBtn = document.getElementById('ops-start-btn');
+
+  title.textContent = 'Sync Data';
+
+  let ih = '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>';
+  ih += '<div style="margin-top:0.75rem;margin-bottom:0.25rem;font-size:0.8125rem;color:#9ca3af;">Direction</div>';
+  ih += '<div style="display:flex;flex-direction:column;gap:0.5rem;">';
+  ih += '<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;padding:0.5rem 0.75rem;border-radius:0.5rem;border:1px solid #374151;'
+    + 'background:rgba(96,165,250,0.1);" id="sync-dir-down">';
+  ih += '<input type="radio" name="sync-dir" value="down" checked onchange="updateSyncDir()" style="accent-color:#60a5fa;">';
+  ih += '<span style="color:#60a5fa;font-weight:600;">' + esc(envA) + '</span>';
+  ih += '<span style="color:#6b7280;">&rarr;</span>';
+  ih += '<span style="color:#fbbf24;font-weight:600;">' + esc(envB) + '</span>';
+  ih += '<span style="font-size:0.75rem;color:#6b7280;margin-left:auto;">content flows down</span>';
+  ih += '</label>';
+  ih += '<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;padding:0.5rem 0.75rem;border-radius:0.5rem;border:1px solid #374151;" id="sync-dir-up">';
+  ih += '<input type="radio" name="sync-dir" value="up" onchange="updateSyncDir()" style="accent-color:#fbbf24;">';
+  ih += '<span style="color:#fbbf24;font-weight:600;">' + esc(envB) + '</span>';
+  ih += '<span style="color:#6b7280;">&rarr;</span>';
+  ih += '<span style="color:#60a5fa;font-weight:600;">' + esc(envA) + '</span>';
+  ih += '<span style="font-size:0.75rem;color:#6b7280;margin-left:auto;">content flows up</span>';
+  ih += '</label>';
+  ih += '</div>';
+
+  info.innerHTML = ih;
+  startBtn.className = 'btn btn-primary btn-sm';
+  startBtn.textContent = 'Sync';
+
+  document.getElementById('ops-dry-run').checked = true;
+  document.getElementById('ops-modal-output').style.display = 'none';
+  document.getElementById('ops-modal-terminal').textContent = '';
+  startBtn.disabled = false;
+  document.getElementById('ops-modal').style.display = 'flex';
+}
+
+function updateSyncDir() {
+  const dir = document.querySelector('input[name="sync-dir"]:checked').value;
+  const downLabel = document.getElementById('sync-dir-down');
+  const upLabel = document.getElementById('sync-dir-up');
+  if (dir === 'down') {
+    downLabel.style.background = 'rgba(96,165,250,0.1)';
+    upLabel.style.background = 'transparent';
+    // envA -> envB (default / downward)
+    opsCtx.fromEnv = downLabel.querySelector('span[style*="color:#60a5fa"]').textContent;
+    opsCtx.toEnv = downLabel.querySelector('span[style*="color:#fbbf24"]').textContent;
+  } else {
+    downLabel.style.background = 'transparent';
+    upLabel.style.background = 'rgba(251,191,36,0.1)';
+    // envB -> envA (upward)
+    opsCtx.fromEnv = upLabel.querySelector('span[style*="color:#fbbf24"]').textContent;
+    opsCtx.toEnv = upLabel.querySelector('span[style*="color:#60a5fa"]').textContent;
+  }
+}
+
+function openOpsModal(type, project, fromEnv, toEnv) {
+  opsCtx = { type, project, fromEnv, toEnv };
+
+  if (opsEventSource) { opsEventSource.close(); opsEventSource = null; }
+
+  const title = document.getElementById('ops-modal-title');
+  const info = document.getElementById('ops-modal-info');
+  const startBtn = document.getElementById('ops-start-btn');
+
+  if (type === 'promote') {
+    title.textContent = 'Promote Code';
+    info.innerHTML = '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>'
+      + '<div class="restore-info-row"><span class="restore-info-label">Direction</span><span class="restore-info-value">' + esc(fromEnv) + ' &rarr; ' + esc(toEnv) + '</span></div>';
+    startBtn.className = 'btn btn-primary btn-sm';
+    startBtn.textContent = 'Promote';
+  } else if (type === 'sync') {
+    title.textContent = 'Sync Data';
+    info.innerHTML = '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>'
+      + '<div class="restore-info-row"><span class="restore-info-label">Direction</span><span class="restore-info-value">' + esc(fromEnv) + ' &rarr; ' + esc(toEnv) + '</span></div>';
+    startBtn.className = 'btn btn-primary btn-sm';
+    startBtn.textContent = 'Sync';
+  }
+
+  document.getElementById('ops-dry-run').checked = true;
+  document.getElementById('ops-modal-output').style.display = 'none';
+  document.getElementById('ops-modal-terminal').textContent = '';
+  startBtn.disabled = false;
+
+  document.getElementById('ops-modal').style.display = 'flex';
+}
+
+// ---------------------------------------------------------------------------
+// Lifecycle Modal (Restart / Rebuild / Recreate)
+// ---------------------------------------------------------------------------
+function openLifecycleModal(action, project, env) {
+  opsCtx = { type: action, project, fromEnv: env, toEnv: null };
+
+  if (opsEventSource) { opsEventSource.close(); opsEventSource = null; }
+
+  const title = document.getElementById('ops-modal-title');
+  const info = document.getElementById('ops-modal-info');
+  const startBtn = document.getElementById('ops-start-btn');
+  const dryRunRow = document.getElementById('ops-dry-run-row');
+
+  // Hide the dry-run checkbox — lifecycle ops don't use it
+  if (dryRunRow) dryRunRow.style.display = 'none';
+
+  if (action === 'restart') {
+    title.textContent = 'Restart Containers';
+    info.innerHTML = ''
+      + '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>'
+      + '<div class="restore-info-row"><span class="restore-info-label">Environment</span><span class="restore-info-value">' + esc(env) + '</span></div>'
+      + '<div style="background:rgba(16,185,129,0.08);border:1px solid rgba(16,185,129,0.25);border-radius:0.5rem;padding:0.625rem 0.875rem;font-size:0.8125rem;color:#6ee7b7;margin-top:0.75rem;">'
+      + 'Safe operation. Runs <code>docker restart</code> on each container. No image changes, no data loss.</div>';
+    startBtn.className = 'btn btn-sm';
+    startBtn.style.cssText = 'background:#065f46;color:#6ee7b7;border:1px solid rgba(110,231,179,0.3);';
+    startBtn.textContent = 'Restart';
+
+  } else if (action === 'rebuild') {
+    title.textContent = 'Rebuild Environment';
+    info.innerHTML = ''
+      + '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>'
+      + '<div class="restore-info-row"><span class="restore-info-label">Environment</span><span class="restore-info-value">' + esc(env) + '</span></div>'
+      + '<div style="background:rgba(251,191,36,0.08);border:1px solid rgba(251,191,36,0.25);border-radius:0.5rem;padding:0.625rem 0.875rem;font-size:0.8125rem;color:#fde68a;margin-top:0.75rem;">'
+      + 'Stops containers via Coolify, rebuilds the Docker image, then starts again. No data loss.</div>';
+    startBtn.className = 'btn btn-sm';
+    startBtn.style.cssText = 'background:#78350f;color:#fde68a;border:1px solid rgba(251,191,36,0.3);';
+    startBtn.textContent = 'Rebuild';
+
+  } else if (action === 'recreate') {
+    title.textContent = 'Recreate Environment';
+    info.innerHTML = ''
+      + '<div class="restore-info-row"><span class="restore-info-label">Project</span><span class="restore-info-value">' + esc(project) + '</span></div>'
+      + '<div class="restore-info-row"><span class="restore-info-label">Environment</span><span class="restore-info-value">' + esc(env) + '</span></div>'
+      + '<div style="background:rgba(220,38,38,0.1);border:1px solid rgba(220,38,38,0.3);border-radius:0.5rem;padding:0.75rem 1rem;font-size:0.8125rem;color:#fca5a5;margin-top:0.75rem;">'
+      + '<strong style="display:block;margin-bottom:0.375rem;">DESTRUCTIVE — Disaster Recovery Only</strong>'
+      + 'Stops containers, wipes all data volumes, rebuilds image, starts fresh. '
+      + 'You must restore a backup afterwards.</div>'
+      + '<div style="margin-top:0.875rem;">'
+      + '<label style="font-size:0.8125rem;color:#9ca3af;display:block;margin-bottom:0.375rem;">Type the environment name to confirm:</label>'
+      + '<input id="recreate-confirm-input" type="text" placeholder="' + esc(env) + '" '
+      + 'style="width:100%;box-sizing:border-box;padding:0.5rem 0.75rem;background:#1f2937;border:1px solid rgba(220,38,38,0.4);border-radius:0.375rem;color:#f3f4f6;font-size:0.875rem;" '
+      + 'oninput="checkRecreateConfirm(\'' + esc(env) + '\')">'
+      + '</div>';
+    startBtn.className = 'btn btn-danger btn-sm';
+    startBtn.style.cssText = '';
+    startBtn.textContent = 'Recreate';
+    startBtn.disabled = true;  // enabled after typing env name
+  }
+
+  document.getElementById('ops-modal-output').style.display = 'none';
+  document.getElementById('ops-modal-terminal').textContent = '';
+
+  document.getElementById('ops-modal').style.display = 'flex';
+  if (action === 'recreate') {
+    setTimeout(() => {
+      const inp = document.getElementById('recreate-confirm-input');
+      if (inp) inp.focus();
+    }, 100);
+  }
+}
+
+function checkRecreateConfirm(expectedEnv) {
+  const inp = document.getElementById('recreate-confirm-input');
+  const startBtn = document.getElementById('ops-start-btn');
+  if (!inp || !startBtn) return;
+  startBtn.disabled = inp.value.trim() !== expectedEnv;
+}
+
+function closeOpsModal() {
+  if (opsEventSource) { opsEventSource.close(); opsEventSource = null; }
+  document.getElementById('ops-modal').style.display = 'none';
+  opsCtx = { type: null, project: null, fromEnv: null, toEnv: null };
+  // Restore dry-run row visibility for promote/sync operations
+  const dryRunRow = document.getElementById('ops-dry-run-row');
+  if (dryRunRow) dryRunRow.style.display = '';
+  // Reset start button style
+  const startBtn = document.getElementById('ops-start-btn');
+  if (startBtn) { startBtn.style.cssText = ''; startBtn.disabled = false; }
+}
+
+function _btnLabelForType(type) {
+  if (type === 'promote') return 'Promote';
+  if (type === 'sync') return 'Sync';
+  if (type === 'restart') return 'Restart';
+  if (type === 'rebuild') return 'Rebuild';
+  if (type === 'recreate') return 'Recreate';
+  return 'Run';
+}
+
+function startOperation() {
+  const { type, project, fromEnv, toEnv } = opsCtx;
+  if (!type || !project) return;
+
+  const dryRun = document.getElementById('ops-dry-run').checked;
+  const startBtn = document.getElementById('ops-start-btn');
+  const outputDiv = document.getElementById('ops-modal-output');
+  const term = document.getElementById('ops-modal-terminal');
+
+  outputDiv.style.display = 'block';
+  term.textContent = 'Starting...\n';
+  startBtn.disabled = true;
+  startBtn.textContent = 'Running...';
+
+  let url;
+  if (type === 'promote') {
+    url = '/api/promote/' + encodeURIComponent(project) + '/' + encodeURIComponent(fromEnv) + '/' + encodeURIComponent(toEnv) + '?dry_run=' + dryRun + '&token=' + encodeURIComponent(getToken());
+  } else if (type === 'sync') {
+    url = '/api/sync/' + encodeURIComponent(project) + '?from=' + encodeURIComponent(fromEnv) + '&to=' + encodeURIComponent(toEnv) + '&dry_run=' + dryRun + '&token=' + encodeURIComponent(getToken());
+  } else if (type === 'restart' || type === 'rebuild' || type === 'recreate') {
+    // All three lifecycle ops go through /api/rebuild/{project}/{env}?action=...
+    url = '/api/rebuild/' + encodeURIComponent(project) + '/' + encodeURIComponent(fromEnv)
+      + '?action=' + encodeURIComponent(type) + '&token=' + encodeURIComponent(getToken());
+  }
+
   const es = new EventSource(url);
+  opsEventSource = es;
+  let opDone = false;
+
   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 (d.line) { term.textContent += d.line + '\n'; term.scrollTop = term.scrollHeight; }
+    try {
+      const d = JSON.parse(e.data);
+      if (d.done) {
+        opDone = true;
+        es.close();
+        opsEventSource = null;
+        const msg = d.success ? '\n--- Operation complete ---\n' : '\n--- Operation FAILED ---\n';
+        term.textContent += msg;
+        term.scrollTop = term.scrollHeight;
+        toast(d.success ? 'Operation completed' : 'Operation failed', d.success ? 'success' : 'error');
+        startBtn.disabled = false;
+        startBtn.textContent = _btnLabelForType(type);
+
+        // Show "Go to Backups" banner after recreate (or legacy rebuild)
+        const showBackupBanner = (type === 'recreate') && d.success && d.project && d.env;
+        if (showBackupBanner) {
+          const restoreProject = d.project;
+          const restoreEnv = d.env;
+          const banner = document.createElement('div');
+          banner.style.cssText = 'margin-top:1rem;padding:0.75rem 1rem;background:rgba(16,185,129,0.1);border:1px solid rgba(16,185,129,0.3);border-radius:0.5rem;display:flex;align-items:center;gap:0.75rem;';
+          banner.innerHTML = '<span style="color:#6ee7b7;font-size:0.8125rem;flex:1;">Environment recreated. Next step: restore a backup.</span>'
+            + '<button class="btn btn-ghost btn-sm" style="color:#6ee7b7;border-color:rgba(110,231,179,0.3);white-space:nowrap;" '
+            + 'onclick="closeOpsModal();currentPage=\'backups\';backupDrillLevel=2;backupDrillProject=\'' + restoreProject + '\';backupDrillEnv=\'' + restoreEnv + '\';cachedBackups=null;selectedBackups.clear();document.querySelectorAll(\'#sidebar-nav .sidebar-link\').forEach(el=>el.classList.toggle(\'active\',el.dataset.page===\'backups\'));renderPage();pushHash();">'
+            + 'Go to Backups &rarr;</button>';
+          outputDiv.appendChild(banner);
+        }
+
+        return;
+      }
+      if (d.line) {
+        term.textContent += d.line + '\n';
+        term.scrollTop = term.scrollHeight;
+      }
+    } catch (_) {}
   };
-  es.onerror = function() { es.close(); term.textContent += '\n--- Connection lost ---\n'; toast('Connection lost', 'error'); };
+
+  es.onerror = function() {
+    es.close();
+    opsEventSource = null;
+    if (opDone) return;
+    term.textContent += '\n--- Connection lost ---\n';
+    toast('Connection lost', 'error');
+    startBtn.disabled = false;
+    startBtn.textContent = _btnLabelForType(type);
+  };
 }
 
 // ---------------------------------------------------------------------------
@@ -758,20 +1564,65 @@
   logCtx = { project: null, env: null, service: null };
 }
 
-function setBackupFilter(type, value) {
-  if (type === 'project') backupFilterProject = value;
-  if (type === 'env') backupFilterEnv = value;
-  renderBackups();
-}
-
 async function createBackup(project, env) {
   if (!confirm(`Create backup for ${project}/${env}?`)) return;
   toast('Creating backup...', 'info');
   try {
     await api(`/api/backups/${project}/${env}`, { method: 'POST' });
     toast('Backup created for ' + project + '/' + env, 'success');
+    cachedBackups = null;
     if (currentPage === 'backups') renderBackups();
   } catch (e) { toast('Backup failed: ' + e.message, 'error'); }
+}
+
+async function deleteBackup(project, env, name, hasLocal, hasOffsite) {
+  let target;
+  if (hasLocal && hasOffsite) {
+    target = await showDeleteTargetDialog(name);
+    if (!target) return;
+  } else if (hasLocal) {
+    target = 'local';
+  } else {
+    target = 'offsite';
+  }
+  const label = target === 'both' ? 'local + offsite' : target;
+  if (!confirm(`Delete ${label} copy of ${name}?\n\nThis cannot be undone.`)) return;
+  toast('Deleting backup (' + label + ')...', 'info');
+  try {
+    await api(`/api/backups/${encodeURIComponent(project)}/${encodeURIComponent(env)}/${encodeURIComponent(name)}?target=${target}`, { method: 'DELETE' });
+    toast('Backup deleted: ' + name + ' (' + label + ')', 'success');
+    cachedBackups = null;
+    if (currentPage === 'backups') renderBackups();
+  } catch (e) { toast('Delete failed: ' + e.message, 'error'); }
+}
+
+function showDeleteTargetDialog(name) {
+  return new Promise(resolve => {
+    const overlay = document.createElement('div');
+    overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:9999;';
+    const box = document.createElement('div');
+    box.style.cssText = 'background:#1e293b;border:1px solid #334155;border-radius:0.75rem;padding:1.5rem;min-width:320px;max-width:420px;color:#e2e8f0;';
+    box.innerHTML = `
+      <h3 style="margin:0 0 0.5rem;font-size:1rem;color:#f1f5f9;">Delete from where?</h3>
+      <p style="margin:0 0 1.25rem;font-size:0.85rem;color:#94a3b8;">This backup exists in both local and offsite storage.</p>
+      <div style="display:flex;flex-direction:column;gap:0.5rem;">
+        <button class="btn btn-ghost" style="justify-content:flex-start;color:#f87171;border-color:#7f1d1d;" data-target="local">Local only</button>
+        <button class="btn btn-ghost" style="justify-content:flex-start;color:#a78bfa;border-color:rgba(167,139,250,0.25);" data-target="offsite">Offsite only</button>
+        <button class="btn btn-danger" style="justify-content:flex-start;" data-target="both">Both (local + offsite)</button>
+        <button class="btn btn-ghost" style="justify-content:flex-start;margin-top:0.25rem;" data-target="">Cancel</button>
+      </div>`;
+    overlay.appendChild(box);
+    document.body.appendChild(overlay);
+    box.addEventListener('click', e => {
+      const btn = e.target.closest('[data-target]');
+      if (!btn) return;
+      document.body.removeChild(overlay);
+      resolve(btn.dataset.target || null);
+    });
+    overlay.addEventListener('click', e => {
+      if (e.target === overlay) { document.body.removeChild(overlay); resolve(null); }
+    });
+  });
 }
 
 // ---------------------------------------------------------------------------
@@ -781,6 +1632,89 @@
   const m = {};
   for (const item of arr) { const k = item[key] || 'other'; (m[k] = m[k] || []).push(item); }
   return m;
+}
+
+// ---------------------------------------------------------------------------
+// URL Hash Routing
+// ---------------------------------------------------------------------------
+function pushHash() {
+  let hash = '';
+  if (currentPage === 'dashboard') {
+    if (viewMode === 'table') {
+      hash = '/dashboard/table';
+      if (tableFilter) hash += '/' + encodeURIComponent(tableFilter);
+    } else if (drillLevel === 2) {
+      hash = '/dashboard/' + encodeURIComponent(drillProject) + '/' + encodeURIComponent(drillEnv);
+    } else if (drillLevel === 1) {
+      hash = '/dashboard/' + encodeURIComponent(drillProject);
+    } else {
+      hash = '/dashboard';
+    }
+  } else if (currentPage === 'backups') {
+    if (backupDrillLevel === 2) {
+      hash = '/backups/' + encodeURIComponent(backupDrillProject) + '/' + encodeURIComponent(backupDrillEnv);
+    } else if (backupDrillLevel === 1) {
+      hash = '/backups/' + encodeURIComponent(backupDrillProject);
+    } else {
+      hash = '/backups';
+    }
+  } else if (currentPage === 'system') {
+    hash = '/system';
+  } else if (currentPage === 'operations') {
+    hash = '/operations';
+  }
+  const newHash = '#' + hash;
+  if (window.location.hash !== newHash) {
+    history.replaceState(null, '', newHash);
+  }
+}
+
+function navigateToHash() {
+  const raw = (window.location.hash || '').replace(/^#\/?/, '');
+  const parts = raw.split('/').map(decodeURIComponent).filter(Boolean);
+
+  if (!parts.length) { showPage('dashboard'); return; }
+
+  const page = parts[0];
+  if (page === 'dashboard') {
+    currentPage = 'dashboard';
+    drillLevel = 0; drillProject = null; drillEnv = null;
+    viewMode = 'cards'; tableFilter = null; tableFilterLabel = '';
+    cachedBackups = null;
+    backupDrillLevel = 0; backupDrillProject = null; backupDrillEnv = null;
+
+    if (parts[1] === 'table') {
+      viewMode = 'table';
+      if (parts[2]) { tableFilter = parts[2]; tableFilterLabel = parts[2]; }
+    } else if (parts[1]) {
+      drillProject = parts[1]; drillLevel = 1;
+      if (parts[2]) { drillEnv = parts[2]; drillLevel = 2; }
+    }
+    document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el =>
+      el.classList.toggle('active', el.dataset.page === 'dashboard'));
+    renderPage();
+  } else if (page === 'backups') {
+    currentPage = 'backups';
+    drillLevel = 0; drillProject = null; drillEnv = null;
+    viewMode = 'cards'; tableFilter = null; tableFilterLabel = '';
+    cachedBackups = null;
+    backupDrillLevel = 0; backupDrillProject = null; backupDrillEnv = null;
+    selectedBackups.clear();
+
+    if (parts[1]) {
+      backupDrillProject = parts[1]; backupDrillLevel = 1;
+      if (parts[2]) { backupDrillEnv = parts[2]; backupDrillLevel = 2; }
+    }
+    document.querySelectorAll('#sidebar-nav .sidebar-link').forEach(el =>
+      el.classList.toggle('active', el.dataset.page === 'backups'));
+    renderPage();
+  } else if (page === 'system') {
+    showPage('system');
+  } else if (page === 'operations') {
+    showPage('operations');
+  } else {
+    showPage('dashboard');
+  }
 }
 
 // ---------------------------------------------------------------------------
@@ -795,11 +1729,20 @@
         allServices = data;
         document.getElementById('login-overlay').style.display = 'none';
         document.getElementById('app').style.display = 'flex';
-      const vEl = document.getElementById('app-version'); if (vEl && typeof APP_VERSION !== 'undefined') vEl.textContent = APP_VERSION;
-        showPage('dashboard');
+        const vEl = document.getElementById('app-version'); if (vEl && typeof APP_VERSION !== 'undefined') vEl.textContent = APP_VERSION;
+        navigateToHash();
         startAutoRefresh();
       })
       .catch(() => { localStorage.removeItem('ops_token'); });
   }
-  document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLogModal(); });
+  document.addEventListener('keydown', e => {
+    if (e.key === 'Escape') {
+      closeLogModal();
+      closeRestoreModal();
+      closeOpsModal();
+    }
+  });
+  window.addEventListener('hashchange', () => {
+    if (getToken()) navigateToHash();
+  });
 })();

--
Gitblit v1.3.1