Skip to content
Clone
2687ace
95 lines 3.7 KB
import type { ReactNode } from 'react'
import { Loader2 } from 'lucide-react'
import { API_PROBE_INTERVAL_MS, retryApiHealth, useApiHealth, useApiUnavailable } from '@/shared/api'
import { useT } from '@/shared/i18n'
/**
* App-wide「api error」intermediate state.
*
* `ApiErrorGate` sits above the router: while the rolling failure rate stays
* above the threshold in `@/shared/api/health` it replaces the whole tree with
* the outage screen, so a dead backend reads as one clear page instead of a
* grid of half-rendered panels each showing its own error.
*
* Unmounting is also the recovery mechanism: the health store probes the
* service on its own, and as soon as one request answers, the tree remounts and
* every view refetches through `useAsync` — no document reload involved.
*/
export function ApiErrorGate({ children }: { children: ReactNode }) {
const unavailable = useApiUnavailable()
if (unavailable) return <ServiceUnavailableScreen />
return <>{children}</>
}
function ServiceUnavailableScreen() {
const { t } = useT()
const {
failures,
samples,
errorRate,
burstFailures,
lastFailureStatus,
lastFailureUrl,
probing,
lastProbeAt,
} = useApiHealth()
const percent = Math.round(errorRate * 100)
const path = lastFailureUrl ? lastFailureUrl.replace(/^https?:\/\/[^/]+/, '') : null
/** Failure reason in words: status 0 / transport error means "no answer". */
const failureLabel =
lastFailureStatus === null
? t('error.gate.noResponse')
: lastFailureStatus === 0
? t('error.gate.networkError')
: lastFailureStatus >= 500
? t('error.gate.serverError', { status: lastFailureStatus })
: `HTTP ${lastFailureStatus}`
return (
<main
role="alert"
aria-live="assertive"
className="flex h-dvh w-full flex-col items-center justify-center gap-5 overflow-y-auto bg-shell-surface px-6 py-10 text-center text-shell-ink"
>
<img
src="/wronged.png"
alt={t('error.gate.imageAlt')}
draggable={false}
className="max-h-[38vh] w-auto max-w-[min(420px,72vw)] shrink-0 select-none object-contain"
/>
<div className="flex max-w-md flex-col gap-1.5">
<h1 className="text-[17px] font-semibold tracking-tight">{t('error.gate.title')}</h1>
<p className="text-[13px] leading-relaxed text-shell-ink-muted">
{t('error.gate.body', { count: burstFailures })}
</p>
</div>
<p className="max-w-[min(560px,92vw)] truncate font-mono text-[11px] text-shell-ink-muted">
{t('error.gate.lastFailure', {
reason: failureLabel,
path: path ? ` · ${path}` : '',
percent,
failures,
samples,
})}
</p>
<div className="justify-self-center">
<button
type="button"
onClick={retryApiHealth}
disabled={probing}
className="inline-flex h-9 min-w-32 items-center justify-center gap-2 rounded-field bg-primary px-4 font-mono text-sm font-bold tracking-[0.4px] text-primary-foreground transition-[filter] hover:brightness-95 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50"
>
{probing ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
{probing ? t('error.gate.connecting') : t('error.gate.retry')}
</button>
</div>
<p className="text-[11px] text-shell-ink-muted">
{t('error.gate.probeInterval', { seconds: Math.round(API_PROBE_INTERVAL_MS / 1000) })}
{lastProbeAt
? ` · ${t('error.gate.lastProbe', { time: new Date(lastProbeAt).toLocaleTimeString([], { hour12: false }) })}`
: ''}
</p>
</main>
)
}