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

@@ -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 BlacklistedScreen } from './BlacklistedScreen';
export { default as AccountDeletedScreen } from './AccountDeletedScreen';
export { default as ServiceUnavailableScreen } from './ServiceUnavailableScreen';

View File

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