3fe634c9bc
- дашборд инстанса: два ряда полукругов с подписями сверху — «Контейнер инстанса» (лимиты профиля) и «Сервер целиком» (вся машина); - голосовая комната: рядом с качеством связи видно пинг участника («Отличное соединение, 42 мс») и он обновляется раз в секунду. Свой RTT измеряется по статистике WebRTC, чужой приходит по data-каналу LiveKit (`glchat.latency`, lossy), поэтому пинг виден для каждого участника; - статистика берётся с любой своей дорожки: в аудио-сессии RTT раньше был недоступен (отчёт читался только с видео); - в «Аудио-видео» появился тумблер «Показывать пинг в голосовой комнате»: выключенный не опрашивает SDK и не публикует своё значение; - тесты: ряд хоста и подписи, пинг в плитке своего и чужого участника, публикация по служебной теме, выключенный пинг не дёргает SDK.
221 lines
10 KiB
TypeScript
221 lines
10 KiB
TypeScript
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<string, unknown> = {}) {
|
|
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',
|
|
},
|
|
host_cpu: { percent: 12.5, cores: 4, source: 'host' },
|
|
host_memory: {
|
|
used_bytes: 1_500_000_000,
|
|
total_bytes: 3_479_592_960,
|
|
percent: 43.1,
|
|
source: 'host',
|
|
},
|
|
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: [] }) },
|
|
]);
|
|
}
|
|
|
|
/** waitForMetrics ждёт первый ответ метрик: до него индикаторы показывают «—». */
|
|
async function waitForMetrics(dialog: HTMLElement): Promise<void> {
|
|
await waitFor(() => {
|
|
expect(within(dialog).getByTestId('row-container-cpu-value').textContent).not.toBe('—');
|
|
});
|
|
}
|
|
|
|
/** openInstance открывает настройки и переходит на вкладку «Инстанс». */
|
|
async function openInstance(): Promise<HTMLElement> {
|
|
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 container = await within(dialog).findByTestId('row-container');
|
|
await waitForMetrics(dialog);
|
|
const cpu = within(container).getByTestId('row-container-cpu');
|
|
expect(cpu).toHaveAttribute('role', 'meter');
|
|
expect(within(cpu).getByTestId('row-container-cpu-value')).toHaveTextContent('23,4 %');
|
|
// Полукруг заполняется пропорционально: 23.4 % от длины дуги.
|
|
const arc = within(cpu).getByTestId('row-container-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(container).getByTestId('row-container-memory');
|
|
expect(within(memory).getByTestId('row-container-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 container = await within(dialog).findByTestId('row-container');
|
|
await waitForMetrics(dialog);
|
|
const host = within(dialog).getByTestId('row-host');
|
|
// Подписи сверху объясняют, к чему относится ряд.
|
|
expect(within(container).getByText('Контейнер инстанса')).toBeVisible();
|
|
expect(within(host).getByText('Сервер целиком')).toBeVisible();
|
|
expect(within(host).getByText('Вся машина')).toBeVisible();
|
|
|
|
expect(within(host).getByTestId('row-host-cpu-value')).toHaveTextContent('12,5 %');
|
|
expect(within(host).getByTestId('row-host-memory-value')).toHaveTextContent('43,1 %');
|
|
// Память хоста — от всей машины, а не от лимита контейнера.
|
|
expect(within(host).getByText(/1,4 GB \/ 3,2 GB/)).toBeVisible();
|
|
});
|
|
|
|
it('показывает диск и базу без подписей — только в тултипах', async () => {
|
|
installFetch(adminRoutes());
|
|
renderApp('/app/empty');
|
|
const dialog = await openInstance();
|
|
|
|
await waitForMetrics(dialog);
|
|
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: '' },
|
|
host_cpu: { percent: 0, cores: 0, source: '' },
|
|
host_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();
|
|
});
|
|
});
|
|
});
|