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
+73
View File
@@ -28,6 +28,7 @@ import {
import { makeVoiceState, voiceRoutes, type VoiceRoutesOptions } from './voiceRoutes';
import type { InstanceMeta } from '@/api/meta';
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
import { useVoiceStore } from '@/stores/voice';
const ALL_PERMISSIONS = [
'VIEW_GUILD',
@@ -197,6 +198,78 @@ describe('голосовая комната: вход', () => {
expect(voice.joinCount()).toBe(0);
});
it('показывает пинг участника рядом с качеством связи', { timeout: 20_000 }, async () => {
await bootVoice({ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }] });
await clickChannel('Голосовая');
await waitConnected();
// Свой пинг приходит из отчёта WebRTC (candidate-pair) и публикуется
// в комнату; у чужого участника он приходит по data-каналу.
const room = FakeRoom.latest();
room.setStats([
['pair', { type: 'candidate-pair', state: 'succeeded', currentRoundTripTime: 0.042 }],
]);
// Первый такт может прийтись на момент, когда микрофон ещё не опубликован,
// поэтому ждём следующего (интервал публикации пинга — секунда).
await waitFor(
() => {
expect(
within(screen.getByTestId('voice-tile-user-1')).getByTestId('voice-quality-label'),
).toHaveTextContent('42 мс');
},
{ timeout: 5_000 },
);
const ownLabel = within(screen.getByTestId('voice-tile-user-1')).getByTestId(
'voice-quality-label',
);
expect(ownLabel).toHaveTextContent('Отличное соединение, 42 мс');
// Второй участник подключается к комнате и присылает свой пинг.
const remote = room.addRemote('user-2', 'Bob');
act(() => {
room.emitData(
new TextEncoder().encode(JSON.stringify({ ms: 128 })),
remote,
'glchat.latency',
);
});
const remoteTile = await screen.findByTestId('voice-tile-user-2');
await waitFor(
() => {
expect(within(remoteTile).getByTestId('voice-quality-label')).toHaveTextContent(
'Отличное соединение, 128 мс',
);
},
{ timeout: 5_000 },
);
// Свой пинг уходит в комнату по служебной теме и без гарантии доставки.
await waitFor(
() => {
expect(room.localParticipant.data.length).toBeGreaterThan(0);
},
{ timeout: 5_000 },
);
const sent = room.localParticipant.data.at(-1);
expect(sent?.options).toMatchObject({ topic: 'glchat.latency', reliable: false });
expect(new TextDecoder().decode(sent?.payload ?? new Uint8Array())).toContain('"ms"');
});
it('выключенный пинг участников не опрашивает SDK', async () => {
useVoiceStore.setState({ latencySharingEnabled: false });
await bootVoice({ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }] });
await clickChannel('Голосовая');
await waitConnected();
const track = FakeRoom.latest().localParticipant.getTrackPublication('microphone')?.track;
const before = track?.statsRequests ?? 0;
await new Promise((resolve) => setTimeout(resolve, 1_500));
expect(track?.statsRequests).toBe(before);
expect(useVoiceConnectionStore.getState().latencies).toEqual({});
});
it('переходит в другую комнату через выход из старой', async () => {
const voice = await bootVoice();