fix(ui): revalidate dashboard HTML on every load and answer /health/readiness

The UI image sent its HTML with no Cache-Control while the content-hashed
_next bundles are immutable for a year, so a browser could keep stale HTML
across a deploy and request chunk hashes the new image no longer ships,
failing with 404 until a hard reload. The HTML locations now send
Cache-Control: no-cache so the browser revalidates against ETag on each load.

nginx also answers 200 on /health/readiness, the path the gateway exposes,
so an ingress-wide ALB health check on that path no longer marks the UI
target unhealthy

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yassin 2026-10-03 01:17:30 +00:00
parent b877a38e5f
commit b99d8ef755
2 changed files with 61 additions and 5 deletions

View file

@ -18,6 +18,7 @@ Requires a working docker CLI.
"""
import os
import re
import shutil
import subprocess
import time
@ -96,6 +97,17 @@ def _probe(network: str, container: str, path: str) -> "subprocess.CompletedProc
)
def _response_headers(network: str, container: str, path: str) -> dict[str, str]:
head = _docker(
"run", "--rm", "--network", network, CURL_IMAGE,
"--silent", "--show-error", "--max-time", "10", "--head",
f"http://{container}:{UI_PORT}{path}",
check=False,
)
header_lines = (line for line in head.stdout.splitlines() if ":" in line)
return {name.strip().lower(): value.strip() for name, value in (line.split(":", 1) for line in header_lines)}
def test_ui_serves_as_arbitrary_uid_read_only(ui_container: tuple[str, str]) -> None:
"""nginx boots and serves as an arbitrary uid with a read-only root fs.
@ -130,3 +142,41 @@ def test_ui_serves_as_arbitrary_uid_read_only(ui_container: tuple[str, str]) ->
f"GET {path} returned {page.stdout.strip()!r} as uid {ARBITRARY_UID}.\n"
f"{_container_logs(container)}"
)
def test_readiness_path_answers_200_like_the_gateway(ui_container: tuple[str, str]) -> None:
network, container = ui_container
readiness = _probe(network, container, "/health/readiness")
assert readiness.stdout.strip() == "200", (
f"GET /health/readiness returned {readiness.stdout.strip()!r}; an ingress-wide ALB health check on "
f"that path marks the UI target unhealthy.\n{_container_logs(container)}"
)
def test_html_revalidates_while_hashed_bundles_stay_immutable(ui_container: tuple[str, str]) -> None:
network, container = ui_container
for path in ("/ui", "/ui/", "/ui/login", "/"):
headers = _response_headers(network, container, path)
assert headers.get("cache-control") == "no-cache", (
f"GET {path} sent Cache-Control {headers.get('cache-control')!r}; a browser that keeps this HTML "
f"requests the previous deploy's chunk hashes after a rollout.\n{_container_logs(container)}"
)
html = _docker(
"run", "--rm", "--network", network, CURL_IMAGE,
"--silent", "--show-error", "--max-time", "10",
f"http://{container}:{UI_PORT}/ui/",
check=False,
)
chunk_paths = tuple(
match.group(1) for match in re.finditer(r'src="(/litellm-asset-prefix/_next/static/[^"]+\.js)"', html.stdout)
)
assert chunk_paths, f"the UI HTML references no /litellm-asset-prefix/_next/static/*.js bundle:\n{html.stdout[:2000]}"
chunk_headers = _response_headers(network, container, chunk_paths[0])
assert "immutable" in chunk_headers.get("cache-control", ""), (
f"GET {chunk_paths[0]} sent Cache-Control {chunk_headers.get('cache-control')!r}; hashed bundles must "
f"stay cacheable.\n{_container_logs(container)}"
)

View file

@ -70,8 +70,10 @@ http {
expires 1d;
}
# Probe target — doesn't depend on disk.
location = /healthz { default_type text/plain; return 200 "ok\n"; }
# Probe targets — don't depend on disk. /health/readiness mirrors the
# gateway's path so one ingress-wide ALB health check covers the UI too.
location = /healthz { default_type text/plain; return 200 "ok\n"; }
location = /health/readiness { default_type text/plain; return 200 "ok\n"; }
# Next.js App Router (output: "export") emits an RSC/flight payload
# as <route>.txt next to <route>.html, plus __next.*.txt segment
@ -95,14 +97,18 @@ http {
# proxy_server: serve /ui/<page> from out/<page>.html, with App
# Router-aware fallback (out/<page>/index.html) and a final SPA
# fallback to out/index.html for client-side routes.
location = /ui { try_files /index.html =404; }
location = /ui/ { try_files /index.html =404; }
# The HTML is the manifest for the content-hashed bundles above, so it
# must revalidate on every load (ETag/Last-Modified) or a browser keeps
# asking for chunks the previous deploy shipped and gets 404s.
location = /ui { add_header Cache-Control "no-cache"; try_files /index.html =404; }
location = /ui/ { add_header Cache-Control "no-cache"; try_files /index.html =404; }
location ~ ^/ui/(.+)$ {
add_header Cache-Control "no-cache";
try_files /$1.html /$1/index.html /index.html =404;
}
# `/` is handy for direct-debug port-forwards.
location = / { try_files /index.html =404; }
location = / { add_header Cache-Control "no-cache"; try_files /index.html =404; }
# Anything else (API calls etc.) returns 404 from the UI's
# perspective. A reverse proxy in front of this image routes the