feat(web): второй ряд дашборда и пинг участников в голосовой комнате

- дашборд инстанса: два ряда полукругов с подписями сверху — «Контейнер
  инстанса» (лимиты профиля) и «Сервер целиком» (вся машина);
- голосовая комната: рядом с качеством связи видно пинг участника
  («Отличное соединение, 42 мс») и он обновляется раз в секунду. Свой RTT
  измеряется по статистике WebRTC, чужой приходит по data-каналу LiveKit
  (`glchat.latency`, lossy), поэтому пинг виден для каждого участника;
- статистика берётся с любой своей дорожки: в аудио-сессии RTT раньше был
  недоступен (отчёт читался только с видео);
- в «Аудио-видео» появился тумблер «Показывать пинг в голосовой комнате»:
  выключенный не опрашивает SDK и не публикует своё значение;
- тесты: ряд хоста и подписи, пинг в плитке своего и чужого участника,
  публикация по служебной теме, выключенный пинг не дёргает SDK.
This commit is contained in:
2026-09-20 22:10:17 +03:00
parent 5668d779df
commit 3fe634c9bc
16 changed files with 476 additions and 51 deletions
+43 -5
View File
@@ -15,6 +15,13 @@ function metricsPayload(overrides: Record<string, unknown> = {}) {
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,
@@ -59,6 +66,13 @@ function adminRoutes(metrics: unknown = metricsPayload()): FetchRoute[] {
]);
}
/** 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('Настройки пользователя');
@@ -74,27 +88,49 @@ describe('дашборд инстанса', () => {
renderApp('/app/empty');
const dialog = await openInstance();
const cpu = await within(dialog).findByTestId('gauge-cpu');
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('gauge-cpu-value')).toHaveTextContent('23,4 %');
expect(within(cpu).getByTestId('row-container-cpu-value')).toHaveTextContent('23,4 %');
// Полукруг заполняется пропорционально: 23.4 % от длины дуги.
const arc = within(cpu).getByTestId('gauge-cpu-arc');
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(dialog).getByTestId('gauge-memory');
expect(within(memory).getByTestId('gauge-memory-value')).toHaveTextContent('25 %');
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');
@@ -149,6 +185,8 @@ describe('дашборд инстанса', () => {
],
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: '' },
}),
),
);