Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion dashboard/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
36 changes: 36 additions & 0 deletions tests/test_services_and_throughput.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Loading