feat(cabinet): recoverable "service unavailable" screen when the backend is unreachable

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.
This commit is contained in:
c0mrade
2026-06-04 12:44:27 +03:00
parent f7bd36a95e
commit ac8a0fc41a
15 changed files with 384 additions and 3 deletions

View File

@@ -28,6 +28,7 @@ import {
ChannelSubscriptionScreen, ChannelSubscriptionScreen,
BlacklistedScreen, BlacklistedScreen,
AccountDeletedScreen, AccountDeletedScreen,
ServiceUnavailableScreen,
} from './components/blocking'; } from './components/blocking';
import { ErrorBoundary } from './components/ErrorBoundary'; import { ErrorBoundary } from './components/ErrorBoundary';
import { PermissionRoute } from '@/components/auth/PermissionRoute'; import { PermissionRoute } from '@/components/auth/PermissionRoute';
@@ -232,6 +233,10 @@ function BlockingOverlay() {
return <AccountDeletedScreen />; return <AccountDeletedScreen />;
} }
if (blockingType === 'backend_unavailable') {
return <ServiceUnavailableScreen />;
}
return null; return null;
} }

View File

@@ -7,6 +7,7 @@ import {
safeRedirectToLogin, safeRedirectToLogin,
} from '../utils/token'; } from '../utils/token';
import { useBlockingStore } from '../store/blocking'; import { useBlockingStore } from '../store/blocking';
import { reportPossibleBackendDown, markBackendReached } from './health';
import { API } from '../config/constants'; import { API } from '../config/constants';
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'; 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(); const newToken = await tokenRefreshManager.refreshAccessToken();
if (newToken) { if (newToken) {
token = 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 { } else {
tokenStorage.clearTokens(); tokenStorage.clearTokens();
safeRedirectToLogin(); safeRedirectToLogin();
@@ -195,10 +202,28 @@ export function isAccountDeletedError(
} }
apiClient.interceptors.response.use( 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) => { async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; 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)) { if (isMaintenanceError(error)) {
const detail = (error.response?.data as { detail: MaintenanceError }).detail; const detail = (error.response?.data as { detail: MaintenanceError }).detail;
useBlockingStore.getState().setMaintenance({ useBlockingStore.getState().setMaintenance({
@@ -256,6 +281,9 @@ apiClient.interceptors.response.use(
originalRequest.headers.Authorization = `Bearer ${newToken}`; originalRequest.headers.Authorization = `Bearer ${newToken}`;
} }
return apiClient(originalRequest); 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 { } else {
tokenStorage.clearTokens(); tokenStorage.clearTokens();
safeRedirectToLogin(); safeRedirectToLogin();

99
src/api/health.ts Normal file
View File

@@ -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<boolean> {
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<void> {
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;
}
}

View File

@@ -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<HTMLDivElement>(true, { lockScroll: false });
return (
<div
ref={screenRef}
role="alertdialog"
aria-modal="true"
aria-labelledby="service-unavailable-title"
tabIndex={-1}
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6"
>
<div className="w-full max-w-md text-center">
{/* Icon */}
<div className="mb-8">
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
<CloudWarningIcon className="h-12 w-12 text-warning-500" />
</div>
</div>
{/* Title */}
<h1 id="service-unavailable-title" className="mb-4 text-2xl font-bold text-white">
{t('blocking.serviceUnavailable.title')}
</h1>
{/* Message */}
<p className="mb-6 text-lg text-dark-400">{t('blocking.serviceUnavailable.description')}</p>
{/* Retry button */}
<button
onClick={handleRetry}
disabled={isChecking}
className="flex w-full items-center justify-center gap-3 rounded-xl bg-dark-800 px-6 py-4 font-semibold text-white transition-all duration-200 hover:bg-dark-700 disabled:bg-dark-800 disabled:opacity-60"
>
{isChecking ? (
<>
<svg
className="h-5 w-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{t('blocking.serviceUnavailable.checking')}
</>
) : (
<>
<RestartIcon className="h-5 w-5" />
{t('blocking.serviceUnavailable.retry')}
</>
)}
</button>
{/* Decorative dots */}
<div className="mt-8 flex items-center justify-center gap-2">
<div
className="h-2 w-2 animate-pulse rounded-full bg-warning-500"
style={{ animationDelay: '0ms' }}
/>
<div
className="h-2 w-2 animate-pulse rounded-full bg-warning-500"
style={{ animationDelay: '300ms' }}
/>
<div
className="h-2 w-2 animate-pulse rounded-full bg-warning-500"
style={{ animationDelay: '600ms' }}
/>
</div>
<p className="mt-4 text-sm text-dark-500">{t('blocking.serviceUnavailable.hint')}</p>
</div>
</div>
);
}

View File

@@ -2,3 +2,4 @@ export { default as MaintenanceScreen } from './MaintenanceScreen';
export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen'; export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen';
export { default as BlacklistedScreen } from './BlacklistedScreen'; export { default as BlacklistedScreen } from './BlacklistedScreen';
export { default as AccountDeletedScreen } from './AccountDeletedScreen'; export { default as AccountDeletedScreen } from './AccountDeletedScreen';
export { default as ServiceUnavailableScreen } from './ServiceUnavailableScreen';

View File

@@ -49,6 +49,7 @@ import {
PiFlag, PiFlag,
PiArrowCounterClockwise, PiArrowCounterClockwise,
PiArrowClockwise, PiArrowClockwise,
PiCloudWarning,
PiRocket, PiRocket,
PiFloppyDisk, PiFloppyDisk,
PiPaperPlaneTilt, PiPaperPlaneTilt,
@@ -361,6 +362,10 @@ export const RestartIcon = ({ className }: IconProps) => (
<PiArrowClockwise className={cn('h-5 w-5', className)} /> <PiArrowClockwise className={cn('h-5 w-5', className)} />
); );
export const CloudWarningIcon = ({ className }: IconProps) => (
<PiCloudWarning className={cn('h-5 w-5', className)} />
);
export const RocketIcon = ({ className }: IconProps) => ( export const RocketIcon = ({ className }: IconProps) => (
<PiRocket className={cn('h-5 w-5', className)} /> <PiRocket className={cn('h-5 w-5', className)} />
); );

View File

@@ -4810,6 +4810,13 @@
"openBot": "Open the bot", "openBot": "Open the bot",
"retry": "I pressed /start, retry", "retry": "I pressed /start, retry",
"hint": "After running /start, return here and tap «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": { "merge": {

View File

@@ -4470,6 +4470,13 @@
"openBot": "باز کردن ربات", "openBot": "باز کردن ربات",
"retry": "من /start را زدم، دوباره امتحان کن", "retry": "من /start را زدم، دوباره امتحان کن",
"hint": "پس از اجرای /start، به اینجا بازگردید و «دوباره امتحان کنید» را بزنید." "hint": "پس از اجرای /start، به اینجا بازگردید و «دوباره امتحان کنید» را بزنید."
},
"serviceUnavailable": {
"title": "سرویس در دسترس نیست",
"description": "در حال حاضر امکان اتصال به سرویس وجود ندارد. ممکن است در حال تعمیر باشد یا مشکل موقتی در اتصال وجود داشته باشد.",
"retry": "تلاش مجدد",
"checking": "در حال بررسی اتصال...",
"hint": "به‌محض در دسترس قرار گرفتن سرویس، این صفحه به‌طور خودکار بازخوانی می‌شود."
} }
}, },
"merge": { "merge": {

View File

@@ -5365,6 +5365,13 @@
"openBot": "Открыть бота", "openBot": "Открыть бота",
"retry": "Я нажал /start, попробовать снова", "retry": "Я нажал /start, попробовать снова",
"hint": "После /start вернитесь сюда и нажмите «Попробовать снова»." "hint": "После /start вернитесь сюда и нажмите «Попробовать снова»."
},
"serviceUnavailable": {
"title": "Сервис недоступен",
"description": "Не удаётся подключиться к сервису. Возможно, идут технические работы или временные неполадки со связью.",
"retry": "Повторить попытку",
"checking": "Проверяем соединение...",
"hint": "Страница обновится автоматически, как только сервис снова станет доступен."
} }
}, },
"merge": { "merge": {

View File

@@ -4351,6 +4351,13 @@
"openBot": "打开机器人", "openBot": "打开机器人",
"retry": "我已按 /start重试", "retry": "我已按 /start重试",
"hint": "运行 /start 后,返回此处并点击「重试」。" "hint": "运行 /start 后,返回此处并点击「重试」。"
},
"serviceUnavailable": {
"title": "服务不可用",
"description": "暂时无法连接到服务。可能正在进行维护,或存在临时的网络问题。",
"retry": "重试",
"checking": "正在检查连接...",
"hint": "服务恢复后,本页面将自动刷新。"
} }
}, },
"banSystem": { "banSystem": {

View File

@@ -217,6 +217,11 @@ export const useAuthStore = create<AuthState>()(
const user = await authApi.getMe(); const user = await authApi.getMe();
await get().checkAdminStatus(); await get().checkAdminStatus();
applySession(newToken, refreshToken, user); 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 { } else {
clearSession(); clearSession();
} }
@@ -237,6 +242,11 @@ export const useAuthStore = create<AuthState>()(
} catch { } catch {
clearSession(); 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 { } else {
clearSession(); clearSession();
} }

View File

@@ -5,6 +5,7 @@ export type BlockingType =
| 'channel_subscription' | 'channel_subscription'
| 'blacklisted' | 'blacklisted'
| 'account_deleted' | 'account_deleted'
| 'backend_unavailable'
| null; | null;
interface MaintenanceInfo { interface MaintenanceInfo {
@@ -54,6 +55,9 @@ interface BlockingState {
setChannelSubscription: (info: ChannelSubscriptionInfo) => void; setChannelSubscription: (info: ChannelSubscriptionInfo) => void;
setBlacklisted: (info: BlacklistedInfo) => void; setBlacklisted: (info: BlacklistedInfo) => void;
setAccountDeleted: (info: AccountDeletedInfo) => 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; clearBlocking: () => void;
} }
@@ -100,6 +104,15 @@ export const useBlockingStore = create<BlockingState>((set) => ({
blacklistedInfo: null, blacklistedInfo: null,
}), }),
setBackendUnavailable: () =>
set({
blockingType: 'backend_unavailable',
maintenanceInfo: null,
channelInfo: null,
blacklistedInfo: null,
accountDeletedInfo: null,
}),
clearBlocking: () => clearBlocking: () =>
set({ set({
blockingType: null, blockingType: null,

View File

@@ -5,6 +5,8 @@ import {
deleteCloudStorageItem, deleteCloudStorageItem,
} from '@telegram-apps/sdk-react'; } from '@telegram-apps/sdk-react';
import { isInTelegramWebApp } from '../hooks/useTelegramSDK'; import { isInTelegramWebApp } from '../hooks/useTelegramSDK';
import { API } from '../config/constants';
import { reportPossibleBackendDown } from '../api/health';
const TOKEN_KEYS = { const TOKEN_KEYS = {
ACCESS: 'access_token', ACCESS: 'access_token',
@@ -205,6 +207,17 @@ class TokenRefreshManager {
private subscribers: ((token: string | null) => void)[] = []; private subscribers: ((token: string | null) => void)[] = [];
private refreshEndpoint = '/api/cabinet/auth/refresh'; 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 { setRefreshEndpoint(endpoint: string): void {
this.refreshEndpoint = endpoint; this.refreshEndpoint = endpoint;
} }
@@ -214,6 +227,8 @@ class TokenRefreshManager {
return this.refreshPromise; return this.refreshPromise;
} }
this.lastFailureWasTransport = false;
const refreshToken = tokenStorage.getRefreshToken(); const refreshToken = tokenStorage.getRefreshToken();
if (!refreshToken) { if (!refreshToken) {
return null; return null;
@@ -238,7 +253,10 @@ class TokenRefreshManager {
const response = await axios.post<{ access_token?: string }>( const response = await axios.post<{ access_token?: string }>(
this.refreshEndpoint, this.refreshEndpoint,
{ refresh_token: refreshToken }, { 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; const newAccessToken = response.data.access_token;
@@ -249,7 +267,16 @@ class TokenRefreshManager {
} }
return null; 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; return null;
} }
} }

2
src/vite-env.d.ts vendored
View File

@@ -7,6 +7,8 @@ interface ImportMetaEnv {
readonly VITE_TELEGRAM_BOT_USERNAME?: string; readonly VITE_TELEGRAM_BOT_USERNAME?: string;
readonly VITE_APP_NAME?: string; readonly VITE_APP_NAME?: string;
readonly VITE_APP_LOGO?: string; readonly VITE_APP_LOGO?: string;
/** Optional override for the backend liveness URL (defaults to `<origin>/health/unified`). */
readonly VITE_HEALTH_URL?: string;
} }
interface ImportMeta { interface ImportMeta {

View File

@@ -27,6 +27,13 @@ export default defineConfig({
// Strip /api prefix: /api/cabinet/auth -> /cabinet/auth // Strip /api prefix: /api/cabinet/auth -> /cabinet/auth
rewrite: (path) => path.replace(/^\/api/, ''), 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: { build: {