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
+105 -3
View File
@@ -1,4 +1,4 @@
import type { LocalVideoTrack, Participant, Room, Track } from 'livekit-client';
import type { LocalAudioTrack, LocalVideoTrack, Participant, Room, Track } from 'livekit-client';
import type { VoiceDeviceKind, VoicePublishQuality } from '@/stores/voice';
@@ -106,8 +106,20 @@ export interface VoiceRoomCallbacks {
onStatus: (status: VoiceRoomStatus) => void;
/** SDK сообщает, что свой микрофон говорит, хотя публикация выключена. */
onSpeakingWhileMuted: () => void;
/**
* Пришёл (или измерился) пинг участника: `identity` — идентификатор
* пользователя, `ms` — задержка до инстанса. `null` означает, что участник
* отключился и значение нужно забыть.
*/
onLatency?: (identity: string, ms: number | null) => void;
}
/** Топик служебных сообщений с пингом: чужие data-пакеты игнорируем. */
export const LATENCY_TOPIC = 'glchat.latency';
/** Как часто участник публикует свой пинг. */
export const LATENCY_INTERVAL_MS = 1_000;
export interface VoiceRoomHandle {
connect: (url: string, token: string) => Promise<void>;
disconnect: () => Promise<void>;
@@ -121,6 +133,12 @@ export interface VoiceRoomHandle {
setVideoQuality: (quality: VoicePublishQuality) => Promise<void>;
/** Статистика своей публикации для попапа «Статистика». */
fetchStats: () => Promise<VoiceStats>;
/**
* Запускает публикацию своего пинга раз в секунду и отдаёт функцию остановки.
* Значение уходит всем участникам комнаты по data-каналу, поэтому пинг
* каждого виден остальным (AGENT.md 7.14).
*/
startLatencyReporting: (shouldReport?: () => boolean) => () => void;
switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise<void>;
participants: () => VoiceParticipant[];
}
@@ -234,6 +252,68 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
return fallback;
};
/**
* startLatencyReporting измеряет свой RTT до инстанса раз в секунду,
* отдаёт его локально и публикует в комнату. Ошибки глушим: телеметрия не
* должна ломать голосовую сессию.
*/
const startLatencyReporting = (shouldReport?: () => boolean): (() => void) => {
let stopped = false;
const tick = async (): Promise<void> => {
// Выключенный пинг не только не публикуется, но и не опрашивает SDK.
if (stopped || shouldReport?.() === false) {
return;
}
const identity = room.localParticipant.identity;
let latency: number | null;
try {
latency = (await collectStats()).roundTripMs;
} catch {
// Отчёт WebRTC может быть недоступен — просто пропускаем такт.
latency = null;
}
if (stopped || latency === null) {
return;
}
callbacks.onLatency?.(identity, latency);
const payload = new TextEncoder().encode(JSON.stringify({ ms: latency }));
void room.localParticipant
.publishData(payload, { reliable: false, topic: LATENCY_TOPIC })
.catch(() => undefined);
};
void tick();
const timer = setInterval(() => {
void tick();
}, LATENCY_INTERVAL_MS);
return () => {
stopped = true;
clearInterval(timer);
};
};
/** Своя дорожка микрофона: нужна для статистики в аудио-сессии. */
const microphoneTrack = (): LocalAudioTrack | undefined => {
const track = room.localParticipant.getTrackPublication(Track.Source.Microphone)?.track;
return track as LocalAudioTrack | undefined;
};
/** parseLatency разбирает полезную нагрузку с пингом участника. */
function parseLatency(payload: Uint8Array): number | null {
try {
const parsed: unknown = JSON.parse(new TextDecoder().decode(payload));
if (typeof parsed !== 'object' || parsed === null) {
return null;
}
const value = (parsed as { ms?: unknown }).ms;
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 60_000) {
return null;
}
return Math.round(value);
} catch {
return null;
}
}
/** Число из отчёта WebRTC: `null`, если поля нет или оно не число. */
const num = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null;
@@ -248,7 +328,9 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
* Чего SDK не отдал — остаётся `null`, интерфейс покажет «—».
*/
const collectStats = async (): Promise<VoiceStats> => {
const track = videoPublication();
// Качество канала (RTT, jitter, потери) есть и в аудио-сессии, поэтому
// отчёт берём с любой своей дорожки: видео, иначе микрофон.
const track = videoPublication() ?? microphoneTrack();
if (track === undefined || typeof track.getRTCStatsReport !== 'function') {
previousSample = null;
return { ...EMPTY_VOICE_STATS };
@@ -350,8 +432,26 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
callbacks.onParticipants(snapshotAll(room));
};
// Пинг участника: своё значение измеряем по WebRTC-статистике и рассылаем
// по data-каналу, чужие значения принимаем и отдаём в колбэк.
room.on(
RoomEvent.DataReceived,
(payload: Uint8Array, participant?: { identity: string }, _kind?: unknown, topic?: string) => {
if (topic !== LATENCY_TOPIC || participant === undefined) {
return;
}
const parsed = parseLatency(payload);
if (parsed !== null) {
callbacks.onLatency?.(participant.identity, parsed);
}
},
);
room.on(RoomEvent.ParticipantConnected, refresh);
room.on(RoomEvent.ParticipantDisconnected, refresh);
room.on(RoomEvent.ParticipantDisconnected, (participant: Participant) => {
callbacks.onLatency?.(participant.identity, null);
refresh();
});
room.on(RoomEvent.TrackSubscribed, refresh);
room.on(RoomEvent.TrackUnsubscribed, refresh);
room.on(RoomEvent.TrackPublished, refresh);
@@ -389,6 +489,8 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
await room.disconnect();
},
startLatencyReporting,
dispose() {
room.removeAllListeners();
},