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