diff --git a/app/assets/css/components.css b/app/assets/css/components.css index b53a14f..de59528 100644 --- a/app/assets/css/components.css +++ b/app/assets/css/components.css @@ -2394,6 +2394,13 @@ a.kiosk-brand { width: auto; } +/* Applied to the header logo when the last two pings to RavenBrain + have failed — gives a quick visual signal that the network is down. */ +.logo-offline { + filter: grayscale(1); + transition: filter 0.4s ease; +} + .kiosk-queue-status { flex: 1; diff --git a/app/common/storage/networkHealth.ts b/app/common/storage/networkHealth.ts new file mode 100644 index 0000000..e68b184 --- /dev/null +++ b/app/common/storage/networkHealth.ts @@ -0,0 +1,63 @@ +import { useEffect, useState } from "react"; +import { ping } from "~/common/storage/rb.ts"; + +const POLL_INTERVAL_MS = 30_000; + +let lastResults: boolean[] = []; +let pollTimer: ReturnType | null = null; +let started = false; +const subscribers = new Set<() => void>(); + +function notify() { + subscribers.forEach((fn) => fn()); +} + +async function runPing() { + const ok = await ping(); + lastResults = [...lastResults, ok].slice(-2); + notify(); +} + +function start() { + if (started || typeof window === "undefined") return; + started = true; + runPing(); + pollTimer = setInterval(runPing, POLL_INTERVAL_MS); +} + +export interface NetworkHealth { + /** Result of the most recent ping, or null before the first ping completes. */ + alive: boolean | null; + /** True only when the last two pings both failed. */ + isOffline: boolean; + /** True once at least one ping has completed. */ + ready: boolean; +} + +/** + * Subscribes to a shared, app-wide ping loop that runs every 30 seconds. + * The first call from any component starts the loop; the loop never stops + * (the app is a SPA so the timer naturally dies when the tab closes). + * + * Components re-render whenever a new ping result arrives. + */ +export function useNetworkHealth(): NetworkHealth { + const [, setTick] = useState(0); + + useEffect(() => { + const tick = () => setTick((n) => n + 1); + subscribers.add(tick); + start(); + return () => { + subscribers.delete(tick); + }; + }, []); + + const alive = + lastResults.length === 0 ? null : lastResults[lastResults.length - 1]; + const isOffline = + lastResults.length >= 2 && lastResults.every((r) => r === false); + const ready = lastResults.length > 0; + + return { alive, isOffline, ready }; +} diff --git a/app/common/storage/rb.ts b/app/common/storage/rb.ts index 47c2659..f029cc3 100644 --- a/app/common/storage/rb.ts +++ b/app/common/storage/rb.ts @@ -33,23 +33,33 @@ import type { MatchStrategyDrawing } from "~/types/MatchStrategyDrawing.ts"; import type { StrategyStroke } from "~/types/StrategyStroke.ts"; import type { FieldCalibration } from "~/types/FieldCalibration.ts"; +const PING_TIMEOUT_MS = 3000; + /** * Sends a ping request to the API to check if the server is reachable. * + * Bounded by a 3-second timeout so flaky venue WiFi (TCP connects but + * never delivers) can't hang sync flows indefinitely. + * * @return {Promise} A promise that resolves to true if the server responds with a status indicating success, otherwise false. */ export async function ping(): Promise { - return fetch(import.meta.env.VITE_API_HOST + "/api/ping", {}) - .then((resp) => { - const ver = resp.headers.get("X-RavenBrain-Version"); - if (ver && typeof sessionStorage !== "undefined") { - sessionStorage.setItem(SESSION_KEY_RAVENBRAIN_VERSION, ver); - } - return resp.ok; - }) - .catch(() => { - return false; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), PING_TIMEOUT_MS); + try { + const resp = await fetch(import.meta.env.VITE_API_HOST + "/api/ping", { + signal: controller.signal, }); + const ver = resp.headers.get("X-RavenBrain-Version"); + if (ver && typeof sessionStorage !== "undefined") { + sessionStorage.setItem(SESSION_KEY_RAVENBRAIN_VERSION, ver); + } + return resp.ok; + } catch { + return false; + } finally { + clearTimeout(timer); + } } /** diff --git a/app/common/storage/rbauth.ts b/app/common/storage/rbauth.ts index 0055ef4..9de79ad 100644 --- a/app/common/storage/rbauth.ts +++ b/app/common/storage/rbauth.ts @@ -1,5 +1,5 @@ -import { useEffect, useState } from "react"; -import { ping } from "~/common/storage/rb.ts"; +import { useEffect, useRef, useState } from "react"; +import { useNetworkHealth } from "~/common/storage/networkHealth.ts"; import type { RBJWT } from "~/types/RBJWT.ts"; const SESSION_KEY_ACCESS_TOKEN = "raveneye_access_token"; @@ -61,6 +61,15 @@ export async function authenticate( localStorage.setItem(SESSION_KEY_REFRESH_TOKEN, json.refresh_token); } window.dispatchEvent(new Event(AUTH_CHANGED_EVENT)); + // Kick off a full server-data sync so first-login scouts have every + // reference list (tournaments, areas, event types, etc.) in IndexedDB + // before they navigate to track pages. Fire-and-forget — never block + // the login flow on sync. Dynamic import breaks the rbauth↔sync cycle. + import("~/common/sync/sync.ts") + .then((m) => m.doServerDataSync()) + .catch((err) => { + console.warn("Post-login server data sync failed", err); + }); return; }); } @@ -256,6 +265,8 @@ export async function rbfetch( return response; } +const RBFETCH_TIMEOUT_MS = 20_000; + async function doRbFetch( urlpath: string, options: RequestInit, @@ -264,6 +275,8 @@ async function doRbFetch( if (typeof sessionStorage !== "undefined") { accessToken = sessionStorage.getItem(SESSION_KEY_ACCESS_TOKEN); } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), RBFETCH_TIMEOUT_MS); const o2: Record = { ...options, headers: { @@ -271,10 +284,15 @@ async function doRbFetch( "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, }, + mode: "cors", + signal: controller.signal, }; - o2.mode = "cors"; - return fetch(import.meta.env.VITE_API_HOST + urlpath, o2); + try { + return await fetch(import.meta.env.VITE_API_HOST + urlpath, o2); + } finally { + clearTimeout(timer); + } } /** @@ -329,101 +347,90 @@ export function getRavenBrainVersion(): string | null { * - `debug_expired` (boolean): Intermediate login status - the access token has expired */ export function useLoginStatus() { + const { alive, ready } = useNetworkHealth(); const [loading, setLoading] = useState(true); - const [alive, setAlive] = useState(false); + const [aliveState, setAliveState] = useState(false); const [hasToken, setHasToken] = useState(false); const [expired, setExpired] = useState(false); const [loggedIn, setLoggedIn] = useState(false); + const startedRef = useRef(false); useEffect(() => { - ping() - .then((result) => { - if (!result) { - // Offline: trust the local session if we have a non-expired JWT - setAlive(false); - const accessToken = - typeof sessionStorage !== "undefined" - ? sessionStorage.getItem(SESSION_KEY_ACCESS_TOKEN) - : null; - if (accessToken && !isJwtExpired(accessToken)) { - setHasToken(true); - setLoggedIn(true); - } - setLoading(false); - return; - } - setAlive(true); - let accessToken = null; - if (typeof sessionStorage !== "undefined") { - accessToken = sessionStorage.getItem(SESSION_KEY_ACCESS_TOKEN); - } - if (accessToken === null) { - setHasToken(false); - // No access token but server is alive — try refresh from localStorage - refreshAccessToken() - .then((refreshed) => { - if (refreshed) { - return validate().then(() => { - setHasToken(true); - setLoggedIn(true); - setLoading(false); - }); - } - setLoading(false); - }) - .catch(() => { - setLoading(false); - }); - return; - } else { - setHasToken(true); - } + if (!ready || startedRef.current) return; + startedRef.current = true; - if (isJwtExpired(accessToken)) { - setExpired(true); - refreshAccessToken() - .then((refreshed) => { - if (refreshed) { - return validate().then(() => { - setExpired(false); - setLoggedIn(true); - setLoading(false); - }); - } - setLoading(false); - }) - .catch(() => { + if (!alive) { + // Offline: trust the local session if we have a non-expired JWT + setAliveState(false); + const accessToken = + typeof sessionStorage !== "undefined" + ? sessionStorage.getItem(SESSION_KEY_ACCESS_TOKEN) + : null; + if (accessToken && !isJwtExpired(accessToken)) { + setHasToken(true); + setLoggedIn(true); + } + setLoading(false); + return; + } + setAliveState(true); + let accessToken = null; + if (typeof sessionStorage !== "undefined") { + accessToken = sessionStorage.getItem(SESSION_KEY_ACCESS_TOKEN); + } + if (accessToken === null) { + setHasToken(false); + // No access token but server is alive — try refresh from localStorage + refreshAccessToken() + .then((refreshed) => { + if (refreshed) { + return validate().then(() => { + setHasToken(true); + setLoggedIn(true); setLoading(false); }); - } else { - validate() - .then(() => { + } + setLoading(false); + }) + .catch(() => { + setLoading(false); + }); + return; + } else { + setHasToken(true); + } + + if (isJwtExpired(accessToken)) { + setExpired(true); + refreshAccessToken() + .then((refreshed) => { + if (refreshed) { + return validate().then(() => { + setExpired(false); setLoggedIn(true); setLoading(false); - }) - .catch(() => { - setLoading(false); }); - } - }) - .catch(() => { - // Offline: trust the local session if we have a non-expired JWT - setAlive(false); - const accessToken = - typeof sessionStorage !== "undefined" - ? sessionStorage.getItem(SESSION_KEY_ACCESS_TOKEN) - : null; - if (accessToken && !isJwtExpired(accessToken)) { - setHasToken(true); + } + setLoading(false); + }) + .catch(() => { + setLoading(false); + }); + } else { + validate() + .then(() => { setLoggedIn(true); - } - setLoading(false); - }); - }, []); + setLoading(false); + }) + .catch(() => { + setLoading(false); + }); + } + }, [ready, alive]); return { loading, - debug_alive: alive, + debug_alive: aliveState, debug_hasToken: hasToken, debug_expired: expired, loggedIn, diff --git a/app/root.tsx b/app/root.tsx index 3f8cd80..edc4449 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -24,6 +24,7 @@ import { } from "~/common/sync/sync.ts"; import { useEffect, useState } from "react"; import { getRavenBrainVersion } from "~/common/storage/rbauth.ts"; +import { useNetworkHealth } from "~/common/storage/networkHealth.ts"; import Banners from "~/common/banners/Banners.tsx"; import AdminMenu from "~/common/AdminMenu.tsx"; @@ -37,6 +38,7 @@ export const links: Route.LinksFunction = () => [ export function Layout({ children }: { children: React.ReactNode }) { const syncStatus = useOverallSyncStatus(); + const { isOffline } = useNetworkHealth(); const [ravenBrainVersion, setRavenBrainVersion] = useState( null, ); @@ -68,7 +70,11 @@ export function Layout({ children }: { children: React.ReactNode }) {
diff --git a/app/routes/report/pit-kiosk-page.tsx b/app/routes/report/pit-kiosk-page.tsx index 5d540d1..c1b0209 100644 --- a/app/routes/report/pit-kiosk-page.tsx +++ b/app/routes/report/pit-kiosk-page.tsx @@ -16,6 +16,7 @@ import type { NexusQueueStatus } from "~/types/NexusQueueStatus.ts"; import logoUrl from "~/assets/images/logo.png"; import Title from "~/common/icons/Title.tsx"; import Spinner from "~/common/Spinner.tsx"; +import { useNetworkHealth } from "~/common/storage/networkHealth.ts"; import { deriveAlliances, isFinalsDecided, @@ -170,6 +171,7 @@ function TopBar({ }) { const startTime = formatQueueTime(queueStatus?.estimatedStartTime ?? null); const queueTime = formatQueueTime(queueStatus?.estimatedQueueTime ?? null); + const { isOffline } = useNetworkHealth(); const status = queueStatus?.teamStatus ?? null; const barClass = status === "On field" @@ -185,7 +187,11 @@ function TopBar({ return (