feat(web): второй ряд дашборда и пинг участников в голосовой комнате
- дашборд инстанса: два ряда полукругов с подписями сверху — «Контейнер инстанса» (лимиты профиля) и «Сервер целиком» (вся машина); - голосовая комната: рядом с качеством связи видно пинг участника («Отличное соединение, 42 мс») и он обновляется раз в секунду. Свой RTT измеряется по статистике WebRTC, чужой приходит по data-каналу LiveKit (`glchat.latency`, lossy), поэтому пинг виден для каждого участника; - статистика берётся с любой своей дорожки: в аудио-сессии RTT раньше был недоступен (отчёт читался только с видео); - в «Аудио-видео» появился тумблер «Показывать пинг в голосовой комнате»: выключенный не опрашивает SDK и не публикует своё значение; - тесты: ряд хоста и подписи, пинг в плитке своего и чужого участника, публикация по служебной теме, выключенный пинг не дёргает SDK.
This commit is contained in:
@@ -21,6 +21,7 @@ export const RoomEvent = {
|
||||
Reconnecting: 'reconnecting',
|
||||
Reconnected: 'reconnected',
|
||||
Disconnected: 'disconnected',
|
||||
DataReceived: 'dataReceived',
|
||||
} as const;
|
||||
|
||||
export const ConnectionQuality = {
|
||||
@@ -106,6 +107,8 @@ export class FakeParticipant {
|
||||
readonly isLocal: boolean;
|
||||
/** Вызовы, которые проверяют тесты: `setMicrophoneEnabled(false)` и т.п. */
|
||||
readonly calls: { method: string; value: unknown }[] = [];
|
||||
/** Отправленные data-пакеты: `{ payload, options }` каждого publishData. */
|
||||
readonly data: { payload: Uint8Array; options: unknown }[] = [];
|
||||
/** Параметры захвата экрана: `{ enabled, options }` каждого вызова. */
|
||||
readonly screenOptions: { enabled: boolean; options: unknown }[] = [];
|
||||
private readonly publications = new Map<string, FakePublication>();
|
||||
@@ -156,6 +159,12 @@ export class FakeParticipant {
|
||||
this.room.emit(RoomEvent.TrackUnsubscribed);
|
||||
}
|
||||
|
||||
/** Отправка data-сообщения: тесты проверяют тему, payload и надёжность. */
|
||||
publishData(payload: Uint8Array, options?: unknown): Promise<void> {
|
||||
this.data.push({ payload, options });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
setSpeaking(speaking: boolean): void {
|
||||
this.isSpeaking = speaking;
|
||||
}
|
||||
@@ -238,6 +247,11 @@ export class FakeRoom {
|
||||
this.stats = entries;
|
||||
}
|
||||
|
||||
/** Эмуляция входящего data-пакета: событие `DataReceived` от участника. */
|
||||
emitData(payload: Uint8Array, participant: FakeParticipant, topic: string): void {
|
||||
this.emit(RoomEvent.DataReceived, payload, participant, undefined, topic);
|
||||
}
|
||||
|
||||
on(event: string, handler: Handler): this {
|
||||
const handlers = this.handlers.get(event) ?? new Set<Handler>();
|
||||
handlers.add(handler);
|
||||
|
||||
@@ -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: '' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -121,6 +121,10 @@ async function bootPanel() {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// Фоновая публикация пинга опрашивает SDK раз в секунду; тесты попапа
|
||||
// считают обращения к SDK, поэтому выключаем её и проверяем отдельно.
|
||||
useVoiceStore.setState({ latencySharingEnabled: false });
|
||||
|
||||
await act(async () => {
|
||||
await useVoiceConnectionStore.getState().join('g-1', 'c-voice');
|
||||
await useVoiceConnectionStore.getState().setCameraEnabled(true);
|
||||
|
||||
Reference in New Issue
Block a user