131 lines
5.0 KiB
TypeScript
131 lines
5.0 KiB
TypeScript
|
|
import { useQuery } from '@tanstack/react-query';
|
||
|
|
import { useTranslation } from 'react-i18next';
|
||
|
|
import { Link } from 'react-router';
|
||
|
|
|
||
|
|
import { fetchReadiness, readyQueryKey } from '@/api/health';
|
||
|
|
import { fetchMeta, metaQueryKey } from '@/api/meta';
|
||
|
|
import { Badge, Button, Card } from '@/components/ui/primitives';
|
||
|
|
import { StatBytes, StatRow } from '@/components/ui/StatRow';
|
||
|
|
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
||
|
|
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||
|
|
import { useUiStore } from '@/stores/ui';
|
||
|
|
|
||
|
|
/** Публичная страница состояния инстанса: мета, healthz/readyz, тема и язык. */
|
||
|
|
export default function StatusPage() {
|
||
|
|
const { t, i18n } = useTranslation();
|
||
|
|
const describeError = useApiErrorMessage();
|
||
|
|
const theme = useUiStore((state) => state.theme);
|
||
|
|
const toggleTheme = useUiStore((state) => state.toggleTheme);
|
||
|
|
const locale = i18n.resolvedLanguage ?? 'ru';
|
||
|
|
|
||
|
|
const meta = useQuery({
|
||
|
|
queryKey: metaQueryKey,
|
||
|
|
queryFn: ({ signal }) => fetchMeta(signal),
|
||
|
|
retry: 1,
|
||
|
|
});
|
||
|
|
|
||
|
|
const readiness = useQuery({
|
||
|
|
queryKey: readyQueryKey,
|
||
|
|
queryFn: ({ signal }) => fetchReadiness(signal),
|
||
|
|
retry: 1,
|
||
|
|
refetchInterval: 30_000,
|
||
|
|
});
|
||
|
|
|
||
|
|
const online = readiness.isSuccess || meta.isSuccess;
|
||
|
|
const schemaReady = readiness.data?.checks['database'] === 'ok';
|
||
|
|
|
||
|
|
const changeLanguage = (language: SupportedLanguage): void => {
|
||
|
|
window.localStorage.setItem('glchat.language', language);
|
||
|
|
void i18n.changeLanguage(language);
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col gap-6 px-4 py-10">
|
||
|
|
<header className="flex flex-wrap items-center justify-between gap-4">
|
||
|
|
<div>
|
||
|
|
<h1 className="text-2xl font-semibold tracking-tight">{t('app.name')}</h1>
|
||
|
|
<p className="mt-1 text-fg-muted">{t('app.tagline')}</p>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Badge aria-live="polite" data-testid="connection-state">
|
||
|
|
<span
|
||
|
|
aria-hidden="true"
|
||
|
|
className={`h-2 w-2 rounded-full ${online ? 'bg-success' : 'bg-danger'}`}
|
||
|
|
/>
|
||
|
|
{t(online ? 'health.online' : 'health.offline')}
|
||
|
|
</Badge>
|
||
|
|
<Button variant="ghost" onClick={toggleTheme}>
|
||
|
|
{t(theme === 'dark' ? 'theme.switchToLight' : 'theme.switchToDark')}
|
||
|
|
</Button>
|
||
|
|
<label className="flex items-center gap-1 text-sm text-fg-muted">
|
||
|
|
<span className="sr-only">{t('language.label')}</span>
|
||
|
|
<select
|
||
|
|
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-2 py-1 text-fg"
|
||
|
|
value={locale.startsWith('en') ? 'en' : 'ru'}
|
||
|
|
onChange={(event) => changeLanguage(event.target.value === 'en' ? 'en' : 'ru')}
|
||
|
|
>
|
||
|
|
{supportedLanguages.map((language) => (
|
||
|
|
<option key={language} value={language}>
|
||
|
|
{t(`language.${language}`)}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<Card>
|
||
|
|
<h2 className="text-lg font-semibold">{t('status.title')}</h2>
|
||
|
|
<p className="mt-1 text-fg-muted">{t('status.description')}</p>
|
||
|
|
|
||
|
|
{meta.isPending ? (
|
||
|
|
<p className="mt-4 text-fg-muted" role="status">
|
||
|
|
{t('status.connecting')}
|
||
|
|
</p>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
{meta.isError ? (
|
||
|
|
<div className="mt-4 flex items-center justify-between gap-4" role="alert">
|
||
|
|
<p className="text-danger">{describeError(meta.error)}</p>
|
||
|
|
<Button variant="ghost" onClick={() => void meta.refetch()}>
|
||
|
|
{t('status.retry')}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
{meta.data ? (
|
||
|
|
<dl className="mt-4 text-sm">
|
||
|
|
<StatRow label={t('status.instance')} value={meta.data.name} />
|
||
|
|
<StatRow label={t('status.version')} value={meta.data.version} />
|
||
|
|
<StatRow label={t('status.apiVersion')} value={meta.data.api_version} />
|
||
|
|
<StatRow
|
||
|
|
label={t('status.schemaReady')}
|
||
|
|
value={schemaReady ? t('status.schemaOk') : (readiness.data?.status ?? '…')}
|
||
|
|
/>
|
||
|
|
<StatRow
|
||
|
|
label={t('status.maxUpload')}
|
||
|
|
value={<StatBytes bytes={meta.data.max_upload_size} locale={locale} />}
|
||
|
|
/>
|
||
|
|
</dl>
|
||
|
|
) : null}
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<div className="flex flex-wrap gap-3">
|
||
|
|
<Link className="text-sm text-accent underline" to="/login">
|
||
|
|
{t('auth.login.title')}
|
||
|
|
</Link>
|
||
|
|
<Link className="text-sm text-accent underline" to="/register">
|
||
|
|
{t('auth.register.title')}
|
||
|
|
</Link>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<footer className="mt-auto flex items-center justify-between text-xs text-fg-muted">
|
||
|
|
<span>
|
||
|
|
{t('app.name')} · {t('footer.license')}
|
||
|
|
</span>
|
||
|
|
<span className="tabular-nums">{meta.data?.commit ?? ''}</span>
|
||
|
|
</footer>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|