1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
| | import hashlib
| | import logging
| | from contextlib import asynccontextmanager
| | from pathlib import Path
| |
| | 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, cancel, promote, rebuild, registry, restore, schedule, services, status, sync_data, system
| |
| | logging.basicConfig(
| | level=logging.INFO,
| | format="%(asctime)s %(levelname)s %(name)s: %(message)s",
| | )
| | 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
| | async def lifespan(app: FastAPI):
| | logger.info("Ops WebUI server is running")
| | yield
| |
| |
| | app = FastAPI(
| | title="Ops WebUI API",
| | description="Backend API for the ops web dashboard",
| | version="1.0.0",
| | lifespan=lifespan,
| | )
| |
| | # ---------------------------------------------------------------------------
| | # CORS – open for development; restrict in production via env/reverse proxy
| | # ---------------------------------------------------------------------------
| | app.add_middleware(
| | CORSMiddleware,
| | allow_origins=["*"],
| | allow_credentials=True,
| | allow_methods=["*"],
| | allow_headers=["*"],
| | )
| |
| | # ---------------------------------------------------------------------------
| | # API routers
| | # ---------------------------------------------------------------------------
| | app.include_router(status.router, prefix="/api/status", tags=["status"])
| | app.include_router(backups.router, prefix="/api/backups", tags=["backups"])
| | 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"])
| | app.include_router(schedule.router, prefix="/api/schedule", tags=["schedule"])
| | app.include_router(cancel.router, prefix="/api/operations", tags=["operations"])
| |
| | # ---------------------------------------------------------------------------
| | # 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")
| | 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)
|
|