diff --git a/src/App.tsx b/src/App.tsx
index d9cbe47..6c47968 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -28,6 +28,7 @@ import {
ChannelSubscriptionScreen,
BlacklistedScreen,
AccountDeletedScreen,
+ ServiceUnavailableScreen,
} from './components/blocking';
import { ErrorBoundary } from './components/ErrorBoundary';
import { PermissionRoute } from '@/components/auth/PermissionRoute';
@@ -232,6 +233,10 @@ function BlockingOverlay() {
return ;
}
+ if (blockingType === 'backend_unavailable') {
+ return ;
+ }
+
return null;
}
diff --git a/src/api/client.ts b/src/api/client.ts
index 20ee92f..ec4a1fc 100644
--- a/src/api/client.ts
+++ b/src/api/client.ts
@@ -7,6 +7,7 @@ import {
safeRedirectToLogin,
} from '../utils/token';
import { useBlockingStore } from '../store/blocking';
+import { reportPossibleBackendDown, markBackendReached } from './health';
import { API } from '../config/constants';
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
@@ -94,6 +95,12 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) =>
const newToken = await tokenRefreshManager.refreshAccessToken();
if (newToken) {
token = newToken;
+ } else if (tokenRefreshManager.lastFailureWasTransport) {
+ // Backend unreachable (not a rejected token): keep the session intact so
+ // the ServiceUnavailableScreen can auto-recover once the backend returns,
+ // instead of wiping tokens and stranding the user on /login. Let the
+ // request go out with the stale token; it will fail and be handled.
+ return config;
} else {
tokenStorage.clearTokens();
safeRedirectToLogin();
@@ -195,10 +202,28 @@ export function isAccountDeletedError(
}
apiClient.interceptors.response.use(
- (response) => response,
+ (response) => {
+ // First successful response means the app reached the backend at least once
+ // (it bootstrapped). Recovery from a later outage can then just lift the
+ // overlay instead of hard-reloading and losing unsaved UI state.
+ markBackendReached();
+ return response;
+ },
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
+ // Transport-level failure: no HTTP response at all (backend unreachable, DNS
+ // failure, connection refused, timeout). All the coded guards below need
+ // `error.response`, so this case had zero handling and produced a blank
+ // screen during bootstrap. Confirm the outage with a liveness probe (so a
+ // one-off blip doesn't blank an already-loaded app) and, if confirmed, flip
+ // the full-screen ServiceUnavailableScreen. Fire-and-forget — the original
+ // request still rejects now. Axios cancellations are not outages.
+ if (!error.response && error.code !== 'ERR_CANCELED') {
+ void reportPossibleBackendDown();
+ return Promise.reject(error);
+ }
+
if (isMaintenanceError(error)) {
const detail = (error.response?.data as { detail: MaintenanceError }).detail;
useBlockingStore.getState().setMaintenance({
@@ -256,6 +281,9 @@ apiClient.interceptors.response.use(
originalRequest.headers.Authorization = `Bearer ${newToken}`;
}
return apiClient(originalRequest);
+ } else if (tokenRefreshManager.lastFailureWasTransport) {
+ // Backend died between the 401 and the refresh: keep the session so the
+ // ServiceUnavailableScreen can recover, rather than logging the user out.
} else {
tokenStorage.clearTokens();
safeRedirectToLogin();
diff --git a/src/api/health.ts b/src/api/health.ts
new file mode 100644
index 0000000..80a7c09
--- /dev/null
+++ b/src/api/health.ts
@@ -0,0 +1,99 @@
+import axios from 'axios';
+import { useBlockingStore } from '../store/blocking';
+
+/**
+ * Backend liveness probe + "service unavailable" detection.
+ *
+ * The bot backend serves an unauthenticated liveness endpoint at the host ROOT
+ * (`/health/unified` when the Web API is enabled, `/health` otherwise) — NOT
+ * under the apiClient baseURL (`/api`). So we probe it with a BARE axios call at
+ * a root-relative URL, deliberately bypassing both the baseURL and the apiClient
+ * response interceptor (a probe must never itself touch blocking state).
+ */
+function resolveHealthUrl(): string {
+ const override = import.meta.env.VITE_HEALTH_URL;
+ if (override) return override;
+ const apiUrl = String(import.meta.env.VITE_API_URL || '/api').trim();
+ // For an absolute API base (remote / sub-path deployments like
+ // `https://api.bot.com/cabinet`) the health route still lives at the host
+ // ROOT, so derive the origin — NOT just strip `/api`. Mirrors the URL parsing
+ // in WebSocketProvider. For a relative base (`/api`, `/cabinet`) health is
+ // same-origin at the site root.
+ if (apiUrl.startsWith('http://') || apiUrl.startsWith('https://')) {
+ try {
+ return `${new URL(apiUrl).origin}/health/unified`;
+ } catch {
+ // malformed URL — fall through to the root-relative path
+ }
+ }
+ return '/health/unified';
+}
+
+export const HEALTH_URL = resolveHealthUrl();
+
+// Statuses a reverse proxy returns when it is up but its API upstream is down.
+// These must count as NOT reachable, otherwise the recovery poll would reload
+// the app straight back into a still-broken backend.
+const GATEWAY_DOWN_STATUSES = new Set([502, 503, 504]);
+
+/**
+ * Resolves true when the backend is actually serving — i.e. it returns an HTTP
+ * response that is NOT a gateway-down status. A 404 still counts as up (a
+ * Web-API-disabled deployment serves `/health`, not `/health/unified`, so
+ * probing the latter 404s while the server is fine). A 502/503/504 means a front
+ * proxy is up but the API upstream is dead, so it counts as NOT reachable.
+ * Resolves false on a transport-level failure with no response.
+ */
+export async function pingBackend(): Promise {
+ try {
+ await axios.get(HEALTH_URL, { timeout: 5000 });
+ return true;
+ } catch (err) {
+ if (axios.isAxiosError(err) && err.response) {
+ return !GATEWAY_DOWN_STATUSES.has(err.response.status);
+ }
+ return false;
+ }
+}
+
+let everReachedBackend = false;
+
+/** Called on the first successful API response: the app reached the backend at
+ * least once, so a later outage struck an already-loaded session. */
+export function markBackendReached(): void {
+ everReachedBackend = true;
+}
+
+/** True once the app has had at least one successful backend response. Lets the
+ * ServiceUnavailableScreen recover by lifting the overlay (state preserved)
+ * rather than hard-reloading, which is only needed when the INITIAL bootstrap
+ * never reached the backend. */
+export function hasEverReachedBackend(): boolean {
+ return everReachedBackend;
+}
+
+let confirmInFlight = false;
+
+/**
+ * Called from the places that see a transport-level (no-response) failure — the
+ * apiClient interceptor and the bootstrap token refresh. Rather than blanking
+ * the whole app on a single one-off network blip, it CONFIRMS the outage with a
+ * liveness probe first and only then flips the global `backend_unavailable`
+ * state that renders the full-screen ServiceUnavailableScreen. Fire-and-forget:
+ * callers still reject/handle their own request immediately. Guarded so a burst
+ * of failing requests triggers at most one probe, and skipped once the screen is
+ * already shown.
+ */
+export async function reportPossibleBackendDown(): Promise {
+ if (confirmInFlight) return;
+ if (useBlockingStore.getState().blockingType === 'backend_unavailable') return;
+ confirmInFlight = true;
+ try {
+ const reachable = await pingBackend();
+ if (!reachable) {
+ useBlockingStore.getState().setBackendUnavailable();
+ }
+ } finally {
+ confirmInFlight = false;
+ }
+}
diff --git a/src/components/blocking/ServiceUnavailableScreen.tsx b/src/components/blocking/ServiceUnavailableScreen.tsx
new file mode 100644
index 0000000..e5fa9a3
--- /dev/null
+++ b/src/components/blocking/ServiceUnavailableScreen.tsx
@@ -0,0 +1,156 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useQueryClient } from '@tanstack/react-query';
+import { useBlockingStore } from '../../store/blocking';
+import { useFocusTrap } from '../../hooks/useFocusTrap';
+import { pingBackend, hasEverReachedBackend } from '../../api/health';
+import { CloudWarningIcon, RestartIcon } from '@/components/icons';
+
+const POLL_INTERVAL_MS = 5000;
+
+/**
+ * Full-screen state shown when the backend is unreachable (transport-level
+ * failure), replacing the blank loader the app used to get stuck on. Polls the
+ * liveness endpoint and, once the backend answers again, reloads to re-bootstrap
+ * cleanly from whatever state the failed boot left behind. A manual retry button
+ * lets the user force an immediate check.
+ */
+export default function ServiceUnavailableScreen() {
+ const { t } = useTranslation();
+ const clearBlocking = useBlockingStore((state) => state.clearBlocking);
+ const queryClient = useQueryClient();
+ const [isChecking, setIsChecking] = useState(false);
+ const isCheckingRef = useRef(false);
+
+ const recover = useCallback(() => {
+ clearBlocking();
+ if (hasEverReachedBackend()) {
+ // The app was already loaded and merely covered by this overlay — its
+ // routes/forms are still mounted with their state. Lift the overlay and
+ // refetch instead of a hard reload that would discard unsaved input.
+ void queryClient.invalidateQueries();
+ } else {
+ // The initial bootstrap never reached the backend (blank loader) — reload
+ // to re-bootstrap cleanly now that it is back.
+ window.location.reload();
+ }
+ }, [clearBlocking, queryClient]);
+
+ // Manual retry: immediate probe with a visible checking state.
+ const handleRetry = useCallback(async () => {
+ if (isCheckingRef.current) return;
+ isCheckingRef.current = true;
+ setIsChecking(true);
+ try {
+ if (await pingBackend()) {
+ recover();
+ }
+ } finally {
+ isCheckingRef.current = false;
+ setIsChecking(false);
+ }
+ }, [recover]);
+
+ // Auto-recovery: probe immediately on mount, then every POLL_INTERVAL_MS.
+ useEffect(() => {
+ let cancelled = false;
+ const tick = async () => {
+ if (cancelled) return;
+ if (await pingBackend()) {
+ if (!cancelled) recover();
+ }
+ };
+ void tick();
+ const id = setInterval(tick, POLL_INTERVAL_MS);
+ return () => {
+ cancelled = true;
+ clearInterval(id);
+ };
+ }, [recover]);
+
+ const screenRef = useFocusTrap(true, { lockScroll: false });
+
+ return (
+