1b1a679827
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).
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>
|
|
);
|
|
}
|