Files
glchat/web/src/pages/StatusPage.tsx
T
grendervill 1b1a679827 feat(api): ручки Фаз(ы) 1 — профиль, серверы, роли, админ инстанса
REST-слой Фазы 1 на huma (OpenAPI 3.1 генерируется из кода):

- профиль: GET/PATCH /users/@me, смена пароля со step-up, публичный профиль,
  завершение онбординга (новая миграция 00003 с onboarding_completed_at);
- серверы: создание/изменение/удаление, join/leave, список серверов
  пользователя, журнал действий;
- комнаты: список с учётом прав, создание/изменение/удаление;
- участники: список с профилями и ролями, никнейм, тайм-аут, исключение;
- роли: CRUD, выдача/снятие с проверкой иерархии и запретом выдачи прав выше
  собственных;
- админ инстанса: публичная информация, настройки, серверы, пользователи,
  аудит, выдача прав администратора со step-up; обход лимитов фиксируется в
  аудите отдельной записью limits.bypass;
- движок прав: участие в сервере стало обязательным условием (IsMember),
  не участник не получает прав роли @user; калькулятор прав общий для API и
  Gateway, инвалидация кэша после изменений;
- Gateway: браузерный клиент аутентифицируется cookie на рукопожатии, IDENTIFY
  без токена использует её; события GUILD/CHANNEL/MEMBER/ROLE рассылаются из
  ручек, USER_UPDATE — адресно;
- ошибки huma отдаются в едином конверте {"error":{"code","message"}}.

Тесты: 8 сценариев API (профиль, жизненный цикл сервера и права, лимиты и
обход админом, иерархия ролей, тайм-аут, скрытие комнаты оверрайдом,
членство в движке прав, cookie-идентификация Gateway).
2026-09-19 21:50:06 +03:00

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>
);
}