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
+29
View File
@@ -53,6 +53,11 @@ export interface VoiceConnectionState {
channelId: string | null;
/** Участники комнаты глазами SDK: речь, качество, дорожки. */
participants: VoiceParticipant[];
/**
* Пинг участников до инстанса, мс: своё значение измеряем по статистике
* WebRTC, чужие приходят по data-каналу (AGENT.md 7.14).
*/
latencies: Record<string, number>;
/** Опубликован ли свой микрофон. */
micEnabled: boolean;
/** Локальное глушение: чужой звук не слышен. */
@@ -105,6 +110,7 @@ const defaults = {
guildId: null,
channelId: null,
participants: [] as VoiceParticipant[],
latencies: {} as Record<string, number>,
micEnabled: true,
deafened: false,
cameraEnabled: false,
@@ -118,6 +124,8 @@ const defaults = {
/** Активная комната SDK. Живёт вне стора: подписки и таймеры не сериализуются. */
let room: VoiceRoomHandle | null = null;
/** Остановка публикации пинга: живёт вместе с активной комнатой. */
let stopLatencyReporting: (() => void) | null = null;
/** Канал, о котором уже знает сервер: только для него шлём PATCH и leave. */
let joinedChannelId: string | null = null;
/** Номер подключения: ответы устаревших операций не должны менять состояние. */
@@ -184,6 +192,9 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
/** Отключение от SDK-комнаты без запросов: используется и в leave, и в reset. */
const closeRoom = async (): Promise<void> => {
stopLatencyReporting?.();
stopLatencyReporting = null;
set({ latencies: {} });
const handle = room;
room = null;
if (handle === null) {
@@ -396,6 +407,20 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
void finishSession();
},
onSpeakingWhileMuted: warnSpeakingWhileMuted,
onLatency: (identity, latency) => {
if (mine !== generation) {
return;
}
set((state) => {
const latencies = { ...state.latencies };
if (latency === null) {
delete latencies[identity];
} else {
latencies[identity] = latency;
}
return { latencies };
});
},
});
if (mine !== generation) {
handle.dispose();
@@ -410,6 +435,10 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
}
return;
}
// Пинг публикуется раз в секунду и виден всем участникам комнаты.
stopLatencyReporting = handle.startLatencyReporting(
() => useVoiceStore.getState().latencySharingEnabled,
);
await handle.setMicrophoneEnabled(preferences.micEnabled && !pushToTalk);
await handle.setCameraEnabled(preferences.cameraEnabled);
// Качество публикации — из предпочтений клиента.