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
@@ -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 })}`;
}