From e1107600ecda4be128dd06798856d5719a43824f Mon Sep 17 00:00:00 2001 From: grendervill Date: Sun, 20 Sep 2026 21:26:25 +0300 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=B4=D0=B0=D1=88=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=B4=20=D0=B8=D0=BD=D1=81=D1=82=D0=B0=D0=BD=D1=81=D0=B0?= =?UTF-8?q?=20=D0=B2=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA?= =?UTF-8?q?=D0=B0=D1=85=20=D1=81=20=D0=B6=D0=B8=D0=B2=D1=8B=D0=BC=D0=B8=20?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вкладка «Инстанс» сверху показывает нагрузку сервера, ниже — диск и базу, внизу healthcheck с пингом (AGENT.md 7.19). - `Gauge` — полукруглый индикатор: заполняется по значению, меняет цвет на порогах 70 % и 90 %, доступен как `role="meter"`; - процессор и память обновляются раз в секунду (`useInstanceMetrics`), для простоя вкладки опрос останавливается; - два блока диска: «занято / всего» и вес базы (файл + WAL); видимых подписей нет — они раскрываются в тултипе при наведении на значение; - healthcheck: состояние каждой проверки, общий статус, техническая деталь и живой пинг — время ответа инстанса на запрос дашборда в миллисекундах; - переводы ru/en, тесты на индикаторы, формат «занято / всего», тултипы, healthcheck и отсутствие раздела у обычного пользователя. --- web/src/api/metrics.ts | 48 +++++ .../components/instance/InstanceDashboard.tsx | 107 ++++++++++ .../components/instance/InstanceHealth.tsx | 126 ++++++++++++ web/src/components/ui/Gauge.tsx | 102 ++++++++++ web/src/i18n/locales/en.json | 43 ++++- web/src/i18n/locales/ru.json | 43 ++++- web/src/lib/useInstanceMetrics.ts | 57 ++++++ .../pages/settings/InstanceSettingsPage.tsx | 9 + web/tests/instanceDashboard.test.tsx | 182 ++++++++++++++++++ 9 files changed, 715 insertions(+), 2 deletions(-) create mode 100644 web/src/api/metrics.ts create mode 100644 web/src/components/instance/InstanceDashboard.tsx create mode 100644 web/src/components/instance/InstanceHealth.tsx create mode 100644 web/src/components/ui/Gauge.tsx create mode 100644 web/src/lib/useInstanceMetrics.ts create mode 100644 web/tests/instanceDashboard.test.tsx diff --git a/web/src/api/metrics.ts b/web/src/api/metrics.ts new file mode 100644 index 0000000..febcd59 --- /dev/null +++ b/web/src/api/metrics.ts @@ -0,0 +1,48 @@ +import { request } from './client'; + +/** Живые метрики инстанса для дашборда в настройках (AGENT.md 7.19). */ +export interface InstanceMetrics { + cpu: { + /** Загрузка в процентах от доступного времени (лимит контейнера или ядра). */ + percent: number; + cores: number; + source: 'cgroup' | 'host' | ''; + }; + memory: { + used_bytes: number; + total_bytes: number; + percent: number; + source: 'cgroup' | 'host' | ''; + }; + disk: { + used_bytes: number; + total_bytes: number; + free_bytes: number; + }; + database: { + bytes: number; + wal_bytes: number; + }; + checks: InstanceCheck[]; + health: 'ok' | 'warn' | 'failed'; + uptime_seconds: number; + version: string; + commit: string; + collected_at: string; +} + +export interface InstanceCheck { + name: string; + status: 'ok' | 'warn' | 'failed' | 'off'; + message?: string; +} + +export const metricsQueryKey = ['instance', 'metrics'] as const; + +export async function fetchInstanceMetrics(signal?: AbortSignal): Promise { + const payload = await request<{ metrics: InstanceMetrics }>( + '/instance/metrics', + signal === undefined ? {} : { signal }, + ); + return payload.metrics; +} diff --git a/web/src/components/instance/InstanceDashboard.tsx b/web/src/components/instance/InstanceDashboard.tsx new file mode 100644 index 0000000..921d89a --- /dev/null +++ b/web/src/components/instance/InstanceDashboard.tsx @@ -0,0 +1,107 @@ +import { useTranslation } from 'react-i18next'; + +import { Card } from '@/components/ui/primitives'; +import { Gauge } from '@/components/ui/Gauge'; +import { formatBytes } from '@/lib/format'; +import type { InstanceMetricsState } from '@/lib/useInstanceMetrics'; + +interface InstanceDashboardProps { + state: InstanceMetricsState; +} + +/** + * Дашборд инстанса: полукруглые индикаторы процессора и памяти, ниже — два + * блока про диск (занято / всего) и вес базы данных. Значения обновляются раз + * в секунду, подписи у блоков диска показываются только при наведении. + */ +export function InstanceDashboard({ state }: InstanceDashboardProps) { + const { t, i18n } = useTranslation(); + const locale = i18n.resolvedLanguage ?? 'ru'; + const metrics = state.metrics; + + return ( + +
+

{t('settings.instance.dashboard.title')}

+ + {t('settings.instance.dashboard.refreshHint')} + +
+ +
+ + +
+ +
+ {/* Подписи не показываем: они раскрываются при наведении на значение. */} + + +
+
+ ); +} + +interface DiskStatProps { + value: string; + title: string; + testId: string; +} + +/** DiskStat — блок с одним значением: подпись доступна только в тултипе. */ +function DiskStat({ value, title, testId }: DiskStatProps) { + return ( +
+ + {value} + +
+ ); +} + +function formatPercent(value: number, locale: string): string { + const formatted = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value); + return `${formatted} %`; +} diff --git a/web/src/components/instance/InstanceHealth.tsx b/web/src/components/instance/InstanceHealth.tsx new file mode 100644 index 0000000..e9d630d --- /dev/null +++ b/web/src/components/instance/InstanceHealth.tsx @@ -0,0 +1,126 @@ +import { useTranslation } from 'react-i18next'; + +import type { InstanceCheck } from '@/api/metrics'; +import { Card } from '@/components/ui/primitives'; +import type { InstanceMetricsState } from '@/lib/useInstanceMetrics'; + +interface InstanceHealthProps { + state: InstanceMetricsState; +} + +/** Порядок проверок в списке: сначала критичные для работы инстанса. */ +const CHECK_ORDER = ['app', 'database', 'disk', 'storage', 'voice', 'gateway', 'metrics']; + +const STATUS_CLASS: Record = { + ok: 'bg-success', + warn: 'bg-warning', + failed: 'bg-danger', + off: 'bg-fg-muted', +}; + +/** + * Healthcheck инстанса и живой пинг: состояние компонентов (приложение, база, + * диск, запись, SFU, Gateway) и время ответа инстанса на запрос дашборда. + */ +export function InstanceHealth({ state }: InstanceHealthProps) { + const { t } = useTranslation(); + const metrics = state.metrics; + const checks = sortChecks(metrics?.checks ?? []); + + return ( + +
+

{t('settings.instance.health.title')}

+
+ + + + {t('settings.instance.health.ping')} + + {state.pingMs === null + ? t('settings.instance.health.pingUnavailable') + : t('settings.instance.health.pingValue', { ms: state.pingMs })} + + +
+
+ + {state.failed && metrics === undefined ? ( +

{t('settings.instance.health.unavailable')}

+ ) : ( +
    + {checks.map((check) => ( +
  • +
  • + ))} +
+ )} +
+ ); +} + +/** sortChecks ставит известные проверки в привычном порядке, прочие — в конец. */ +function sortChecks(checks: InstanceCheck[]): InstanceCheck[] { + return [...checks].sort((left, right) => { + const leftIndex = CHECK_ORDER.indexOf(left.name); + const rightIndex = CHECK_ORDER.indexOf(right.name); + return ( + (leftIndex === -1 ? CHECK_ORDER.length : leftIndex) - + (rightIndex === -1 ? CHECK_ORDER.length : rightIndex) + ); + }); +} + +/** checkName переводит имя проверки, неизвестные имена показываем как есть. */ +function checkName(t: (key: string) => string, name: string): string { + const key = `settings.instance.health.checks.${name}`; + const translated = t(key); + return translated === key ? name : translated; +} + +function statusDotClass(health: string | undefined): string { + switch (health) { + case 'ok': + return 'bg-success'; + case 'warn': + return 'bg-warning'; + case 'failed': + return 'bg-danger'; + default: + return 'bg-fg-muted'; + } +} diff --git a/web/src/components/ui/Gauge.tsx b/web/src/components/ui/Gauge.tsx new file mode 100644 index 0000000..a6f2ac3 --- /dev/null +++ b/web/src/components/ui/Gauge.tsx @@ -0,0 +1,102 @@ +interface GaugeProps { + /** Заполнение в процентах (0…100). */ + value: number; + /** Подпись для скринридера и тултипа. */ + label: string; + /** Крупное значение в центре (например, «23 %»). */ + display: string; + /** Уточнение под значением (например, «1,2 / 3,4 ГБ»). */ + caption?: string | undefined; + /** Порог, после которого индикатор считается нагруженным. */ + warnAt?: number; + dangerAt?: number; + testId?: string; +} + +/** Радиус и параметры полукруга: дуга 180° от левого края к правому. */ +const RADIUS = 62; +const CENTER_X = 80; +const CENTER_Y = 74; +const STROKE = 12; +const LENGTH = Math.PI * RADIUS; + +/** describeArc собирает путь полукруга (180°) в координатах SVG. */ +function describeArc(): string { + const startX = CENTER_X - RADIUS; + const endX = CENTER_X + RADIUS; + return `M ${String(startX)} ${String(CENTER_Y)} A ${String(RADIUS)} ${String(RADIUS)} 0 0 1 ${String(endX)} ${String(CENTER_Y)}`; +} + +function toneClass(value: number, warnAt: number, dangerAt: number): string { + if (value >= dangerAt) { + return 'text-danger'; + } + if (value >= warnAt) { + return 'text-warning'; + } + return 'text-accent'; +} + +/** + * Gauge — полукруглый индикатор нагрузки: заполняется по значению и меняет + * цвет при высокой загрузке. Длина дуги считается через `stroke-dasharray`, + * поэтому анимация — обычный CSS-переход (при `prefers-reduced-motion` + * анимации отключаются глобально). + */ +export function Gauge({ + value, + label, + display, + caption, + warnAt = 70, + dangerAt = 90, + testId, +}: GaugeProps) { + const clamped = Math.min(100, Math.max(0, Number.isFinite(value) ? value : 0)); + const filled = (clamped / 100) * LENGTH; + const tone = toneClass(clamped, warnAt, dangerAt); + + return ( +
+ +
+ + {display} + + {caption === undefined ? null : ( + {caption} + )} +
+
+ ); +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 2cfcb82..540cce0 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -729,7 +729,48 @@ "memberCount": "{{count}} members", "adminBadge": "admin", "mainBadge": "main", - "usersIdHint": "User IDs are visible to the instance administrator only." + "usersIdHint": "User IDs are visible to the instance administrator only.", + "dashboard": { + "title": "Server dashboard", + "refreshHint": "updates every second", + "cpu": "CPU", + "cpuCaption_one": "{{count}} core", + "cpuCaption_few": "{{count}} cores", + "cpuCaption_many": "{{count}} cores", + "cpuCaption_other": "{{count}} cores", + "memory": "Memory", + "diskHint": "Disk: used / total. {{free}} free", + "databaseHint": "Database: main file and WAL ({{wal}})" + }, + "health": { + "title": "Instance healthcheck", + "collecting": "Collecting data…", + "unavailable": "The instance does not answer dashboard requests.", + "ping": "Ping", + "pingValue": "{{ms}} ms", + "pingUnavailable": "—", + "pingHint": "Instance response time for a dashboard request", + "summary": { + "ok": "All good", + "warn": "Warnings", + "failed": "Errors" + }, + "status": { + "ok": "running", + "warn": "warning", + "failed": "failed", + "off": "disabled" + }, + "checks": { + "app": "Application", + "database": "Database", + "disk": "Disk", + "storage": "Data writes", + "voice": "Voice (SFU)", + "gateway": "Gateway", + "metrics": "Metrics" + } + } }, "servers": { "pickerLabel": "My servers", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index bcbf67f..cf48392 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -729,7 +729,48 @@ "memberCount": "{{count}} участн.", "adminBadge": "админ", "mainBadge": "главный", - "usersIdHint": "ID пользователей видны только администратору инстанса." + "usersIdHint": "ID пользователей видны только администратору инстанса.", + "dashboard": { + "title": "Дашборд сервера", + "refreshHint": "обновляется раз в секунду", + "cpu": "Процессор", + "cpuCaption_one": "{{count}} ядро", + "cpuCaption_few": "{{count}} ядра", + "cpuCaption_many": "{{count}} ядер", + "cpuCaption_other": "{{count}} ядра", + "memory": "Память", + "diskHint": "Диск: занято / всего. Свободно {{free}}", + "databaseHint": "База данных: файл и журнал WAL ({{wal}})" + }, + "health": { + "title": "Healthcheck инстанса", + "collecting": "Собираем данные…", + "unavailable": "Инстанс не отвечает на запросы дашборда.", + "ping": "Пинг", + "pingValue": "{{ms}} мс", + "pingUnavailable": "—", + "pingHint": "Время ответа инстанса на запрос дашборда", + "summary": { + "ok": "Всё в порядке", + "warn": "Есть предупреждения", + "failed": "Есть ошибки" + }, + "status": { + "ok": "работает", + "warn": "внимание", + "failed": "ошибка", + "off": "выключено" + }, + "checks": { + "app": "Приложение", + "database": "База данных", + "disk": "Диск", + "storage": "Запись данных", + "voice": "Голос (SFU)", + "gateway": "Gateway", + "metrics": "Метрики" + } + } }, "servers": { "pickerLabel": "Мои серверы", diff --git a/web/src/lib/useInstanceMetrics.ts b/web/src/lib/useInstanceMetrics.ts new file mode 100644 index 0000000..16cfe27 --- /dev/null +++ b/web/src/lib/useInstanceMetrics.ts @@ -0,0 +1,57 @@ +import { useQuery } from '@tanstack/react-query'; +import { useCallback, useState } from 'react'; + +import { fetchInstanceMetrics, metricsQueryKey, type InstanceMetrics } from '@/api/metrics'; + +/** Как часто дашборд перезапрашивает метрики (AGENT.md 7.19: раз в секунду). */ +export const METRICS_INTERVAL_MS = 1_000; + +export interface InstanceMetricsState { + metrics: InstanceMetrics | undefined; + /** Время ответа инстанса на запрос дашборда, мс (оценка пинга). */ + pingMs: number | null; + pending: boolean; + failed: boolean; +} + +/** + * useInstanceMetrics опрашивает метрики инстанса раз в секунду и измеряет + * время ответа — оно же показывается как пинг пользователя до инстанса. + * Оба блока дашборда используют один ключ запроса: React Query выполняет + * единственный опрос независимо от числа подписчиков. + */ +export function useInstanceMetrics(enabled: boolean): InstanceMetricsState { + const [pingMs, setPingMs] = useState(null); + + const measure = useCallback((started: number) => { + if (typeof performance === 'undefined') { + return; + } + const elapsed = performance.now() - started; + // Округляем до миллисекунды и не показываем 0 мс: запрос всегда занимает время. + setPingMs(Math.max(1, Math.round(elapsed))); + }, []); + + const query = useQuery({ + queryKey: metricsQueryKey, + queryFn: async ({ signal }) => { + const started = typeof performance === 'undefined' ? 0 : performance.now(); + const metrics = await fetchInstanceMetrics(signal); + measure(started); + return metrics; + }, + enabled, + refetchInterval: METRICS_INTERVAL_MS, + // Опрос идёт только когда вкладка видима: незачем нагружать инстанс в фоне. + refetchIntervalInBackground: false, + retry: 1, + staleTime: 0, + }); + + return { + metrics: query.data, + pingMs, + pending: query.isPending, + failed: query.isError, + }; +} diff --git a/web/src/pages/settings/InstanceSettingsPage.tsx b/web/src/pages/settings/InstanceSettingsPage.tsx index 63b7884..38a2d5a 100644 --- a/web/src/pages/settings/InstanceSettingsPage.tsx +++ b/web/src/pages/settings/InstanceSettingsPage.tsx @@ -11,10 +11,13 @@ import { instanceSettingsQueryKey, instanceUsersQueryKey, } from '@/api/instance'; +import { InstanceDashboard } from '@/components/instance/InstanceDashboard'; +import { InstanceHealth } from '@/components/instance/InstanceHealth'; import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice'; import { Card } from '@/components/ui/primitives'; import { formatDateTime } from '@/lib/identity'; import { useCurrentUser } from '@/lib/hooks'; +import { useInstanceMetrics } from '@/lib/useInstanceMetrics'; const AUDIT_LIMIT = 50; @@ -49,6 +52,8 @@ export default function InstanceSettingsPage() { enabled: isAdmin, retry: 0, }); + // Живые метрики: опрос раз в секунду, пока раздел открыт (AGENT.md 7.19). + const metrics = useInstanceMetrics(isAdmin); if (!isAdmin) { return ( @@ -69,6 +74,8 @@ export default function InstanceSettingsPage() {

{t('settings.instance.description')}

+ +

{t('settings.instance.settings')}

{settings.isPending ? : null} @@ -204,6 +211,8 @@ export default function InstanceSettingsPage() { )}
+ + ); } diff --git a/web/tests/instanceDashboard.test.tsx b/web/tests/instanceDashboard.test.tsx new file mode 100644 index 0000000..1ec5b8f --- /dev/null +++ b/web/tests/instanceDashboard.test.tsx @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { installFetch, json, makeUser, renderApp, type FetchRoute } from './helpers'; + +/** Метрики инстанса в формате ручки `GET /instance/metrics`. */ +function metricsPayload(overrides: Record = {}) { + return { + metrics: { + cpu: { percent: 23.4, cores: 4, source: 'cgroup' }, + memory: { + used_bytes: 805_306_368 / 4, + total_bytes: 805_306_368, + percent: 25, + source: 'cgroup', + }, + disk: { + used_bytes: 12 * 1024 ** 3, + total_bytes: 24 * 1024 ** 3, + free_bytes: 12 * 1024 ** 3, + }, + database: { bytes: 1_572_864, wal_bytes: 262_144 }, + checks: [ + { name: 'app', status: 'ok', message: 'v0.1.0' }, + { name: 'database', status: 'ok', message: 'schema_version=10' }, + { name: 'disk', status: 'ok', message: '50.0% free' }, + { name: 'storage', status: 'ok' }, + { name: 'voice', status: 'ok', message: 'RoomService' }, + { name: 'gateway', status: 'ok', message: 'sessions=3' }, + ], + health: 'ok', + uptime_seconds: 3600, + version: 'v0.1.0', + commit: 'abc1234', + collected_at: '2026-09-20T18:00:00.123Z', + ...overrides, + }, + }; +} + +function appRoutes(current = makeUser(), extra: FetchRoute[] = []): FetchRoute[] { + return [ + ...extra, + { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, + { match: '/api/v1/users/@me', response: () => json({ user: current }) }, + { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, + ]; +} + +/** adminRoutes: раздел «Инстанс» виден только администратору инстанса. */ +function adminRoutes(metrics: unknown = metricsPayload()): FetchRoute[] { + return appRoutes(makeUser({ is_instance_admin: true }), [ + { match: '/api/v1/instance/metrics', response: () => json(metrics) }, + { match: '/api/v1/instance/settings', response: () => json({ settings: {} }) }, + { match: '/api/v1/instance/guilds', response: () => json({ guilds: [] }) }, + { match: '/api/v1/instance/users', response: () => json({ users: [] }) }, + { match: '/api/v1/instance/audit', response: () => json({ entries: [] }) }, + ]); +} + +/** openInstance открывает настройки и переходит на вкладку «Инстанс». */ +async function openInstance(): Promise { + const gear = await screen.findByLabelText('Настройки пользователя'); + await userEvent.click(gear); + const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); + await userEvent.click(await within(dialog).findByRole('button', { name: 'Инстанс' })); + return dialog; +} + +describe('дашборд инстанса', () => { + it('показывает полукруглые индикаторы процессора и памяти', async () => { + installFetch(adminRoutes()); + renderApp('/app/empty'); + const dialog = await openInstance(); + + const cpu = await within(dialog).findByTestId('gauge-cpu'); + expect(cpu).toHaveAttribute('role', 'meter'); + expect(within(cpu).getByTestId('gauge-cpu-value')).toHaveTextContent('23,4 %'); + // Полукруг заполняется пропорционально: 23.4 % от длины дуги. + const arc = within(cpu).getByTestId('gauge-cpu-arc'); + const [filled, total] = (arc.getAttribute('stroke-dasharray') ?? '').split(' '); + expect(Number(filled)).toBeGreaterThan(0); + expect(Number(filled) / Number(total)).toBeCloseTo(0.234, 2); + + const memory = within(dialog).getByTestId('gauge-memory'); + expect(within(memory).getByTestId('gauge-memory-value')).toHaveTextContent('25 %'); + expect(memory).toHaveAttribute('aria-valuenow', '25'); + // Подпись памяти — занято / всего. + expect(within(memory).getByText(/192 MB \/ 768 MB/)).toBeVisible(); + }); + + it('показывает диск и базу без подписей — только в тултипах', async () => { + installFetch(adminRoutes()); + renderApp('/app/empty'); + const dialog = await openInstance(); + + const disk = await within(dialog).findByTestId('disk-usage'); + const diskValue = within(disk).getByTestId('disk-usage-value'); + expect(diskValue).toHaveTextContent('12 GB / 24 GB'); + expect(diskValue).toHaveAttribute('title', expect.stringContaining('Диск: занято / всего')); + expect(diskValue).toHaveAttribute('title', expect.stringContaining('12 GB')); + + const database = within(dialog).getByTestId('database-size'); + const databaseValue = within(database).getByTestId('database-size-value'); + // База = основной файл + журнал WAL (1.5 MB + 0.25 MB) → 1,8 MB + // (formatBytes оставляет одну цифру после запятой для значений < 10). + expect(databaseValue).toHaveTextContent('1,8 MB'); + expect(databaseValue).toHaveAttribute('title', expect.stringContaining('База данных')); + + // Видимых подписей у блоков нет: только значения. + expect(disk.textContent).toBe(diskValue.textContent); + expect(database.textContent).toBe(databaseValue.textContent); + }); + + it('показывает healthcheck и живой пинг', async () => { + installFetch(adminRoutes()); + renderApp('/app/empty'); + const dialog = await openInstance(); + + const health = await within(dialog).findByTestId('instance-health'); + expect(within(health).getByTestId('instance-health-summary')).toHaveTextContent( + 'Всё в порядке', + ); + expect(within(health).getByTestId('instance-check-app')).toHaveAttribute('data-status', 'ok'); + expect(within(health).getByTestId('instance-check-database')).toHaveTextContent( + 'schema_version=10', + ); + expect(within(health).getByTestId('instance-check-voice')).toHaveTextContent('работает'); + expect(within(health).getByTestId('instance-check-gateway')).toHaveTextContent('Gateway'); + + // Пинг измеряется на запросе дашборда и показывается в миллисекундах. + await waitFor(() => { + expect(within(health).getByTestId('instance-ping').getAttribute('data-ping')).not.toBe(''); + }); + expect(within(health).getByTestId('instance-ping')).toHaveTextContent('Пинг'); + expect(within(health).getByTestId('instance-ping').textContent).toMatch(/\d+ мс/); + }); + + it('деградирует мягко: предупреждения и ошибки видно в списке', async () => { + installFetch( + adminRoutes( + metricsPayload({ + health: 'failed', + checks: [ + { name: 'app', status: 'ok' }, + { name: 'database', status: 'failed', message: 'database is locked' }, + { name: 'disk', status: 'warn', message: '11.0% free' }, + ], + cpu: { percent: 0, cores: 0, source: '' }, + memory: { used_bytes: 0, total_bytes: 0, percent: 0, source: '' }, + }), + ), + ); + renderApp('/app/empty'); + const dialog = await openInstance(); + + const health = await within(dialog).findByTestId('instance-health'); + expect(within(health).getByTestId('instance-health-summary')).toHaveTextContent('Есть ошибки'); + expect(within(health).getByTestId('instance-check-database')).toHaveAttribute( + 'data-status', + 'failed', + ); + expect(within(health).getByTestId('instance-check-disk')).toHaveAttribute( + 'data-status', + 'warn', + ); + }); + + it('обычный пользователь дашборд не видит', async () => { + installFetch(appRoutes(makeUser({ is_instance_admin: false }))); + renderApp('/app/empty'); + // Кнопка настроек в панели пользователя есть всегда, а раздел «Инстанс» — + // только у администратора, поэтому проверяем отсутствие пункта. + const gear = await screen.findByLabelText('Настройки пользователя'); + await userEvent.click(gear); + const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); + await waitFor(() => { + expect(within(dialog).queryByRole('button', { name: 'Инстанс' })).toBeNull(); + }); + }); +});