diff --git a/backend/atlas_portal/routes/lab.py b/backend/atlas_portal/routes/lab.py index 40d0a23..fd46549 100644 --- a/backend/atlas_portal/routes/lab.py +++ b/backend/atlas_portal/routes/lab.py @@ -14,6 +14,15 @@ from .. import settings _LAB_STATUS_CACHE: dict[str, Any] = {"ts": 0.0, "value": None} +def _status_response(payload: dict[str, Any]) -> Any: + """Return lab status JSON with browser and proxy caching disabled.""" + + response = jsonify(payload) + response.headers["Cache-Control"] = "no-store, max-age=0" + response.headers["Pragma"] = "no-cache" + return response + + def _vm_query(expr: str) -> float | None: """Run one instant VictoriaMetrics query and return the largest value.""" @@ -171,7 +180,7 @@ def register(app) -> None: now = time.time() cached = _LAB_STATUS_CACHE.get("value") if cached and (now - float(_LAB_STATUS_CACHE.get("ts", 0.0)) < settings.LAB_STATUS_CACHE_SEC): - return jsonify(cached) + return _status_response(cached) t_total = time.perf_counter() timings_ms: dict[str, int] = {} @@ -242,4 +251,4 @@ def register(app) -> None: _LAB_STATUS_CACHE["ts"] = now _LAB_STATUS_CACHE["value"] = payload - return jsonify(payload) + return _status_response(payload) diff --git a/backend/tests/test_lab_routes.py b/backend/tests/test_lab_routes.py index 912e73f..ab62a0f 100644 --- a/backend/tests/test_lab_routes.py +++ b/backend/tests/test_lab_routes.py @@ -129,6 +129,8 @@ def test_lab_status_uses_cache_and_probe_fallbacks(monkeypatch) -> None: payload = response.get_json() assert response.status_code == 200 + assert response.headers["Cache-Control"] == "no-store, max-age=0" + assert response.headers["Pragma"] == "no-cache" assert payload["connected"] is True assert payload["atlas"]["source"] == "grafana" assert payload["dedicated_hosts"]["up"] is True @@ -144,6 +146,7 @@ def test_lab_status_uses_cache_and_probe_fallbacks(monkeypatch) -> None: second = client.get("/api/lab/status") assert second.get_json() == payload + assert second.headers["Cache-Control"] == "no-store, max-age=0" lab._LAB_STATUS_CACHE["ts"] = 0.0 lab._LAB_STATUS_CACHE["value"] = None diff --git a/frontend/src/App.vue b/frontend/src/App.vue index aeea30d..6c7f18d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -37,7 +37,8 @@ async function refreshLabStatus() { const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), 10000); try { - const resp = await fetch("/api/lab/status", { + const resp = await fetch(`/api/lab/status?ts=${Date.now()}`, { + cache: "no-store", headers: { Accept: "application/json" }, signal: controller.signal, }); @@ -45,11 +46,13 @@ async function refreshLabStatus() { labStatus.value = await resp.json(); statusError.value = ""; } catch (err) { - labStatus.value = null; - if (err?.name === "AbortError") { - statusError.value = "Live data timed out"; - } else { - statusError.value = "Live data unavailable"; + if (!labStatus.value) { + labStatus.value = null; + if (err?.name === "AbortError") { + statusError.value = "Live data timed out"; + } else { + statusError.value = "Live data unavailable"; + } } } finally { window.clearTimeout(timeoutId); @@ -69,7 +72,7 @@ onUnmounted(() => { function scheduleNextPoll() { if (pollTimerId) window.clearTimeout(pollTimerId); - const delayMs = labStatus.value ? 30000 : 8000; + const delayMs = labStatus.value ? 30000 : 4000; pollTimerId = window.setTimeout(refreshLabStatus, delayMs); } diff --git a/testing/frontend/e2e/home.spec.js b/testing/frontend/e2e/home.spec.js index b71e9d2..cab6191 100644 --- a/testing/frontend/e2e/home.spec.js +++ b/testing/frontend/e2e/home.spec.js @@ -49,7 +49,7 @@ test.beforeEach(async ({ page }) => { body: JSON.stringify({ enabled: true, reset_url: "https://sso.example.dev/reset" }), }); }); - await page.route("**/api/lab/status", async (route) => { + await page.route("**/api/lab/status*", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", @@ -106,7 +106,7 @@ test("keeps the homepage usable when public status fails", async ({ page }) => { await page.addInitScript(() => { window.__LAB_STATUS_FAIL__ = true; }); - await page.route("**/api/lab/status", async (route) => { + await page.route("**/api/lab/status*", async (route) => { await route.fulfill({ status: 503, contentType: "application/json", diff --git a/testing/frontend/e2e/request-access.spec.js b/testing/frontend/e2e/request-access.spec.js index 86d7bf7..2663e5c 100644 --- a/testing/frontend/e2e/request-access.spec.js +++ b/testing/frontend/e2e/request-access.spec.js @@ -8,7 +8,7 @@ test.beforeEach(async ({ page }) => { body: JSON.stringify({ enabled: false }), }); }); - await page.route("**/api/lab/status", async (route) => { + await page.route("**/api/lab/status*", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", diff --git a/testing/frontend/unit/static-views.spec.js b/testing/frontend/unit/static-views.spec.js index a813878..d49025b 100644 --- a/testing/frontend/unit/static-views.spec.js +++ b/testing/frontend/unit/static-views.spec.js @@ -215,6 +215,13 @@ describe("static shell views and components", () => { }); await flushPromises(); expect(app.text()).toContain("true"); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringMatching(/^\/api\/lab\/status\?ts=\d+$/), + expect.objectContaining({ + cache: "no-store", + headers: { Accept: "application/json" }, + }), + ); await app.unmount();