From b51ece78a3e4ecc58f92565a5b2c63c154b9a547 Mon Sep 17 00:00:00 2001 From: Hermes Bot Date: Thu, 9 Jul 2026 23:11:32 -0400 Subject: [PATCH] fix(dashboard): revalidate HTML app shell so rebuilds are picked up (obsidian :3000) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator saw the obsidian Services card showing a raw `:3000` instead of the clean `/obsidian/` SSO link, even though PR #78 shipped correctly: /api/services returns the obsidian entry with NO `url`/`check` echoed (only id/name/port/hint/ok), and serviceOpenHref takes the `APP_PREFIX && SSO_ROUTES['obsidian']` branch -> `/obsidian/`. The card never renders `port` as text. So current code cannot produce `:3000` — the browser was serving a CACHED pre-#78 index.html. Root cause: Starlette's StaticFiles sends ETag + Last-Modified but NO Cache-Control on index.html, so browsers apply heuristic freshness and serve the stale app shell without revalidating. Every dashboard rebuild (new SSO routes / service cards) can therefore keep showing the old shell until a hard refresh — a recurring drift, not a one-off. Fixing per-incident ("tell the operator to hard-refresh") would repeat forever; the real fix is upstream. Fix: subclass StaticFiles to add `Cache-Control: no-cache` to HTML responses only. The shell stays cached but MUST revalidate its ETag every load, so new builds appear immediately; hashed static assets keep long-cache behavior. Tests: index.html sends Cache-Control: no-cache (+ retains ETag); obsidian entry has the SSO-routed shape (no `url`, port for probe only, hint uses the same-origin `/obsidian/` path, never `:3000`) — regression-locking the shape against codebase-memory-ui. Validated live on ordo-v2 (only dashboard rebuilt/recreated): served shell now carries `cache-control: no-cache` + the obsidian SSO route; /api/services obsidian entry unchanged & clean; /api/hardware still reports disk (1999.8GB) + both GPUs (1070+5090); dashboard healthy; obsidian/llamacpp/agent container IDs unchanged. Root ruff (dashboard/) + tests green (118 passed). Co-Authored-By: Claude Opus 4.8 --- dashboard/app.py | 23 ++++++++++++++++- tests/test_services_and_throughput.py | 36 +++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/dashboard/app.py b/dashboard/app.py index 55970b0..832a576 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -2046,6 +2046,27 @@ def _empty_payload(): # --- Static --- + +class _NoCacheHTMLStaticFiles(StaticFiles): + """StaticFiles that forces revalidation of the HTML app shell. + + Starlette's StaticFiles sends an ETag + Last-Modified but NO Cache-Control, + which lets browsers apply *heuristic* freshness and serve a stale index.html + without revalidating — so a rebuilt dashboard (new SSO routes, new service + cards, etc.) can keep showing the old shell until a hard refresh. We add + `Cache-Control: no-cache` to HTML responses only: the browser still caches + the shell but MUST revalidate the ETag every load, so a new build is picked + up immediately. Hashed/static assets keep their default long-cache behavior. + """ + + async def get_response(self, path, scope): + response = await super().get_response(path, scope) + content_type = response.headers.get("content-type", "") + if content_type.startswith("text/html"): + response.headers["Cache-Control"] = "no-cache" + return response + + static_dir = Path(__file__).parent / "static" if static_dir.exists(): - app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static") + app.mount("/", _NoCacheHTMLStaticFiles(directory=str(static_dir), html=True), name="static") diff --git a/tests/test_services_and_throughput.py b/tests/test_services_and_throughput.py index 7589856..4da654c 100644 --- a/tests/test_services_and_throughput.py +++ b/tests/test_services_and_throughput.py @@ -170,3 +170,39 @@ def _boom(): assert data["detail"] == "Internal server error" # Must NOT contain the traceback assert "test boom" not in str(data) + + +# ── Static app-shell caching ───────────────────────────────────────────────── + +def test_index_html_sends_no_cache(client): + """The HTML app shell must revalidate every load, so a rebuilt dashboard + (new SSO routes / service cards) is picked up without a hard refresh.""" + r = client.get("/") + assert r.status_code == 200 + assert r.headers.get("content-type", "").startswith("text/html") + assert r.headers.get("cache-control") == "no-cache" + # Still a validated cache — the ETag is what the browser revalidates against. + assert r.headers.get("etag") + + +# ── SSO-routed service entries (obsidian regression) ───────────────────────── + +def test_obsidian_entry_is_sso_routed_like_codebase_memory(): + """The obsidian card must render the clean /obsidian/ SSO link, never a raw + :3000 URL. That requires the SAME entry shape as codebase-memory-ui: a `port` + (for the health probe), NO `url` key (so serviceOpenHref uses SSO_ROUTES), + and a hint that references the same-origin path, not a host:port.""" + from dashboard.services_catalog import SERVICES + + by_id = {s["id"]: s for s in SERVICES} + obsidian = by_id["obsidian"] + reference = by_id["codebase-memory-ui"] + + # No `url` key -> serviceOpenHref falls through to SSO_ROUTES['obsidian']. + assert "url" not in obsidian, "obsidian must not set `url` (would leak :3000)" + assert "url" not in reference # sanity: the working precedent has no url either + # Port exists for the health probe but is never rendered as a link/label. + assert obsidian["port"] == 3000 + # Hint (shown only when offline) must not expose a raw host:port. + assert ":3000" not in obsidian["hint"] + assert "/obsidian/" in obsidian["hint"]