From ac8a0fc41aff4065fdf1dde0bbc47694bfd1f010 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Thu, 4 Jun 2026 12:44:27 +0300 Subject: [PATCH] feat(cabinet): recoverable "service unavailable" screen when the backend is unreachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the backend was down the cabinet got stuck on a blank loader: the bootstrap token refresh used bare axios with no timeout (hung forever), and every interceptor guard required an HTTP response — so a transport-level failure had zero handling and no error UI ever appeared. Add a full-screen ServiceUnavailableScreen (extends the existing blocking-store pattern) shown whenever the backend is unreachable, that auto-recovers when it returns: - New 'backend_unavailable' blocking type + BlockingOverlay branch; mirrors MaintenanceScreen, CloudWarningIcon, i18n in ru/en/zh/fa. - api/health.ts: pingBackend() probes the root /health/unified (bypasses the /api baseURL + interceptor); URL derived from the origin for remote/sub-path deploys; 502/503/504 count as down. reportPossibleBackendDown() confirms an outage with a liveness probe before flipping, so a one-off blip never blanks a loaded app. - Detected from the response interceptor (no-response) and the bootstrap refresh path; doRefresh gets a timeout so it can no longer hang. - A transport failure during refresh now PRESERVES the session (distinguished from a rejected token via lastFailureWasTransport) instead of logging the user out, so recovery actually resumes. - Recovery lifts the overlay + refetches for an already-loaded session, and only hard-reloads when the initial bootstrap never reached the backend — no lost form state. Manual retry + 5s auto-poll. Dev proxy for /health. --- src/App.tsx | 5 + src/api/client.ts | 30 +++- src/api/health.ts | 99 +++++++++++ .../blocking/ServiceUnavailableScreen.tsx | 156 ++++++++++++++++++ src/components/blocking/index.ts | 1 + src/components/icons/extended-icons.tsx | 5 + src/locales/en.json | 7 + src/locales/fa.json | 7 + src/locales/ru.json | 7 + src/locales/zh.json | 7 + src/store/auth.ts | 10 ++ src/store/blocking.ts | 13 ++ src/utils/token.ts | 31 +++- src/vite-env.d.ts | 2 + vite.config.ts | 7 + 15 files changed, 384 insertions(+), 3 deletions(-) create mode 100644 src/api/health.ts create mode 100644 src/components/blocking/ServiceUnavailableScreen.tsx 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 ( +
+
+ {/* Icon */} +
+
+ +
+
+ + {/* Title */} +

+ {t('blocking.serviceUnavailable.title')} +

+ + {/* Message */} +

{t('blocking.serviceUnavailable.description')}

+ + {/* Retry button */} + + + {/* Decorative dots */} +
+
+
+
+
+ +

{t('blocking.serviceUnavailable.hint')}

+
+
+ ); +} diff --git a/src/components/blocking/index.ts b/src/components/blocking/index.ts index 94bced2..041e04c 100644 --- a/src/components/blocking/index.ts +++ b/src/components/blocking/index.ts @@ -2,3 +2,4 @@ export { default as MaintenanceScreen } from './MaintenanceScreen'; export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen'; export { default as BlacklistedScreen } from './BlacklistedScreen'; export { default as AccountDeletedScreen } from './AccountDeletedScreen'; +export { default as ServiceUnavailableScreen } from './ServiceUnavailableScreen'; diff --git a/src/components/icons/extended-icons.tsx b/src/components/icons/extended-icons.tsx index 104eae1..efd452f 100644 --- a/src/components/icons/extended-icons.tsx +++ b/src/components/icons/extended-icons.tsx @@ -49,6 +49,7 @@ import { PiFlag, PiArrowCounterClockwise, PiArrowClockwise, + PiCloudWarning, PiRocket, PiFloppyDisk, PiPaperPlaneTilt, @@ -361,6 +362,10 @@ export const RestartIcon = ({ className }: IconProps) => ( ); +export const CloudWarningIcon = ({ className }: IconProps) => ( + +); + export const RocketIcon = ({ className }: IconProps) => ( ); diff --git a/src/locales/en.json b/src/locales/en.json index 7c90329..8243d4b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -4810,6 +4810,13 @@ "openBot": "Open the bot", "retry": "I pressed /start, retry", "hint": "After running /start, return here and tap «Retry»." + }, + "serviceUnavailable": { + "title": "Service unavailable", + "description": "We can't reach the service right now. It may be undergoing maintenance or there's a temporary connection issue.", + "retry": "Try again", + "checking": "Checking connection...", + "hint": "This page will refresh automatically as soon as the service is back." } }, "merge": { diff --git a/src/locales/fa.json b/src/locales/fa.json index 5de19d6..82b72dc 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -4470,6 +4470,13 @@ "openBot": "باز کردن ربات", "retry": "من /start را زدم، دوباره امتحان کن", "hint": "پس از اجرای /start، به اینجا بازگردید و «دوباره امتحان کنید» را بزنید." + }, + "serviceUnavailable": { + "title": "سرویس در دسترس نیست", + "description": "در حال حاضر امکان اتصال به سرویس وجود ندارد. ممکن است در حال تعمیر باشد یا مشکل موقتی در اتصال وجود داشته باشد.", + "retry": "تلاش مجدد", + "checking": "در حال بررسی اتصال...", + "hint": "به‌محض در دسترس قرار گرفتن سرویس، این صفحه به‌طور خودکار بازخوانی می‌شود." } }, "merge": { diff --git a/src/locales/ru.json b/src/locales/ru.json index fda4bbf..01fd8ab 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -5365,6 +5365,13 @@ "openBot": "Открыть бота", "retry": "Я нажал /start, попробовать снова", "hint": "После /start вернитесь сюда и нажмите «Попробовать снова»." + }, + "serviceUnavailable": { + "title": "Сервис недоступен", + "description": "Не удаётся подключиться к сервису. Возможно, идут технические работы или временные неполадки со связью.", + "retry": "Повторить попытку", + "checking": "Проверяем соединение...", + "hint": "Страница обновится автоматически, как только сервис снова станет доступен." } }, "merge": { diff --git a/src/locales/zh.json b/src/locales/zh.json index 9603d6d..19e7ca5 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -4351,6 +4351,13 @@ "openBot": "打开机器人", "retry": "我已按 /start,重试", "hint": "运行 /start 后,返回此处并点击「重试」。" + }, + "serviceUnavailable": { + "title": "服务不可用", + "description": "暂时无法连接到服务。可能正在进行维护,或存在临时的网络问题。", + "retry": "重试", + "checking": "正在检查连接...", + "hint": "服务恢复后,本页面将自动刷新。" } }, "banSystem": { diff --git a/src/store/auth.ts b/src/store/auth.ts index cfcf23f..4f9395c 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -217,6 +217,11 @@ export const useAuthStore = create()( const user = await authApi.getMe(); await get().checkAdminStatus(); applySession(newToken, refreshToken, user); + } else if (tokenRefreshManager.lastFailureWasTransport) { + // Backend unreachable, not a rejected token — keep the session + // (don't wipe tokens) so the ServiceUnavailableScreen can resume + // once the backend returns. Just stop the bootstrap loader. + set({ isLoading: false }); } else { clearSession(); } @@ -237,6 +242,11 @@ export const useAuthStore = create()( } catch { clearSession(); } + } else if (tokenRefreshManager.lastFailureWasTransport) { + // Backend unreachable, not a rejected token — keep the session + // (don't wipe tokens) so the ServiceUnavailableScreen can resume + // once the backend returns. Just stop the bootstrap loader. + set({ isLoading: false }); } else { clearSession(); } diff --git a/src/store/blocking.ts b/src/store/blocking.ts index 160f371..41d0cba 100644 --- a/src/store/blocking.ts +++ b/src/store/blocking.ts @@ -5,6 +5,7 @@ export type BlockingType = | 'channel_subscription' | 'blacklisted' | 'account_deleted' + | 'backend_unavailable' | null; interface MaintenanceInfo { @@ -54,6 +55,9 @@ interface BlockingState { setChannelSubscription: (info: ChannelSubscriptionInfo) => void; setBlacklisted: (info: BlacklistedInfo) => void; setAccountDeleted: (info: AccountDeletedInfo) => void; + /** Backend is unreachable (transport-level failure). Renders the full-screen + * ServiceUnavailableScreen. No info payload — the screen shows static copy. */ + setBackendUnavailable: () => void; clearBlocking: () => void; } @@ -100,6 +104,15 @@ export const useBlockingStore = create((set) => ({ blacklistedInfo: null, }), + setBackendUnavailable: () => + set({ + blockingType: 'backend_unavailable', + maintenanceInfo: null, + channelInfo: null, + blacklistedInfo: null, + accountDeletedInfo: null, + }), + clearBlocking: () => set({ blockingType: null, diff --git a/src/utils/token.ts b/src/utils/token.ts index ad175ed..5ebbe0c 100644 --- a/src/utils/token.ts +++ b/src/utils/token.ts @@ -5,6 +5,8 @@ import { deleteCloudStorageItem, } from '@telegram-apps/sdk-react'; import { isInTelegramWebApp } from '../hooks/useTelegramSDK'; +import { API } from '../config/constants'; +import { reportPossibleBackendDown } from '../api/health'; const TOKEN_KEYS = { ACCESS: 'access_token', @@ -205,6 +207,17 @@ class TokenRefreshManager { private subscribers: ((token: string | null) => void)[] = []; private refreshEndpoint = '/api/cabinet/auth/refresh'; + /** + * True when the most recent refresh failed at the TRANSPORT level (backend + * unreachable / timeout) rather than because the refresh token was rejected. + * Callers read this right after a null `refreshAccessToken()` to decide whether + * to destroy the session (rejected token) or KEEP it so the recoverable + * ServiceUnavailableScreen can resume once the backend returns. Synchronously + * accurate: it is set inside doRefresh before the shared promise resolves, so + * every awaiter (including deduped concurrent callers) reads a consistent value. + */ + lastFailureWasTransport = false; + setRefreshEndpoint(endpoint: string): void { this.refreshEndpoint = endpoint; } @@ -214,6 +227,8 @@ class TokenRefreshManager { return this.refreshPromise; } + this.lastFailureWasTransport = false; + const refreshToken = tokenStorage.getRefreshToken(); if (!refreshToken) { return null; @@ -238,7 +253,10 @@ class TokenRefreshManager { const response = await axios.post<{ access_token?: string }>( this.refreshEndpoint, { refresh_token: refreshToken }, - { headers: { 'Content-Type': 'application/json' } }, + // Without an explicit timeout this inherits axios's default of 0 (no + // timeout): on the persisted-token bootstrap path an unreachable backend + // would hang the refresh indefinitely, pinning the app on a blank loader. + { headers: { 'Content-Type': 'application/json' }, timeout: API.TIMEOUT_MS }, ); const newAccessToken = response.data.access_token; @@ -249,7 +267,16 @@ class TokenRefreshManager { } return null; - } catch { + } catch (err) { + // This bare-axios call bypasses the apiClient interceptor, so a dead + // backend on the refresh-only bootstrap path would otherwise be swallowed + // here and silently fall through to a login redirect. Distinguish a + // transport-level failure (no response) from an invalid/expired token (a + // 4xx response) and surface the service-unavailable screen for the former. + if (axios.isAxiosError(err) && !err.response) { + this.lastFailureWasTransport = true; + void reportPossibleBackendDown(); + } return null; } } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 2e44efe..50297fe 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -7,6 +7,8 @@ interface ImportMetaEnv { readonly VITE_TELEGRAM_BOT_USERNAME?: string; readonly VITE_APP_NAME?: string; readonly VITE_APP_LOGO?: string; + /** Optional override for the backend liveness URL (defaults to `/health/unified`). */ + readonly VITE_HEALTH_URL?: string; } interface ImportMeta { diff --git a/vite.config.ts b/vite.config.ts index f0cb434..923e3b6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -27,6 +27,13 @@ export default defineConfig({ // Strip /api prefix: /api/cabinet/auth -> /cabinet/auth rewrite: (path) => path.replace(/^\/api/, ''), }, + // The backend serves its liveness endpoint at the host root (not under + // /api). Proxy it too so the "service unavailable" detection probe hits the + // real backend in dev instead of the Vite server (which would mask outages). + '/health': { + target: 'http://localhost:8080', + changeOrigin: true, + }, }, }, build: {