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
7 changes: 7 additions & 0 deletions app/assets/css/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
63 changes: 63 additions & 0 deletions app/common/storage/networkHealth.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setInterval> | 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 };
}
30 changes: 20 additions & 10 deletions app/common/storage/rb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>} A promise that resolves to true if the server responds with a status indicating success, otherwise false.
*/
export async function ping(): Promise<boolean> {
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);
}
}

/**
Expand Down
173 changes: 90 additions & 83 deletions app/common/storage/rbauth.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
});
}
Expand Down Expand Up @@ -256,6 +265,8 @@ export async function rbfetch(
return response;
}

const RBFETCH_TIMEOUT_MS = 20_000;

async function doRbFetch(
urlpath: string,
options: RequestInit,
Expand All @@ -264,17 +275,24 @@ 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<string, unknown> = {
...options,
headers: {
...options.headers,
"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);
}
}

/**
Expand Down Expand Up @@ -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<boolean>(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,
Expand Down
8 changes: 7 additions & 1 deletion app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<string | null>(
null,
);
Expand Down Expand Up @@ -68,7 +70,11 @@ export function Layout({ children }: { children: React.ReactNode }) {
<header>
<div id="logo">
<NavLink to={"/"}>
<img src={logoUrl} alt="Runnymede Robotics" />
<img
src={logoUrl}
alt="Runnymede Robotics"
className={isOffline ? "logo-offline" : undefined}
/>
</NavLink>
</div>
<div id="title">
Expand Down
Loading
Loading