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
+12
View File
@@ -14,6 +14,18 @@ export interface InstanceMetrics {
percent: number;
source: 'cgroup' | 'host' | '';
};
/** Вся машина: второй ряд дашборда. */
host_cpu: {
percent: number;
cores: number;
source: 'cgroup' | 'host' | '';
};
host_memory: {
used_bytes: number;
total_bytes: number;
percent: number;
source: 'cgroup' | 'host' | '';
};
disk: {
used_bytes: number;
total_bytes: number;
@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next';
import type { InstanceMetrics } from '@/api/metrics';
import { Card } from '@/components/ui/primitives';
import { Gauge } from '@/components/ui/Gauge';
import { formatBytes } from '@/lib/format';
@@ -10,16 +11,15 @@ interface InstanceDashboardProps {
}
/**
* Дашборд инстанса: полукруглые индикаторы процессора и памяти, ниже — два
* блока про диск (занято / всего) и вес базы данных. Значения обновляются раз
* в секунду, подписи у блоков диска показываются только при наведении.
* Дашборд инстанса: два ряда полукруглых индикаторов (контейнер и вся машина),
* ниже — два блока про диск (занято / всего) и вес базы данных. Значения
* обновляются раз в секунду, подписи у блоков диска показываются только при
* наведении, а у рядов подпись стоит сверху.
*/
export function InstanceDashboard({ state }: InstanceDashboardProps) {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? 'ru';
const metrics = state.metrics;
// До первого ответа показываем прочерк, а не нули: ноль — это тоже значение.
const pending = metrics === undefined ? '—' : undefined;
return (
<Card data-testid="instance-dashboard">
@@ -30,33 +30,20 @@ export function InstanceDashboard({ state }: InstanceDashboardProps) {
</span>
</header>
<div className="mt-4 flex flex-wrap items-center justify-around gap-6">
<Gauge
testId="gauge-cpu"
value={metrics?.cpu.percent ?? 0}
label={t('settings.instance.dashboard.cpu')}
display={pending ?? formatPercent(metrics?.cpu.percent ?? 0, locale)}
caption={
metrics === undefined
? undefined
: t('settings.instance.dashboard.cpuCaption', {
count: Math.round(metrics.cpu.cores),
})
}
<div className="mt-4 flex flex-col gap-4">
<GaugeRow
testId="row-container"
title={t('settings.instance.dashboard.containerRow')}
hint={t('settings.instance.dashboard.containerHint')}
cpu={metrics?.cpu}
memory={metrics?.memory}
/>
<Gauge
testId="gauge-memory"
value={metrics?.memory.percent ?? 0}
label={t('settings.instance.dashboard.memory')}
display={pending ?? formatPercent(metrics?.memory.percent ?? 0, locale)}
caption={
metrics === undefined
? undefined
: `${formatBytes(metrics.memory.used_bytes, locale)} / ${formatBytes(
metrics.memory.total_bytes,
locale,
)}`
}
<GaugeRow
testId="row-host"
title={t('settings.instance.dashboard.hostRow')}
hint={t('settings.instance.dashboard.hostHint')}
cpu={metrics?.host_cpu}
memory={metrics?.host_memory}
/>
</div>
@@ -91,6 +78,58 @@ export function InstanceDashboard({ state }: InstanceDashboardProps) {
);
}
interface GaugeRowProps {
testId: string;
/** Подпись ряда: к чему относятся индикаторы (контейнер или вся машина). */
title: string;
hint: string;
cpu: InstanceMetrics['cpu'] | undefined;
memory: InstanceMetrics['memory'] | undefined;
}
/** GaugeRow — ряд из двух полукругов с подписью, к чему он относится. */
function GaugeRow({ testId, title, hint, cpu, memory }: GaugeRowProps) {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? 'ru';
const pending = cpu === undefined && memory === undefined ? '—' : undefined;
return (
<section data-testid={testId}>
<header className="flex items-baseline justify-between gap-2">
<h4 className="text-sm font-medium">{title}</h4>
<span className="text-xs text-fg-muted">{hint}</span>
</header>
<div className="mt-1 flex flex-wrap items-center justify-around gap-6">
<Gauge
testId={`${testId}-cpu`}
value={cpu?.percent ?? 0}
label={t('settings.instance.dashboard.cpu')}
display={pending ?? formatPercent(cpu?.percent ?? 0, locale)}
caption={
cpu === undefined
? undefined
: t('settings.instance.dashboard.cpuCaption', { count: Math.round(cpu.cores) })
}
/>
<Gauge
testId={`${testId}-memory`}
value={memory?.percent ?? 0}
label={t('settings.instance.dashboard.memory')}
display={pending ?? formatPercent(memory?.percent ?? 0, locale)}
caption={
memory === undefined
? undefined
: `${formatBytes(memory.used_bytes, locale)} / ${formatBytes(
memory.total_bytes,
locale,
)}`
}
/>
</div>
</section>
);
}
interface DiskStatProps {
value: string;
title: string;
+43 -3
View File
@@ -1,5 +1,7 @@
import { useTranslation } from 'react-i18next';
import { qualityLabel } from '@/components/voice/voiceQualityLabel';
import type { VoiceQuality } from '@/lib/livekit';
/** Цвет точки качества связи: зелёный — отлично, красный — связи нет. */
@@ -11,13 +13,22 @@ const qualityClass: Record<VoiceQuality, string> = {
unknown: 'bg-fg-muted',
};
/** Точка качества связи с подписью для скринридера (AGENT.md 7.14). */
export function VoiceQualityDot({ quality }: { quality: VoiceQuality | null }) {
/**
* Точка качества связи с подписью для скринридера (AGENT.md 7.14).
* В тултип попадает и пинг участника — он обновляется раз в секунду.
*/
export function VoiceQualityDot({
quality,
latencyMs,
}: {
quality: VoiceQuality | null;
latencyMs?: number | undefined;
}) {
const { t } = useTranslation();
if (quality === null) {
return null;
}
const label = t(`voice.quality.${quality}`);
const label = qualityLabel(t, quality, latencyMs);
return (
<span
role="img"
@@ -25,11 +36,40 @@ export function VoiceQualityDot({ quality }: { quality: VoiceQuality | null }) {
title={label}
data-testid="voice-quality"
data-quality={quality}
data-latency={latencyMs ?? ''}
className={`inline-block h-2 w-2 shrink-0 rounded-full ${qualityClass[quality]}`}
/>
);
}
/**
* Точка качества с видимой подписью: «Отличное соединение, 42 мс» в плитке
* участника. Пинг обновляется раз в секунду вместе с состоянием связи.
*/
export function VoiceQualityBadge({
quality,
latencyMs,
}: {
quality: VoiceQuality | null;
latencyMs?: number | undefined;
}) {
const { t } = useTranslation();
if (quality === null) {
return null;
}
return (
<span
className="inline-flex items-center gap-1.5 text-xs text-fg-muted"
data-testid="voice-quality-label"
data-latency={latencyMs ?? ''}
title={t('voice.latencyHint')}
>
<VoiceQualityDot quality={quality} latencyMs={latencyMs} />
<span className="tabular-nums">{qualityLabel(t, quality, latencyMs)}</span>
</span>
);
}
/**
* Иконка состояния участника: перечёркнутая, когда флаг выключен.
* Подпись обязательна — по ней состояние читают и тесты, и скринридер.
@@ -31,6 +31,8 @@ export function VoiceOccupantRow({
const { t } = useTranslation();
const permissions = useVoiceModeration(guildId);
const moveTargets = useVoiceChannels(guildId).filter((channel) => channel.id !== channelId);
// Подписка именно на карту пингов: селектор-функция не вызывает перерисовку.
const latencies = useVoiceConnectionStore((state) => state.latencies);
const setParticipantMuted = useVoiceConnectionStore((state) => state.setParticipantMuted);
const setParticipantDeafened = useVoiceConnectionStore((state) => state.setParticipantDeafened);
const moveParticipant = useVoiceConnectionStore((state) => state.moveParticipant);
@@ -87,7 +89,7 @@ export function VoiceOccupantRow({
camera={occupant.camera}
screen={occupant.screen}
/>
<VoiceQualityDot quality={occupant.quality} />
<VoiceQualityDot quality={occupant.quality} latencyMs={latencies[occupant.userId]} />
</button>
<VoiceOccupantMenu
+4 -2
View File
@@ -6,7 +6,7 @@ import { Avatar } from '@/components/ui/Avatar';
import { Menu, MenuGroupLabel, MenuItem } from '@/components/ui/Menu';
import { Card, Button } from '@/components/ui/primitives';
import { RemoteAudio, RemoteVideo } from '@/components/voice/RemoteAudio';
import { VoiceFlags, VoiceQualityDot } from '@/components/voice/VoiceIndicators';
import { VoiceFlags, VoiceQualityBadge } from '@/components/voice/VoiceIndicators';
import { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu';
import { VoiceSoundboard } from '@/components/voice/VoiceSoundboard';
import { VoiceStatsButton } from '@/components/voice/VoiceStatsButton';
@@ -50,6 +50,8 @@ function VoiceTile({
const [menuOpen, setMenuOpen] = useState(false);
const anchorRef = useRef<HTMLButtonElement | null>(null);
const volume = useVoiceStore((state) => state.participantVolumes[occupant.userId] ?? 100);
// Подписка именно на карту пингов: селектор-функция не вызывает перерисовку.
const latencies = useVoiceConnectionStore((state) => state.latencies);
// Общая громкость звука умножается на личную громкость участника.
const outputVolume = useVoiceStore((state) => state.outputVolume);
const deafened = useVoiceConnectionStore((state) => state.deafened);
@@ -108,7 +110,7 @@ function VoiceTile({
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={occupant.name}>
{occupant.name}
</span>
<VoiceQualityDot quality={occupant.quality} />
<VoiceQualityBadge quality={occupant.quality} latencyMs={latencies[occupant.userId]} />
{occupant.isLocal ? (
<>
{/* Статистика своей публикации: разрешение, fps, кодек, канал (7.14). */}
@@ -0,0 +1,19 @@
import type { TFunction } from 'i18next';
import type { VoiceQuality } from '@/lib/livekit';
/**
* qualityLabel собирает подпись качества связи с пингом участника:
* «Отличное соединение, 42 мс». Пинг неизвестен — остаётся только качество.
*/
export function qualityLabel(
t: TFunction,
quality: VoiceQuality,
latencyMs: number | undefined,
): string {
const qualityText = t(`voice.quality.${quality}`);
if (latencyMs === undefined) {
return qualityText;
}
return `${qualityText}, ${t('voice.latency', { ms: latencyMs })}`;
}
+13 -3
View File
@@ -344,7 +344,10 @@
"medium": "Medium quality",
"high": "High quality"
}
}
},
"latency": "{{ms}} ms",
"latencyHint": "Participant ping to the instance",
"latencyUnknown": "ping unknown"
},
"friends": {
"rail": "Friends",
@@ -740,7 +743,11 @@
"cpuCaption_other": "{{count}} cores",
"memory": "Memory",
"diskHint": "Disk: used / total. {{free}} free",
"databaseHint": "Database: main file and WAL ({{wal}})"
"databaseHint": "Database: main file and WAL ({{wal}})",
"containerRow": "Instance container",
"containerHint": "Profile limits",
"hostRow": "Whole server",
"hostHint": "Entire machine"
},
"health": {
"title": "Instance healthcheck",
@@ -947,7 +954,10 @@
"eventSounds": "Server event sounds",
"eventSoundsHint": "Play the server palette sounds: member joins, mentions and other events.",
"soundboard": "Soundboard",
"soundboardHint": "Play soundboard sounds in a voice channel."
"soundboardHint": "Play soundboard sounds in a voice channel.",
"latency": "Participant ping",
"latencySharing": "Show ping in the voice room",
"latencySharingHint": "Participant latency to the instance updates every second; your own ping is shared with the room."
},
"language": {
"title": "Language & region",
+13 -3
View File
@@ -344,7 +344,10 @@
"medium": "Среднее качество",
"high": "Высокое качество"
}
}
},
"latency": "{{ms}} мс",
"latencyHint": "Пинг участника до инстанса",
"latencyUnknown": "пинг неизвестен"
},
"friends": {
"rail": "Друзья",
@@ -740,7 +743,11 @@
"cpuCaption_other": "{{count}} ядра",
"memory": "Память",
"diskHint": "Диск: занято / всего. Свободно {{free}}",
"databaseHint": "База данных: файл и журнал WAL ({{wal}})"
"databaseHint": "База данных: файл и журнал WAL ({{wal}})",
"containerRow": "Контейнер инстанса",
"containerHint": "Лимиты профиля",
"hostRow": "Сервер целиком",
"hostHint": "Вся машина"
},
"health": {
"title": "Healthcheck инстанса",
@@ -947,7 +954,10 @@
"eventSounds": "Звуки событий сервера",
"eventSoundsHint": "Проигрывать звуки палитры сервера: вход участника, упоминания и другие события.",
"soundboard": "Саундборд",
"soundboardHint": "Проигрывать звуки саундборда в голосовой комнате."
"soundboardHint": "Проигрывать звуки саундборда в голосовой комнате.",
"latency": "Пинг участников",
"latencySharing": "Показывать пинг в голосовой комнате",
"latencySharingHint": "Задержка участников до инстанса обновляется раз в секунду; свой пинг публикуется в комнате."
},
"language": {
"title": "Язык и регион",
+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();
},
@@ -39,9 +39,11 @@ export function VoiceSettingsSection() {
const pttKey = useVoiceStore((state) => state.pttKey);
const eventSoundsEnabled = useVoiceStore((state) => state.eventSoundsEnabled);
const soundboardEnabled = useVoiceStore((state) => state.soundboardEnabled);
const latencySharingEnabled = useVoiceStore((state) => state.latencySharingEnabled);
const setMicActivation = useVoiceStore((state) => state.setMicActivation);
const setPttKey = useVoiceStore((state) => state.setPttKey);
const setEventSoundsEnabled = useVoiceStore((state) => state.setEventSoundsEnabled);
const setLatencySharingEnabled = useVoiceStore((state) => state.setLatencySharingEnabled);
const setSoundboardEnabled = useVoiceStore((state) => state.setSoundboardEnabled);
return (
@@ -149,6 +151,21 @@ export function VoiceSettingsSection() {
<p className="ml-6 text-xs text-fg-muted">{t('settings.voice.soundboardHint')}</p>
</fieldset>
{/* Пинг участников: задержка в комнате и публикация своей (AGENT.md 7.14). */}
<fieldset className="mt-5 border-t border-border/40 pt-4">
<legend className="text-sm font-semibold">{t('settings.voice.latency')}</legend>
<label className="mt-2 flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={latencySharingEnabled}
aria-label={t('settings.voice.latencySharing')}
onChange={(event) => setLatencySharingEnabled(event.target.checked)}
/>
<span>{t('settings.voice.latencySharing')}</span>
</label>
<p className="ml-6 text-xs text-fg-muted">{t('settings.voice.latencySharingHint')}</p>
</fieldset>
<p className="mt-4 text-xs text-fg-muted">{t('settings.voice.stub')}</p>
</SettingsSection>
);
+14
View File
@@ -59,6 +59,11 @@ export interface VoicePreferences {
eventSoundsEnabled: boolean;
/** Саундборд включён. */
soundboardEnabled: boolean;
/**
* Пинг участников: показывать задержку в комнате и делиться своей
* (AGENT.md 7.14). Выключено — SDK не опрашивается вовсе.
*/
latencySharingEnabled: boolean;
/** Качество своей публикации видео. */
videoQuality: VoicePublishQuality;
/** Пресет разрешения и fps для демонстрации экрана. */
@@ -81,6 +86,7 @@ interface VoiceState extends VoicePreferences {
setPttKey: (key: PushToTalkKey) => void;
setEventSoundsEnabled: (enabled: boolean) => void;
setSoundboardEnabled: (enabled: boolean) => void;
setLatencySharingEnabled: (enabled: boolean) => void;
setVideoQuality: (quality: VoicePublishQuality) => void;
setScreenSharePreset: (preset: ScreenSharePresetId) => void;
/** Вернуть исходные предпочтения (используется в тестах). */
@@ -103,6 +109,7 @@ const defaults: VoicePreferences = {
pttKey: 'Space',
eventSoundsEnabled: true,
soundboardEnabled: true,
latencySharingEnabled: true,
videoQuality: 'high',
screenSharePreset: '1080p30',
};
@@ -184,6 +191,10 @@ function initialPreferences(): VoicePreferences {
pttKey: asChoice(stored['pttKey'], PUSH_TO_TALK_KEYS, defaults.pttKey),
eventSoundsEnabled: asBoolean(stored['eventSoundsEnabled'], defaults.eventSoundsEnabled),
soundboardEnabled: asBoolean(stored['soundboardEnabled'], defaults.soundboardEnabled),
latencySharingEnabled: asBoolean(
stored['latencySharingEnabled'],
defaults.latencySharingEnabled,
),
videoQuality: asChoice(stored['videoQuality'], PUBLISH_QUALITIES, defaults.videoQuality),
screenSharePreset: asChoice(
stored['screenSharePreset'],
@@ -213,6 +224,7 @@ function persist(state: VoicePreferences): void {
pttKey: state.pttKey,
eventSoundsEnabled: state.eventSoundsEnabled,
soundboardEnabled: state.soundboardEnabled,
latencySharingEnabled: state.latencySharingEnabled,
videoQuality: state.videoQuality,
screenSharePreset: state.screenSharePreset,
} satisfies VoicePreferences),
@@ -264,6 +276,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => {
setPttKey: (key) => patch({ pttKey: key }),
setEventSoundsEnabled: (enabled) => patch({ eventSoundsEnabled: enabled }),
setSoundboardEnabled: (enabled) => patch({ soundboardEnabled: enabled }),
setLatencySharingEnabled: (enabled) => patch({ latencySharingEnabled: enabled }),
setVideoQuality: (quality) => patch({ videoQuality: quality }),
setScreenSharePreset: (preset) => patch({ screenSharePreset: preset }),
@@ -292,6 +305,7 @@ function preferencesOf(state: VoiceState): VoicePreferences {
pttKey: state.pttKey,
eventSoundsEnabled: state.eventSoundsEnabled,
soundboardEnabled: state.soundboardEnabled,
latencySharingEnabled: state.latencySharingEnabled,
videoQuality: state.videoQuality,
screenSharePreset: state.screenSharePreset,
};
+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);
// Качество публикации — из предпочтений клиента.
+14
View File
@@ -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);
+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: '' },
}),
),
);
+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();
+4
View File
@@ -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);