diff --git a/web/src/api/metrics.ts b/web/src/api/metrics.ts index febcd59..582852c 100644 --- a/web/src/api/metrics.ts +++ b/web/src/api/metrics.ts @@ -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; diff --git a/web/src/components/instance/InstanceDashboard.tsx b/web/src/components/instance/InstanceDashboard.tsx index 591c709..ba60ae9 100644 --- a/web/src/components/instance/InstanceDashboard.tsx +++ b/web/src/components/instance/InstanceDashboard.tsx @@ -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 ( @@ -30,33 +30,20 @@ export function InstanceDashboard({ state }: InstanceDashboardProps) { -
- + -
@@ -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 ( +
+
+

{title}

+ {hint} +
+
+ + +
+
+ ); +} + interface DiskStatProps { value: string; title: string; diff --git a/web/src/components/voice/VoiceIndicators.tsx b/web/src/components/voice/VoiceIndicators.tsx index 7eecc18..4e75716 100644 --- a/web/src/components/voice/VoiceIndicators.tsx +++ b/web/src/components/voice/VoiceIndicators.tsx @@ -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 = { 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 ( ); } +/** + * Точка качества с видимой подписью: «Отличное соединение, 42 мс» в плитке + * участника. Пинг обновляется раз в секунду вместе с состоянием связи. + */ +export function VoiceQualityBadge({ + quality, + latencyMs, +}: { + quality: VoiceQuality | null; + latencyMs?: number | undefined; +}) { + const { t } = useTranslation(); + if (quality === null) { + return null; + } + return ( + + + {qualityLabel(t, quality, latencyMs)} + + ); +} + /** * Иконка состояния участника: перечёркнутая, когда флаг выключен. * Подпись обязательна — по ней состояние читают и тесты, и скринридер. diff --git a/web/src/components/voice/VoiceOccupantRow.tsx b/web/src/components/voice/VoiceOccupantRow.tsx index 5ad1ee6..5a30fb2 100644 --- a/web/src/components/voice/VoiceOccupantRow.tsx +++ b/web/src/components/voice/VoiceOccupantRow.tsx @@ -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} /> - + (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({ {occupant.name} - + {occupant.isLocal ? ( <> {/* Статистика своей публикации: разрешение, fps, кодек, канал (7.14). */} diff --git a/web/src/components/voice/voiceQualityLabel.ts b/web/src/components/voice/voiceQualityLabel.ts new file mode 100644 index 0000000..d9f5f25 --- /dev/null +++ b/web/src/components/voice/voiceQualityLabel.ts @@ -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 })}`; +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 540cce0..5492ed8 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index cf48392..01e6e52 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -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": "Язык и регион", diff --git a/web/src/lib/livekit.ts b/web/src/lib/livekit.ts index 992db5f..114e13d 100644 --- a/web/src/lib/livekit.ts +++ b/web/src/lib/livekit.ts @@ -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; disconnect: () => Promise; @@ -121,6 +133,12 @@ export interface VoiceRoomHandle { setVideoQuality: (quality: VoicePublishQuality) => Promise; /** Статистика своей публикации для попапа «Статистика». */ fetchStats: () => Promise; + /** + * Запускает публикацию своего пинга раз в секунду и отдаёт функцию остановки. + * Значение уходит всем участникам комнаты по data-каналу, поэтому пинг + * каждого виден остальным (AGENT.md 7.14). + */ + startLatencyReporting: (shouldReport?: () => boolean) => () => void; switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise; participants: () => VoiceParticipant[]; } @@ -234,6 +252,68 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise boolean): (() => void) => { + let stopped = false; + const tick = async (): Promise => { + // Выключенный пинг не только не публикуется, но и не опрашивает 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 => { - 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 { + 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 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() {

{t('settings.voice.soundboardHint')}

+ {/* Пинг участников: задержка в комнате и публикация своей (AGENT.md 7.14). */} +
+ {t('settings.voice.latency')} + +

{t('settings.voice.latencySharingHint')}

+
+

{t('settings.voice.stub')}

); diff --git a/web/src/stores/voice.ts b/web/src/stores/voice.ts index 85a93b4..4d086cd 100644 --- a/web/src/stores/voice.ts +++ b/web/src/stores/voice.ts @@ -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((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, }; diff --git a/web/src/stores/voiceConnection.ts b/web/src/stores/voiceConnection.ts index 23ccc95..3e24167 100644 --- a/web/src/stores/voiceConnection.ts +++ b/web/src/stores/voiceConnection.ts @@ -53,6 +53,11 @@ export interface VoiceConnectionState { channelId: string | null; /** Участники комнаты глазами SDK: речь, качество, дорожки. */ participants: VoiceParticipant[]; + /** + * Пинг участников до инстанса, мс: своё значение измеряем по статистике + * WebRTC, чужие приходят по data-каналу (AGENT.md 7.14). + */ + latencies: Record; /** Опубликован ли свой микрофон. */ micEnabled: boolean; /** Локальное глушение: чужой звук не слышен. */ @@ -105,6 +110,7 @@ const defaults = { guildId: null, channelId: null, participants: [] as VoiceParticipant[], + latencies: {} as Record, 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((set, get) = /** Отключение от SDK-комнаты без запросов: используется и в leave, и в reset. */ const closeRoom = async (): Promise => { + stopLatencyReporting?.(); + stopLatencyReporting = null; + set({ latencies: {} }); const handle = room; room = null; if (handle === null) { @@ -396,6 +407,20 @@ export const useVoiceConnectionStore = create((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((set, get) = } return; } + // Пинг публикуется раз в секунду и виден всем участникам комнаты. + stopLatencyReporting = handle.startLatencyReporting( + () => useVoiceStore.getState().latencySharingEnabled, + ); await handle.setMicrophoneEnabled(preferences.micEnabled && !pushToTalk); await handle.setCameraEnabled(preferences.cameraEnabled); // Качество публикации — из предпочтений клиента. diff --git a/web/tests/fakeLiveKit.ts b/web/tests/fakeLiveKit.ts index 7768d87..b19b665 100644 --- a/web/tests/fakeLiveKit.ts +++ b/web/tests/fakeLiveKit.ts @@ -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(); @@ -156,6 +159,12 @@ export class FakeParticipant { this.room.emit(RoomEvent.TrackUnsubscribed); } + /** Отправка data-сообщения: тесты проверяют тему, payload и надёжность. */ + publishData(payload: Uint8Array, options?: unknown): Promise { + 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(); handlers.add(handler); diff --git a/web/tests/instanceDashboard.test.tsx b/web/tests/instanceDashboard.test.tsx index 1ec5b8f..afd1b2f 100644 --- a/web/tests/instanceDashboard.test.tsx +++ b/web/tests/instanceDashboard.test.tsx @@ -15,6 +15,13 @@ function metricsPayload(overrides: Record = {}) { 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 { + await waitFor(() => { + expect(within(dialog).getByTestId('row-container-cpu-value').textContent).not.toBe('—'); + }); +} + /** openInstance открывает настройки и переходит на вкладку «Инстанс». */ async function openInstance(): Promise { 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: '' }, }), ), ); diff --git a/web/tests/voice.test.tsx b/web/tests/voice.test.tsx index 4c510e5..4e3b0b1 100644 --- a/web/tests/voice.test.tsx +++ b/web/tests/voice.test.tsx @@ -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(); diff --git a/web/tests/voiceStats.test.tsx b/web/tests/voiceStats.test.tsx index 872694c..a7d7373 100644 --- a/web/tests/voiceStats.test.tsx +++ b/web/tests/voiceStats.test.tsx @@ -121,6 +121,10 @@ async function bootPanel() { , ); + // Фоновая публикация пинга опрашивает SDK раз в секунду; тесты попапа + // считают обращения к SDK, поэтому выключаем её и проверяем отдельно. + useVoiceStore.setState({ latencySharingEnabled: false }); + await act(async () => { await useVoiceConnectionStore.getState().join('g-1', 'c-voice'); await useVoiceConnectionStore.getState().setCameraEnabled(true);