Compare commits
3 Commits
ce6a566f83
...
3fe634c9bc
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fe634c9bc | |||
| 5668d779df | |||
| 3cfab89965 |
@@ -67,6 +67,8 @@ type metricsCheck struct {
|
|||||||
type metricsPayload struct {
|
type metricsPayload struct {
|
||||||
CPU metricsCPU `json:"cpu"`
|
CPU metricsCPU `json:"cpu"`
|
||||||
Memory metricsMemory `json:"memory"`
|
Memory metricsMemory `json:"memory"`
|
||||||
|
HostCPU metricsCPU `json:"host_cpu"`
|
||||||
|
HostMemory metricsMemory `json:"host_memory"`
|
||||||
Disk metricsDisk `json:"disk"`
|
Disk metricsDisk `json:"disk"`
|
||||||
Database metricsDatabase `json:"database"`
|
Database metricsDatabase `json:"database"`
|
||||||
Checks []metricsCheck `json:"checks"`
|
Checks []metricsCheck `json:"checks"`
|
||||||
@@ -145,6 +147,25 @@ func (s *Server) collectMetrics(ctx context.Context) metricsPayload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Второй ряд дашборда — вся машина: видно, сколько ресурсов у хоста всего.
|
||||||
|
if s.hostCPUSampler == nil {
|
||||||
|
problems = append(problems, "host cpu: sampler is not configured")
|
||||||
|
} else if percent, cores, source, err := s.hostCPUSampler.CPUPercent(); err != nil {
|
||||||
|
problems = append(problems, "host cpu: "+err.Error())
|
||||||
|
} else {
|
||||||
|
payload.HostCPU = metricsCPU{Percent: round1(percent), Cores: cores, Source: source}
|
||||||
|
}
|
||||||
|
if memory, err := sysinfo.ReadHostMemory(s.sysinfoPaths); err != nil {
|
||||||
|
problems = append(problems, "host memory: "+err.Error())
|
||||||
|
} else {
|
||||||
|
payload.HostMemory = metricsMemory{
|
||||||
|
UsedBytes: memory.UsedBytes,
|
||||||
|
TotalBytes: memory.TotalBytes,
|
||||||
|
Percent: round1(memory.Percent()),
|
||||||
|
Source: memory.Source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if disk, err := sysinfo.ReadDisk(s.cfg.DataDir); err != nil {
|
if disk, err := sysinfo.ReadDisk(s.cfg.DataDir); err != nil {
|
||||||
problems = append(problems, "disk: "+err.Error())
|
problems = append(problems, "disk: "+err.Error())
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ type metricsBody struct {
|
|||||||
Percent float64 `json:"percent"`
|
Percent float64 `json:"percent"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
} `json:"memory"`
|
} `json:"memory"`
|
||||||
|
HostCPU struct {
|
||||||
|
Percent float64 `json:"percent"`
|
||||||
|
Cores float64 `json:"cores"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
} `json:"host_cpu"`
|
||||||
|
HostMemory struct {
|
||||||
|
UsedBytes int64 `json:"used_bytes"`
|
||||||
|
TotalBytes int64 `json:"total_bytes"`
|
||||||
|
Percent float64 `json:"percent"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
} `json:"host_memory"`
|
||||||
Disk struct {
|
Disk struct {
|
||||||
UsedBytes int64 `json:"used_bytes"`
|
UsedBytes int64 `json:"used_bytes"`
|
||||||
TotalBytes int64 `json:"total_bytes"`
|
TotalBytes int64 `json:"total_bytes"`
|
||||||
@@ -105,6 +116,18 @@ func TestInstanceMetricsPayload(t *testing.T) {
|
|||||||
} else if payload.Memory.UsedBytes <= 0 || payload.Memory.Percent <= 0 || payload.Memory.Percent > 100 {
|
} else if payload.Memory.UsedBytes <= 0 || payload.Memory.Percent <= 0 || payload.Memory.Percent > 100 {
|
||||||
t.Fatalf("память = %+v", payload.Memory)
|
t.Fatalf("память = %+v", payload.Memory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Второй ряд дашборда — вся машина.
|
||||||
|
if payload.HostCPU.Source == "" {
|
||||||
|
t.Log("источники процессора хоста недоступны (не Linux) — проверка пропущена")
|
||||||
|
} else if payload.HostCPU.Source != "host" || payload.HostCPU.Cores <= 0 {
|
||||||
|
t.Fatalf("процессор хоста = %+v", payload.HostCPU)
|
||||||
|
}
|
||||||
|
if payload.HostMemory.TotalBytes == 0 {
|
||||||
|
t.Log("память хоста недоступна (не Linux) — проверка пропущена")
|
||||||
|
} else if payload.HostMemory.Source != "host" || payload.HostMemory.Percent <= 0 {
|
||||||
|
t.Fatalf("память хоста = %+v", payload.HostMemory)
|
||||||
|
}
|
||||||
if payload.Disk.TotalBytes <= 0 || payload.Disk.UsedBytes <= 0 || payload.Disk.FreeBytes <= 0 {
|
if payload.Disk.TotalBytes <= 0 || payload.Disk.UsedBytes <= 0 || payload.Disk.FreeBytes <= 0 {
|
||||||
t.Fatalf("диск = %+v", payload.Disk)
|
t.Fatalf("диск = %+v", payload.Disk)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,9 +67,11 @@ type Server struct {
|
|||||||
voice *voice.TokenIssuer
|
voice *voice.TokenIssuer
|
||||||
// voiceAdmin — RoomService для модерации на стороне SFU.
|
// voiceAdmin — RoomService для модерации на стороне SFU.
|
||||||
voiceAdmin *voice.AdminClient
|
voiceAdmin *voice.AdminClient
|
||||||
// sysinfoPaths и cpuSampler — источники живых метрик дашборда инстанса.
|
// sysinfoPaths, cpuSampler и hostCPUSampler — источники живых метрик
|
||||||
|
// дашборда инстанса: контейнер и вся машина.
|
||||||
sysinfoPaths sysinfo.Paths
|
sysinfoPaths sysinfo.Paths
|
||||||
cpuSampler *sysinfo.Sampler
|
cpuSampler *sysinfo.Sampler
|
||||||
|
hostCPUSampler *sysinfo.Sampler
|
||||||
// metricsLimiter — отдельный лимит для ручки метрик (опрос раз в секунду).
|
// metricsLimiter — отдельный лимит для ручки метрик (опрос раз в секунду).
|
||||||
metricsLimiter *httpx.RateLimiter
|
metricsLimiter *httpx.RateLimiter
|
||||||
// health — кэш тяжёлых проверок инстанса.
|
// health — кэш тяжёлых проверок инстанса.
|
||||||
@@ -116,6 +118,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
voiceAdmin: voice.NewAdminClient(voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL), cfg.LiveKitAPIURL),
|
voiceAdmin: voice.NewAdminClient(voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL), cfg.LiveKitAPIURL),
|
||||||
}
|
}
|
||||||
s.cpuSampler = sysinfo.NewSampler(s.sysinfoPaths)
|
s.cpuSampler = sysinfo.NewSampler(s.sysinfoPaths)
|
||||||
|
s.hostCPUSampler = sysinfo.NewHostSampler(s.sysinfoPaths)
|
||||||
switch {
|
switch {
|
||||||
case deps.Permissions != nil:
|
case deps.Permissions != nil:
|
||||||
s.perms = deps.Permissions
|
s.perms = deps.Permissions
|
||||||
|
|||||||
@@ -57,16 +57,26 @@ type CPUSample struct {
|
|||||||
// дают нагрузку за последнюю секунду, редкие вызовы — среднее за паузу.
|
// дают нагрузку за последнюю секунду, редкие вызовы — среднее за паузу.
|
||||||
type Sampler struct {
|
type Sampler struct {
|
||||||
paths Paths
|
paths Paths
|
||||||
|
// host заставляет считать нагрузку всей машины (/proc/stat), даже когда
|
||||||
|
// у контейнера есть лимиты: дашборд показывает оба ряда.
|
||||||
|
host bool
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
last *CPUSample
|
last *CPUSample
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSampler создаёт сэмплер процессорного времени.
|
// NewSampler создаёт сэмплер процессорного времени: cgroup контейнера, а при
|
||||||
|
// отсутствии лимитов — вся машина.
|
||||||
func NewSampler(paths Paths) *Sampler {
|
func NewSampler(paths Paths) *Sampler {
|
||||||
return &Sampler{paths: paths}
|
return &Sampler{paths: paths}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewHostSampler создаёт сэмплер нагрузки всей машины: нужен для второго ряда
|
||||||
|
// дашборда, когда контейнер ограничен профилем.
|
||||||
|
func NewHostSampler(paths Paths) *Sampler {
|
||||||
|
return &Sampler{paths: paths, host: true}
|
||||||
|
}
|
||||||
|
|
||||||
// CPUPercent возвращает загрузку в процентах от доступного времени и число
|
// CPUPercent возвращает загрузку в процентах от доступного времени и число
|
||||||
// ядер. Первый вызов без предыдущего снимка возвращает 0: дельты ещё нет.
|
// ядер. Первый вызов без предыдущего снимка возвращает 0: дельты ещё нет.
|
||||||
func (s *Sampler) CPUPercent() (percent float64, cores float64, source string, err error) {
|
func (s *Sampler) CPUPercent() (percent float64, cores float64, source string, err error) {
|
||||||
@@ -109,9 +119,11 @@ func (s *Sampler) CPUPercent() (percent float64, cores float64, source string, e
|
|||||||
// readCPU предпочитает cgroup v2: он показывает нагрузку контейнера и его
|
// readCPU предпочитает cgroup v2: он показывает нагрузку контейнера и его
|
||||||
// лимит, а /proc/stat — всю машину целиком.
|
// лимит, а /proc/stat — всю машину целиком.
|
||||||
func (s *Sampler) readCPU() (CPUSample, float64, string, error) {
|
func (s *Sampler) readCPU() (CPUSample, float64, string, error) {
|
||||||
|
if !s.host {
|
||||||
if sample, cores, ok := s.readCgroupCPU(); ok {
|
if sample, cores, ok := s.readCgroupCPU(); ok {
|
||||||
return sample, cores, "cgroup", nil
|
return sample, cores, "cgroup", nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
sample, cores, err := s.readProcStat()
|
sample, cores, err := s.readProcStat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CPUSample{}, 0, "", err
|
return CPUSample{}, 0, "", err
|
||||||
@@ -234,6 +246,12 @@ func ReadMemory(paths Paths) (Memory, error) {
|
|||||||
return readMemInfo(paths.MemInfo)
|
return readMemInfo(paths.MemInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadHostMemory всегда читает память всей машины (/proc/meminfo): второй ряд
|
||||||
|
// дашборда показывает хост даже при лимитах контейнера.
|
||||||
|
func ReadHostMemory(paths Paths) (Memory, error) {
|
||||||
|
return readMemInfo(paths.MemInfo)
|
||||||
|
}
|
||||||
|
|
||||||
func readCgroupMemory(dir string) (Memory, bool) {
|
func readCgroupMemory(dir string) (Memory, bool) {
|
||||||
current, err := readInt(filepath.Join(dir, "memory.current"))
|
current, err := readInt(filepath.Join(dir, "memory.current"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -87,6 +87,53 @@ func TestCPUPercentFallsBackToProcStat(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHostSamplerIgnoresCgroup(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// cgroup показывает нагрузку контейнера, /proc/stat — всей машины.
|
||||||
|
writeFile(t, dir, "cpu.max", "200000 100000\n")
|
||||||
|
writeFile(t, dir, "cpu.stat", "usage_usec 1000000\n")
|
||||||
|
statPath := writeFile(t, dir, "stat", "cpu 100 0 100 800 0 0 0 0 0 0\n")
|
||||||
|
|
||||||
|
now := time.Unix(1_700_000_000, 0)
|
||||||
|
paths := sysinfo.Paths{
|
||||||
|
ProcStat: statPath,
|
||||||
|
MemInfo: filepath.Join(dir, "meminfo"),
|
||||||
|
CgroupDir: dir,
|
||||||
|
WallClock: func() time.Time { return now },
|
||||||
|
}
|
||||||
|
|
||||||
|
host := sysinfo.NewHostSampler(paths)
|
||||||
|
if _, cores, source, err := host.CPUPercent(); err != nil || source != "host" || cores < 1 {
|
||||||
|
t.Fatalf("хост-сэмплер: источник=%s ядер=%v err=%v", source, cores, err)
|
||||||
|
}
|
||||||
|
container := sysinfo.NewSampler(paths)
|
||||||
|
if _, cores, source, err := container.CPUPercent(); err != nil || source != "cgroup" || cores != 2 {
|
||||||
|
t.Fatalf("контейнерный сэмплер: источник=%s ядер=%v err=%v", source, cores, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadHostMemoryIgnoresCgroupLimit(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeFile(t, dir, "memory.current", "15728640\n")
|
||||||
|
writeFile(t, dir, "memory.max", "805306368\n")
|
||||||
|
writeFile(t, dir, "meminfo", "MemTotal: 3479592 kB\nMemAvailable: 1000000 kB\n")
|
||||||
|
|
||||||
|
memory, err := sysinfo.ReadHostMemory(sysinfo.Paths{
|
||||||
|
MemInfo: filepath.Join(dir, "meminfo"),
|
||||||
|
CgroupDir: dir,
|
||||||
|
ProcStat: filepath.Join(dir, "stat"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("память хоста: %v", err)
|
||||||
|
}
|
||||||
|
if memory.Source != "host" || memory.TotalBytes != 3479592*1024 {
|
||||||
|
t.Fatalf("память хоста = %+v", memory)
|
||||||
|
}
|
||||||
|
if memory.UsedBytes != (3479592-1000000)*1024 {
|
||||||
|
t.Fatalf("занято = %d", memory.UsedBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReadMemoryPrefersCgroupLimit(t *testing.T) {
|
func TestReadMemoryPrefersCgroupLimit(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
writeFile(t, dir, "memory.current", "15728640\n")
|
writeFile(t, dir, "memory.current", "15728640\n")
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ export interface InstanceMetrics {
|
|||||||
percent: number;
|
percent: number;
|
||||||
source: 'cgroup' | 'host' | '';
|
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: {
|
disk: {
|
||||||
used_bytes: number;
|
used_bytes: number;
|
||||||
total_bytes: number;
|
total_bytes: number;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { InstanceMetrics } from '@/api/metrics';
|
||||||
import { Card } from '@/components/ui/primitives';
|
import { Card } from '@/components/ui/primitives';
|
||||||
import { Gauge } from '@/components/ui/Gauge';
|
import { Gauge } from '@/components/ui/Gauge';
|
||||||
import { formatBytes } from '@/lib/format';
|
import { formatBytes } from '@/lib/format';
|
||||||
@@ -10,9 +11,10 @@ interface InstanceDashboardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Дашборд инстанса: полукруглые индикаторы процессора и памяти, ниже — два
|
* Дашборд инстанса: два ряда полукруглых индикаторов (контейнер и вся машина),
|
||||||
* блока про диск (занято / всего) и вес базы данных. Значения обновляются раз
|
* ниже — два блока про диск (занято / всего) и вес базы данных. Значения
|
||||||
* в секунду, подписи у блоков диска показываются только при наведении.
|
* обновляются раз в секунду, подписи у блоков диска показываются только при
|
||||||
|
* наведении, а у рядов подпись стоит сверху.
|
||||||
*/
|
*/
|
||||||
export function InstanceDashboard({ state }: InstanceDashboardProps) {
|
export function InstanceDashboard({ state }: InstanceDashboardProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
@@ -28,25 +30,20 @@ export function InstanceDashboard({ state }: InstanceDashboardProps) {
|
|||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center justify-around gap-6">
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
<Gauge
|
<GaugeRow
|
||||||
testId="gauge-cpu"
|
testId="row-container"
|
||||||
value={metrics?.cpu.percent ?? 0}
|
title={t('settings.instance.dashboard.containerRow')}
|
||||||
label={t('settings.instance.dashboard.cpu')}
|
hint={t('settings.instance.dashboard.containerHint')}
|
||||||
display={formatPercent(metrics?.cpu.percent ?? 0, locale)}
|
cpu={metrics?.cpu}
|
||||||
caption={t('settings.instance.dashboard.cpuCaption', {
|
memory={metrics?.memory}
|
||||||
count: Math.round(metrics?.cpu.cores ?? 0),
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
<Gauge
|
<GaugeRow
|
||||||
testId="gauge-memory"
|
testId="row-host"
|
||||||
value={metrics?.memory.percent ?? 0}
|
title={t('settings.instance.dashboard.hostRow')}
|
||||||
label={t('settings.instance.dashboard.memory')}
|
hint={t('settings.instance.dashboard.hostHint')}
|
||||||
display={formatPercent(metrics?.memory.percent ?? 0, locale)}
|
cpu={metrics?.host_cpu}
|
||||||
caption={`${formatBytes(metrics?.memory.used_bytes ?? 0, locale)} / ${formatBytes(
|
memory={metrics?.host_memory}
|
||||||
metrics?.memory.total_bytes ?? 0,
|
|
||||||
locale,
|
|
||||||
)}`}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -54,10 +51,14 @@ export function InstanceDashboard({ state }: InstanceDashboardProps) {
|
|||||||
{/* Подписи не показываем: они раскрываются при наведении на значение. */}
|
{/* Подписи не показываем: они раскрываются при наведении на значение. */}
|
||||||
<DiskStat
|
<DiskStat
|
||||||
testId="disk-usage"
|
testId="disk-usage"
|
||||||
value={`${formatBytes(metrics?.disk.used_bytes ?? 0, locale)} / ${formatBytes(
|
value={
|
||||||
metrics?.disk.total_bytes ?? 0,
|
metrics === undefined
|
||||||
|
? '— / —'
|
||||||
|
: `${formatBytes(metrics.disk.used_bytes, locale)} / ${formatBytes(
|
||||||
|
metrics.disk.total_bytes,
|
||||||
locale,
|
locale,
|
||||||
)}`}
|
)}`
|
||||||
|
}
|
||||||
title={t('settings.instance.dashboard.diskHint', {
|
title={t('settings.instance.dashboard.diskHint', {
|
||||||
free: formatBytes(metrics?.disk.free_bytes ?? 0, locale),
|
free: formatBytes(metrics?.disk.free_bytes ?? 0, locale),
|
||||||
})}
|
})}
|
||||||
@@ -77,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 {
|
interface DiskStatProps {
|
||||||
value: string;
|
value: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { qualityLabel } from '@/components/voice/voiceQualityLabel';
|
||||||
|
|
||||||
import type { VoiceQuality } from '@/lib/livekit';
|
import type { VoiceQuality } from '@/lib/livekit';
|
||||||
|
|
||||||
/** Цвет точки качества связи: зелёный — отлично, красный — связи нет. */
|
/** Цвет точки качества связи: зелёный — отлично, красный — связи нет. */
|
||||||
@@ -11,13 +13,22 @@ const qualityClass: Record<VoiceQuality, string> = {
|
|||||||
unknown: 'bg-fg-muted',
|
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();
|
const { t } = useTranslation();
|
||||||
if (quality === null) {
|
if (quality === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const label = t(`voice.quality.${quality}`);
|
const label = qualityLabel(t, quality, latencyMs);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
role="img"
|
role="img"
|
||||||
@@ -25,11 +36,40 @@ export function VoiceQualityDot({ quality }: { quality: VoiceQuality | null }) {
|
|||||||
title={label}
|
title={label}
|
||||||
data-testid="voice-quality"
|
data-testid="voice-quality"
|
||||||
data-quality={quality}
|
data-quality={quality}
|
||||||
|
data-latency={latencyMs ?? ''}
|
||||||
className={`inline-block h-2 w-2 shrink-0 rounded-full ${qualityClass[quality]}`}
|
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 { t } = useTranslation();
|
||||||
const permissions = useVoiceModeration(guildId);
|
const permissions = useVoiceModeration(guildId);
|
||||||
const moveTargets = useVoiceChannels(guildId).filter((channel) => channel.id !== channelId);
|
const moveTargets = useVoiceChannels(guildId).filter((channel) => channel.id !== channelId);
|
||||||
|
// Подписка именно на карту пингов: селектор-функция не вызывает перерисовку.
|
||||||
|
const latencies = useVoiceConnectionStore((state) => state.latencies);
|
||||||
const setParticipantMuted = useVoiceConnectionStore((state) => state.setParticipantMuted);
|
const setParticipantMuted = useVoiceConnectionStore((state) => state.setParticipantMuted);
|
||||||
const setParticipantDeafened = useVoiceConnectionStore((state) => state.setParticipantDeafened);
|
const setParticipantDeafened = useVoiceConnectionStore((state) => state.setParticipantDeafened);
|
||||||
const moveParticipant = useVoiceConnectionStore((state) => state.moveParticipant);
|
const moveParticipant = useVoiceConnectionStore((state) => state.moveParticipant);
|
||||||
@@ -87,7 +89,7 @@ export function VoiceOccupantRow({
|
|||||||
camera={occupant.camera}
|
camera={occupant.camera}
|
||||||
screen={occupant.screen}
|
screen={occupant.screen}
|
||||||
/>
|
/>
|
||||||
<VoiceQualityDot quality={occupant.quality} />
|
<VoiceQualityDot quality={occupant.quality} latencyMs={latencies[occupant.userId]} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<VoiceOccupantMenu
|
<VoiceOccupantMenu
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Avatar } from '@/components/ui/Avatar';
|
|||||||
import { Menu, MenuGroupLabel, MenuItem } from '@/components/ui/Menu';
|
import { Menu, MenuGroupLabel, MenuItem } from '@/components/ui/Menu';
|
||||||
import { Card, Button } from '@/components/ui/primitives';
|
import { Card, Button } from '@/components/ui/primitives';
|
||||||
import { RemoteAudio, RemoteVideo } from '@/components/voice/RemoteAudio';
|
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 { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu';
|
||||||
import { VoiceSoundboard } from '@/components/voice/VoiceSoundboard';
|
import { VoiceSoundboard } from '@/components/voice/VoiceSoundboard';
|
||||||
import { VoiceStatsButton } from '@/components/voice/VoiceStatsButton';
|
import { VoiceStatsButton } from '@/components/voice/VoiceStatsButton';
|
||||||
@@ -50,6 +50,8 @@ function VoiceTile({
|
|||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const volume = useVoiceStore((state) => state.participantVolumes[occupant.userId] ?? 100);
|
const volume = useVoiceStore((state) => state.participantVolumes[occupant.userId] ?? 100);
|
||||||
|
// Подписка именно на карту пингов: селектор-функция не вызывает перерисовку.
|
||||||
|
const latencies = useVoiceConnectionStore((state) => state.latencies);
|
||||||
// Общая громкость звука умножается на личную громкость участника.
|
// Общая громкость звука умножается на личную громкость участника.
|
||||||
const outputVolume = useVoiceStore((state) => state.outputVolume);
|
const outputVolume = useVoiceStore((state) => state.outputVolume);
|
||||||
const deafened = useVoiceConnectionStore((state) => state.deafened);
|
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}>
|
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={occupant.name}>
|
||||||
{occupant.name}
|
{occupant.name}
|
||||||
</span>
|
</span>
|
||||||
<VoiceQualityDot quality={occupant.quality} />
|
<VoiceQualityBadge quality={occupant.quality} latencyMs={latencies[occupant.userId]} />
|
||||||
{occupant.isLocal ? (
|
{occupant.isLocal ? (
|
||||||
<>
|
<>
|
||||||
{/* Статистика своей публикации: разрешение, fps, кодек, канал (7.14). */}
|
{/* Статистика своей публикации: разрешение, 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 })}`;
|
||||||
|
}
|
||||||
@@ -344,7 +344,10 @@
|
|||||||
"medium": "Medium quality",
|
"medium": "Medium quality",
|
||||||
"high": "High quality"
|
"high": "High quality"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"latency": "{{ms}} ms",
|
||||||
|
"latencyHint": "Participant ping to the instance",
|
||||||
|
"latencyUnknown": "ping unknown"
|
||||||
},
|
},
|
||||||
"friends": {
|
"friends": {
|
||||||
"rail": "Friends",
|
"rail": "Friends",
|
||||||
@@ -740,7 +743,11 @@
|
|||||||
"cpuCaption_other": "{{count}} cores",
|
"cpuCaption_other": "{{count}} cores",
|
||||||
"memory": "Memory",
|
"memory": "Memory",
|
||||||
"diskHint": "Disk: used / total. {{free}} free",
|
"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": {
|
"health": {
|
||||||
"title": "Instance healthcheck",
|
"title": "Instance healthcheck",
|
||||||
@@ -947,7 +954,10 @@
|
|||||||
"eventSounds": "Server event sounds",
|
"eventSounds": "Server event sounds",
|
||||||
"eventSoundsHint": "Play the server palette sounds: member joins, mentions and other events.",
|
"eventSoundsHint": "Play the server palette sounds: member joins, mentions and other events.",
|
||||||
"soundboard": "Soundboard",
|
"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": {
|
"language": {
|
||||||
"title": "Language & region",
|
"title": "Language & region",
|
||||||
|
|||||||
@@ -344,7 +344,10 @@
|
|||||||
"medium": "Среднее качество",
|
"medium": "Среднее качество",
|
||||||
"high": "Высокое качество"
|
"high": "Высокое качество"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"latency": "{{ms}} мс",
|
||||||
|
"latencyHint": "Пинг участника до инстанса",
|
||||||
|
"latencyUnknown": "пинг неизвестен"
|
||||||
},
|
},
|
||||||
"friends": {
|
"friends": {
|
||||||
"rail": "Друзья",
|
"rail": "Друзья",
|
||||||
@@ -740,7 +743,11 @@
|
|||||||
"cpuCaption_other": "{{count}} ядра",
|
"cpuCaption_other": "{{count}} ядра",
|
||||||
"memory": "Память",
|
"memory": "Память",
|
||||||
"diskHint": "Диск: занято / всего. Свободно {{free}}",
|
"diskHint": "Диск: занято / всего. Свободно {{free}}",
|
||||||
"databaseHint": "База данных: файл и журнал WAL ({{wal}})"
|
"databaseHint": "База данных: файл и журнал WAL ({{wal}})",
|
||||||
|
"containerRow": "Контейнер инстанса",
|
||||||
|
"containerHint": "Лимиты профиля",
|
||||||
|
"hostRow": "Сервер целиком",
|
||||||
|
"hostHint": "Вся машина"
|
||||||
},
|
},
|
||||||
"health": {
|
"health": {
|
||||||
"title": "Healthcheck инстанса",
|
"title": "Healthcheck инстанса",
|
||||||
@@ -947,7 +954,10 @@
|
|||||||
"eventSounds": "Звуки событий сервера",
|
"eventSounds": "Звуки событий сервера",
|
||||||
"eventSoundsHint": "Проигрывать звуки палитры сервера: вход участника, упоминания и другие события.",
|
"eventSoundsHint": "Проигрывать звуки палитры сервера: вход участника, упоминания и другие события.",
|
||||||
"soundboard": "Саундборд",
|
"soundboard": "Саундборд",
|
||||||
"soundboardHint": "Проигрывать звуки саундборда в голосовой комнате."
|
"soundboardHint": "Проигрывать звуки саундборда в голосовой комнате.",
|
||||||
|
"latency": "Пинг участников",
|
||||||
|
"latencySharing": "Показывать пинг в голосовой комнате",
|
||||||
|
"latencySharingHint": "Задержка участников до инстанса обновляется раз в секунду; свой пинг публикуется в комнате."
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"title": "Язык и регион",
|
"title": "Язык и регион",
|
||||||
|
|||||||
+105
-3
@@ -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';
|
import type { VoiceDeviceKind, VoicePublishQuality } from '@/stores/voice';
|
||||||
|
|
||||||
@@ -106,8 +106,20 @@ export interface VoiceRoomCallbacks {
|
|||||||
onStatus: (status: VoiceRoomStatus) => void;
|
onStatus: (status: VoiceRoomStatus) => void;
|
||||||
/** SDK сообщает, что свой микрофон говорит, хотя публикация выключена. */
|
/** SDK сообщает, что свой микрофон говорит, хотя публикация выключена. */
|
||||||
onSpeakingWhileMuted: () => void;
|
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 {
|
export interface VoiceRoomHandle {
|
||||||
connect: (url: string, token: string) => Promise<void>;
|
connect: (url: string, token: string) => Promise<void>;
|
||||||
disconnect: () => Promise<void>;
|
disconnect: () => Promise<void>;
|
||||||
@@ -121,6 +133,12 @@ export interface VoiceRoomHandle {
|
|||||||
setVideoQuality: (quality: VoicePublishQuality) => Promise<void>;
|
setVideoQuality: (quality: VoicePublishQuality) => Promise<void>;
|
||||||
/** Статистика своей публикации для попапа «Статистика». */
|
/** Статистика своей публикации для попапа «Статистика». */
|
||||||
fetchStats: () => Promise<VoiceStats>;
|
fetchStats: () => Promise<VoiceStats>;
|
||||||
|
/**
|
||||||
|
* Запускает публикацию своего пинга раз в секунду и отдаёт функцию остановки.
|
||||||
|
* Значение уходит всем участникам комнаты по data-каналу, поэтому пинг
|
||||||
|
* каждого виден остальным (AGENT.md 7.14).
|
||||||
|
*/
|
||||||
|
startLatencyReporting: (shouldReport?: () => boolean) => () => void;
|
||||||
switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise<void>;
|
switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise<void>;
|
||||||
participants: () => VoiceParticipant[];
|
participants: () => VoiceParticipant[];
|
||||||
}
|
}
|
||||||
@@ -234,6 +252,68 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
|
|||||||
return fallback;
|
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`, если поля нет или оно не число. */
|
/** Число из отчёта WebRTC: `null`, если поля нет или оно не число. */
|
||||||
const num = (value: unknown): number | null =>
|
const num = (value: unknown): number | null =>
|
||||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||||
@@ -248,7 +328,9 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
|
|||||||
* Чего SDK не отдал — остаётся `null`, интерфейс покажет «—».
|
* Чего SDK не отдал — остаётся `null`, интерфейс покажет «—».
|
||||||
*/
|
*/
|
||||||
const collectStats = async (): Promise<VoiceStats> => {
|
const collectStats = async (): Promise<VoiceStats> => {
|
||||||
const track = videoPublication();
|
// Качество канала (RTT, jitter, потери) есть и в аудио-сессии, поэтому
|
||||||
|
// отчёт берём с любой своей дорожки: видео, иначе микрофон.
|
||||||
|
const track = videoPublication() ?? microphoneTrack();
|
||||||
if (track === undefined || typeof track.getRTCStatsReport !== 'function') {
|
if (track === undefined || typeof track.getRTCStatsReport !== 'function') {
|
||||||
previousSample = null;
|
previousSample = null;
|
||||||
return { ...EMPTY_VOICE_STATS };
|
return { ...EMPTY_VOICE_STATS };
|
||||||
@@ -350,8 +432,26 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
|
|||||||
callbacks.onParticipants(snapshotAll(room));
|
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.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.TrackSubscribed, refresh);
|
||||||
room.on(RoomEvent.TrackUnsubscribed, refresh);
|
room.on(RoomEvent.TrackUnsubscribed, refresh);
|
||||||
room.on(RoomEvent.TrackPublished, refresh);
|
room.on(RoomEvent.TrackPublished, refresh);
|
||||||
@@ -389,6 +489,8 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
|
|||||||
await room.disconnect();
|
await room.disconnect();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
startLatencyReporting,
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
room.removeAllListeners();
|
room.removeAllListeners();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -39,9 +39,11 @@ export function VoiceSettingsSection() {
|
|||||||
const pttKey = useVoiceStore((state) => state.pttKey);
|
const pttKey = useVoiceStore((state) => state.pttKey);
|
||||||
const eventSoundsEnabled = useVoiceStore((state) => state.eventSoundsEnabled);
|
const eventSoundsEnabled = useVoiceStore((state) => state.eventSoundsEnabled);
|
||||||
const soundboardEnabled = useVoiceStore((state) => state.soundboardEnabled);
|
const soundboardEnabled = useVoiceStore((state) => state.soundboardEnabled);
|
||||||
|
const latencySharingEnabled = useVoiceStore((state) => state.latencySharingEnabled);
|
||||||
const setMicActivation = useVoiceStore((state) => state.setMicActivation);
|
const setMicActivation = useVoiceStore((state) => state.setMicActivation);
|
||||||
const setPttKey = useVoiceStore((state) => state.setPttKey);
|
const setPttKey = useVoiceStore((state) => state.setPttKey);
|
||||||
const setEventSoundsEnabled = useVoiceStore((state) => state.setEventSoundsEnabled);
|
const setEventSoundsEnabled = useVoiceStore((state) => state.setEventSoundsEnabled);
|
||||||
|
const setLatencySharingEnabled = useVoiceStore((state) => state.setLatencySharingEnabled);
|
||||||
const setSoundboardEnabled = useVoiceStore((state) => state.setSoundboardEnabled);
|
const setSoundboardEnabled = useVoiceStore((state) => state.setSoundboardEnabled);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -149,6 +151,21 @@ export function VoiceSettingsSection() {
|
|||||||
<p className="ml-6 text-xs text-fg-muted">{t('settings.voice.soundboardHint')}</p>
|
<p className="ml-6 text-xs text-fg-muted">{t('settings.voice.soundboardHint')}</p>
|
||||||
</fieldset>
|
</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>
|
<p className="mt-4 text-xs text-fg-muted">{t('settings.voice.stub')}</p>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ export interface VoicePreferences {
|
|||||||
eventSoundsEnabled: boolean;
|
eventSoundsEnabled: boolean;
|
||||||
/** Саундборд включён. */
|
/** Саундборд включён. */
|
||||||
soundboardEnabled: boolean;
|
soundboardEnabled: boolean;
|
||||||
|
/**
|
||||||
|
* Пинг участников: показывать задержку в комнате и делиться своей
|
||||||
|
* (AGENT.md 7.14). Выключено — SDK не опрашивается вовсе.
|
||||||
|
*/
|
||||||
|
latencySharingEnabled: boolean;
|
||||||
/** Качество своей публикации видео. */
|
/** Качество своей публикации видео. */
|
||||||
videoQuality: VoicePublishQuality;
|
videoQuality: VoicePublishQuality;
|
||||||
/** Пресет разрешения и fps для демонстрации экрана. */
|
/** Пресет разрешения и fps для демонстрации экрана. */
|
||||||
@@ -81,6 +86,7 @@ interface VoiceState extends VoicePreferences {
|
|||||||
setPttKey: (key: PushToTalkKey) => void;
|
setPttKey: (key: PushToTalkKey) => void;
|
||||||
setEventSoundsEnabled: (enabled: boolean) => void;
|
setEventSoundsEnabled: (enabled: boolean) => void;
|
||||||
setSoundboardEnabled: (enabled: boolean) => void;
|
setSoundboardEnabled: (enabled: boolean) => void;
|
||||||
|
setLatencySharingEnabled: (enabled: boolean) => void;
|
||||||
setVideoQuality: (quality: VoicePublishQuality) => void;
|
setVideoQuality: (quality: VoicePublishQuality) => void;
|
||||||
setScreenSharePreset: (preset: ScreenSharePresetId) => void;
|
setScreenSharePreset: (preset: ScreenSharePresetId) => void;
|
||||||
/** Вернуть исходные предпочтения (используется в тестах). */
|
/** Вернуть исходные предпочтения (используется в тестах). */
|
||||||
@@ -103,6 +109,7 @@ const defaults: VoicePreferences = {
|
|||||||
pttKey: 'Space',
|
pttKey: 'Space',
|
||||||
eventSoundsEnabled: true,
|
eventSoundsEnabled: true,
|
||||||
soundboardEnabled: true,
|
soundboardEnabled: true,
|
||||||
|
latencySharingEnabled: true,
|
||||||
videoQuality: 'high',
|
videoQuality: 'high',
|
||||||
screenSharePreset: '1080p30',
|
screenSharePreset: '1080p30',
|
||||||
};
|
};
|
||||||
@@ -184,6 +191,10 @@ function initialPreferences(): VoicePreferences {
|
|||||||
pttKey: asChoice(stored['pttKey'], PUSH_TO_TALK_KEYS, defaults.pttKey),
|
pttKey: asChoice(stored['pttKey'], PUSH_TO_TALK_KEYS, defaults.pttKey),
|
||||||
eventSoundsEnabled: asBoolean(stored['eventSoundsEnabled'], defaults.eventSoundsEnabled),
|
eventSoundsEnabled: asBoolean(stored['eventSoundsEnabled'], defaults.eventSoundsEnabled),
|
||||||
soundboardEnabled: asBoolean(stored['soundboardEnabled'], defaults.soundboardEnabled),
|
soundboardEnabled: asBoolean(stored['soundboardEnabled'], defaults.soundboardEnabled),
|
||||||
|
latencySharingEnabled: asBoolean(
|
||||||
|
stored['latencySharingEnabled'],
|
||||||
|
defaults.latencySharingEnabled,
|
||||||
|
),
|
||||||
videoQuality: asChoice(stored['videoQuality'], PUBLISH_QUALITIES, defaults.videoQuality),
|
videoQuality: asChoice(stored['videoQuality'], PUBLISH_QUALITIES, defaults.videoQuality),
|
||||||
screenSharePreset: asChoice(
|
screenSharePreset: asChoice(
|
||||||
stored['screenSharePreset'],
|
stored['screenSharePreset'],
|
||||||
@@ -213,6 +224,7 @@ function persist(state: VoicePreferences): void {
|
|||||||
pttKey: state.pttKey,
|
pttKey: state.pttKey,
|
||||||
eventSoundsEnabled: state.eventSoundsEnabled,
|
eventSoundsEnabled: state.eventSoundsEnabled,
|
||||||
soundboardEnabled: state.soundboardEnabled,
|
soundboardEnabled: state.soundboardEnabled,
|
||||||
|
latencySharingEnabled: state.latencySharingEnabled,
|
||||||
videoQuality: state.videoQuality,
|
videoQuality: state.videoQuality,
|
||||||
screenSharePreset: state.screenSharePreset,
|
screenSharePreset: state.screenSharePreset,
|
||||||
} satisfies VoicePreferences),
|
} satisfies VoicePreferences),
|
||||||
@@ -264,6 +276,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => {
|
|||||||
setPttKey: (key) => patch({ pttKey: key }),
|
setPttKey: (key) => patch({ pttKey: key }),
|
||||||
setEventSoundsEnabled: (enabled) => patch({ eventSoundsEnabled: enabled }),
|
setEventSoundsEnabled: (enabled) => patch({ eventSoundsEnabled: enabled }),
|
||||||
setSoundboardEnabled: (enabled) => patch({ soundboardEnabled: enabled }),
|
setSoundboardEnabled: (enabled) => patch({ soundboardEnabled: enabled }),
|
||||||
|
setLatencySharingEnabled: (enabled) => patch({ latencySharingEnabled: enabled }),
|
||||||
setVideoQuality: (quality) => patch({ videoQuality: quality }),
|
setVideoQuality: (quality) => patch({ videoQuality: quality }),
|
||||||
setScreenSharePreset: (preset) => patch({ screenSharePreset: preset }),
|
setScreenSharePreset: (preset) => patch({ screenSharePreset: preset }),
|
||||||
|
|
||||||
@@ -292,6 +305,7 @@ function preferencesOf(state: VoiceState): VoicePreferences {
|
|||||||
pttKey: state.pttKey,
|
pttKey: state.pttKey,
|
||||||
eventSoundsEnabled: state.eventSoundsEnabled,
|
eventSoundsEnabled: state.eventSoundsEnabled,
|
||||||
soundboardEnabled: state.soundboardEnabled,
|
soundboardEnabled: state.soundboardEnabled,
|
||||||
|
latencySharingEnabled: state.latencySharingEnabled,
|
||||||
videoQuality: state.videoQuality,
|
videoQuality: state.videoQuality,
|
||||||
screenSharePreset: state.screenSharePreset,
|
screenSharePreset: state.screenSharePreset,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ export interface VoiceConnectionState {
|
|||||||
channelId: string | null;
|
channelId: string | null;
|
||||||
/** Участники комнаты глазами SDK: речь, качество, дорожки. */
|
/** Участники комнаты глазами SDK: речь, качество, дорожки. */
|
||||||
participants: VoiceParticipant[];
|
participants: VoiceParticipant[];
|
||||||
|
/**
|
||||||
|
* Пинг участников до инстанса, мс: своё значение измеряем по статистике
|
||||||
|
* WebRTC, чужие приходят по data-каналу (AGENT.md 7.14).
|
||||||
|
*/
|
||||||
|
latencies: Record<string, number>;
|
||||||
/** Опубликован ли свой микрофон. */
|
/** Опубликован ли свой микрофон. */
|
||||||
micEnabled: boolean;
|
micEnabled: boolean;
|
||||||
/** Локальное глушение: чужой звук не слышен. */
|
/** Локальное глушение: чужой звук не слышен. */
|
||||||
@@ -105,6 +110,7 @@ const defaults = {
|
|||||||
guildId: null,
|
guildId: null,
|
||||||
channelId: null,
|
channelId: null,
|
||||||
participants: [] as VoiceParticipant[],
|
participants: [] as VoiceParticipant[],
|
||||||
|
latencies: {} as Record<string, number>,
|
||||||
micEnabled: true,
|
micEnabled: true,
|
||||||
deafened: false,
|
deafened: false,
|
||||||
cameraEnabled: false,
|
cameraEnabled: false,
|
||||||
@@ -118,6 +124,8 @@ const defaults = {
|
|||||||
|
|
||||||
/** Активная комната SDK. Живёт вне стора: подписки и таймеры не сериализуются. */
|
/** Активная комната SDK. Живёт вне стора: подписки и таймеры не сериализуются. */
|
||||||
let room: VoiceRoomHandle | null = null;
|
let room: VoiceRoomHandle | null = null;
|
||||||
|
/** Остановка публикации пинга: живёт вместе с активной комнатой. */
|
||||||
|
let stopLatencyReporting: (() => void) | null = null;
|
||||||
/** Канал, о котором уже знает сервер: только для него шлём PATCH и leave. */
|
/** Канал, о котором уже знает сервер: только для него шлём PATCH и leave. */
|
||||||
let joinedChannelId: string | null = null;
|
let joinedChannelId: string | null = null;
|
||||||
/** Номер подключения: ответы устаревших операций не должны менять состояние. */
|
/** Номер подключения: ответы устаревших операций не должны менять состояние. */
|
||||||
@@ -184,6 +192,9 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
|
|||||||
|
|
||||||
/** Отключение от SDK-комнаты без запросов: используется и в leave, и в reset. */
|
/** Отключение от SDK-комнаты без запросов: используется и в leave, и в reset. */
|
||||||
const closeRoom = async (): Promise<void> => {
|
const closeRoom = async (): Promise<void> => {
|
||||||
|
stopLatencyReporting?.();
|
||||||
|
stopLatencyReporting = null;
|
||||||
|
set({ latencies: {} });
|
||||||
const handle = room;
|
const handle = room;
|
||||||
room = null;
|
room = null;
|
||||||
if (handle === null) {
|
if (handle === null) {
|
||||||
@@ -396,6 +407,20 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
|
|||||||
void finishSession();
|
void finishSession();
|
||||||
},
|
},
|
||||||
onSpeakingWhileMuted: warnSpeakingWhileMuted,
|
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) {
|
if (mine !== generation) {
|
||||||
handle.dispose();
|
handle.dispose();
|
||||||
@@ -410,6 +435,10 @@ export const useVoiceConnectionStore = create<VoiceConnectionState>((set, get) =
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Пинг публикуется раз в секунду и виден всем участникам комнаты.
|
||||||
|
stopLatencyReporting = handle.startLatencyReporting(
|
||||||
|
() => useVoiceStore.getState().latencySharingEnabled,
|
||||||
|
);
|
||||||
await handle.setMicrophoneEnabled(preferences.micEnabled && !pushToTalk);
|
await handle.setMicrophoneEnabled(preferences.micEnabled && !pushToTalk);
|
||||||
await handle.setCameraEnabled(preferences.cameraEnabled);
|
await handle.setCameraEnabled(preferences.cameraEnabled);
|
||||||
// Качество публикации — из предпочтений клиента.
|
// Качество публикации — из предпочтений клиента.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const RoomEvent = {
|
|||||||
Reconnecting: 'reconnecting',
|
Reconnecting: 'reconnecting',
|
||||||
Reconnected: 'reconnected',
|
Reconnected: 'reconnected',
|
||||||
Disconnected: 'disconnected',
|
Disconnected: 'disconnected',
|
||||||
|
DataReceived: 'dataReceived',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const ConnectionQuality = {
|
export const ConnectionQuality = {
|
||||||
@@ -106,6 +107,8 @@ export class FakeParticipant {
|
|||||||
readonly isLocal: boolean;
|
readonly isLocal: boolean;
|
||||||
/** Вызовы, которые проверяют тесты: `setMicrophoneEnabled(false)` и т.п. */
|
/** Вызовы, которые проверяют тесты: `setMicrophoneEnabled(false)` и т.п. */
|
||||||
readonly calls: { method: string; value: unknown }[] = [];
|
readonly calls: { method: string; value: unknown }[] = [];
|
||||||
|
/** Отправленные data-пакеты: `{ payload, options }` каждого publishData. */
|
||||||
|
readonly data: { payload: Uint8Array; options: unknown }[] = [];
|
||||||
/** Параметры захвата экрана: `{ enabled, options }` каждого вызова. */
|
/** Параметры захвата экрана: `{ enabled, options }` каждого вызова. */
|
||||||
readonly screenOptions: { enabled: boolean; options: unknown }[] = [];
|
readonly screenOptions: { enabled: boolean; options: unknown }[] = [];
|
||||||
private readonly publications = new Map<string, FakePublication>();
|
private readonly publications = new Map<string, FakePublication>();
|
||||||
@@ -156,6 +159,12 @@ export class FakeParticipant {
|
|||||||
this.room.emit(RoomEvent.TrackUnsubscribed);
|
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 {
|
setSpeaking(speaking: boolean): void {
|
||||||
this.isSpeaking = speaking;
|
this.isSpeaking = speaking;
|
||||||
}
|
}
|
||||||
@@ -238,6 +247,11 @@ export class FakeRoom {
|
|||||||
this.stats = entries;
|
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 {
|
on(event: string, handler: Handler): this {
|
||||||
const handlers = this.handlers.get(event) ?? new Set<Handler>();
|
const handlers = this.handlers.get(event) ?? new Set<Handler>();
|
||||||
handlers.add(handler);
|
handlers.add(handler);
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ function metricsPayload(overrides: Record<string, unknown> = {}) {
|
|||||||
percent: 25,
|
percent: 25,
|
||||||
source: 'cgroup',
|
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: {
|
disk: {
|
||||||
used_bytes: 12 * 1024 ** 3,
|
used_bytes: 12 * 1024 ** 3,
|
||||||
total_bytes: 24 * 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 открывает настройки и переходит на вкладку «Инстанс». */
|
/** openInstance открывает настройки и переходит на вкладку «Инстанс». */
|
||||||
async function openInstance(): Promise<HTMLElement> {
|
async function openInstance(): Promise<HTMLElement> {
|
||||||
const gear = await screen.findByLabelText('Настройки пользователя');
|
const gear = await screen.findByLabelText('Настройки пользователя');
|
||||||
@@ -74,27 +88,49 @@ describe('дашборд инстанса', () => {
|
|||||||
renderApp('/app/empty');
|
renderApp('/app/empty');
|
||||||
const dialog = await openInstance();
|
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(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 % от длины дуги.
|
// Полукруг заполняется пропорционально: 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(' ');
|
const [filled, total] = (arc.getAttribute('stroke-dasharray') ?? '').split(' ');
|
||||||
expect(Number(filled)).toBeGreaterThan(0);
|
expect(Number(filled)).toBeGreaterThan(0);
|
||||||
expect(Number(filled) / Number(total)).toBeCloseTo(0.234, 2);
|
expect(Number(filled) / Number(total)).toBeCloseTo(0.234, 2);
|
||||||
|
|
||||||
const memory = within(dialog).getByTestId('gauge-memory');
|
const memory = within(container).getByTestId('row-container-memory');
|
||||||
expect(within(memory).getByTestId('gauge-memory-value')).toHaveTextContent('25 %');
|
expect(within(memory).getByTestId('row-container-memory-value')).toHaveTextContent('25 %');
|
||||||
expect(memory).toHaveAttribute('aria-valuenow', '25');
|
expect(memory).toHaveAttribute('aria-valuenow', '25');
|
||||||
// Подпись памяти — занято / всего.
|
// Подпись памяти — занято / всего.
|
||||||
expect(within(memory).getByText(/192 MB \/ 768 MB/)).toBeVisible();
|
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 () => {
|
it('показывает диск и базу без подписей — только в тултипах', async () => {
|
||||||
installFetch(adminRoutes());
|
installFetch(adminRoutes());
|
||||||
renderApp('/app/empty');
|
renderApp('/app/empty');
|
||||||
const dialog = await openInstance();
|
const dialog = await openInstance();
|
||||||
|
|
||||||
|
await waitForMetrics(dialog);
|
||||||
const disk = await within(dialog).findByTestId('disk-usage');
|
const disk = await within(dialog).findByTestId('disk-usage');
|
||||||
const diskValue = within(disk).getByTestId('disk-usage-value');
|
const diskValue = within(disk).getByTestId('disk-usage-value');
|
||||||
expect(diskValue).toHaveTextContent('12 GB / 24 GB');
|
expect(diskValue).toHaveTextContent('12 GB / 24 GB');
|
||||||
@@ -149,6 +185,8 @@ describe('дашборд инстанса', () => {
|
|||||||
],
|
],
|
||||||
cpu: { percent: 0, cores: 0, source: '' },
|
cpu: { percent: 0, cores: 0, source: '' },
|
||||||
memory: { used_bytes: 0, total_bytes: 0, percent: 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: '' },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
import { makeVoiceState, voiceRoutes, type VoiceRoutesOptions } from './voiceRoutes';
|
import { makeVoiceState, voiceRoutes, type VoiceRoutesOptions } from './voiceRoutes';
|
||||||
import type { InstanceMeta } from '@/api/meta';
|
import type { InstanceMeta } from '@/api/meta';
|
||||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||||
|
import { useVoiceStore } from '@/stores/voice';
|
||||||
|
|
||||||
const ALL_PERMISSIONS = [
|
const ALL_PERMISSIONS = [
|
||||||
'VIEW_GUILD',
|
'VIEW_GUILD',
|
||||||
@@ -197,6 +198,78 @@ describe('голосовая комната: вход', () => {
|
|||||||
expect(voice.joinCount()).toBe(0);
|
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 () => {
|
it('переходит в другую комнату через выход из старой', async () => {
|
||||||
const voice = await bootVoice();
|
const voice = await bootVoice();
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ async function bootPanel() {
|
|||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Фоновая публикация пинга опрашивает SDK раз в секунду; тесты попапа
|
||||||
|
// считают обращения к SDK, поэтому выключаем её и проверяем отдельно.
|
||||||
|
useVoiceStore.setState({ latencySharingEnabled: false });
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await useVoiceConnectionStore.getState().join('g-1', 'c-voice');
|
await useVoiceConnectionStore.getState().join('g-1', 'c-voice');
|
||||||
await useVoiceConnectionStore.getState().setCameraEnabled(true);
|
await useVoiceConnectionStore.getState().setCameraEnabled(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user