From b99d8ef75562b77d869d6b0af7b84fd4da78557e Mon Sep 17 00:00:00 2001 From: yassin Date: Sat, 3 Oct 2026 01:17:30 +0000 Subject: [PATCH] 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> --- .../test_ui_image_serves_offline.py | 50 +++++++++++++++++++ ui/nginx.conf | 16 ++++-- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/tests/proxy_migration_tests/test_ui_image_serves_offline.py b/tests/proxy_migration_tests/test_ui_image_serves_offline.py index 5ff68effd7f..468eed91f52 100644 --- a/tests/proxy_migration_tests/test_ui_image_serves_offline.py +++ b/tests/proxy_migration_tests/test_ui_image_serves_offline.py @@ -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)}" + ) diff --git a/ui/nginx.conf b/ui/nginx.conf index 235cb9c501e..b5c0b9abb1b 100644 --- a/ui/nginx.conf +++ b/ui/nginx.conf @@ -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 .txt next to .html, plus __next.*.txt segment @@ -95,14 +97,18 @@ http { # proxy_server: serve /ui/ from out/.html, with App # Router-aware fallback (out//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