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

---
 app/main.py |   80 +++++++++++++++++++++++++++++++++++++--
 1 files changed, 75 insertions(+), 5 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)

--
Gitblit v1.3.1