Files
vidconf/frontend/src/components/admin/AdminSettingsTab.tsx
Max Ronzhin 173d384f06
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
feat(admin): рычаги нагрузки медиа — потолок качества публикации и лимит плиток
instance_settings.media_limits (publish_quality_cap: off/720p/360p/180p,
stage_max_tiles: 4/9/16/25) — новая вкладка «Нагрузка» в админке, дефолты
(off/25) сохраняют текущее поведение существующих инсталляций.

Настройка отдаётся не только GET /admin/settings, но и в join-ответе
(JoinOut) — участнику нужно иметь её на руках ДО публикации трека, а
/admin/settings доступен только администратору.

Потолок качества применяется через RoomOptions.publishDefaults
(videoEncoding + videoSimulcastLayers на пресетах VideoPresets LiveKit) —
режет битрейт верхнего слоя симулкаста, реальное разрешение WebRTC
подстраивает сам. Лимит плиток — фильтрация STAGE_GRID_LAYOUTS по
columns*rows в StageGrid, лишние участники уходят на страницу пагинации
вместо подписки.

Значение приезжает в joinState вместе с токеном ДО первого рендера
LiveKitRoom (RoomPage не рендерит его, пока joinState не заполнен целиком),
поэтому смена настройки не переподключает уже вошедшего участника —
roomOptions пересчитывается по стабильной ссылке на joinState, которая
после подключения не меняется.

Проверено вживую на локальном стенде (docker compose --profile media):
сохранение/персист настроек, join отдаёт актуальные значения, уже
подключённый участник не разрывается при смене настройки в другом окне.
2026-08-02 19:53:23 +03:00

509 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, X } from 'lucide-react'
import {
getAdminSettings,
sendTestEmail,
updateAdminSettings,
type AiLevel,
type SettingsOut,
type SettingsUpdateIn,
type TestEmailOut,
} from '@/api/admin'
import type { PublishQualityCap, SummaryRecipientsMode } from '@/api/conferences'
import { ApiError, errorDetail } from '@/api/client'
import { useAuth } from '@/auth/useAuth'
import { Select } from '@/components/ui/Select'
import { useToast } from '@/components/ui/ToastProvider'
const SUMMARY_RECIPIENTS_OPTIONS = [
{ value: 'all', label: 'Всем участникам' },
{ value: 'owner', label: 'Только организатору' },
]
const PUBLISH_QUALITY_CAP_OPTIONS = [
{ value: 'off', label: 'Без ограничения' },
{ value: '720p', label: 'Не выше 720p' },
{ value: '360p', label: 'Не выше 360p' },
{ value: '180p', label: 'Не выше 180p' },
]
const STAGE_MAX_TILES_OPTIONS = [
{ value: '25', label: '25 (5×5, без ограничения)' },
{ value: '16', label: '16 (4×4)' },
{ value: '9', label: '9 (3×3)' },
{ value: '4', label: '4 (2×2)' },
]
const AI_LEVEL_LABEL: Record<AiLevel, string> = {
min: 'Минимальный (CPU, faster-whisper small + Qwen2.5-3B)',
medium: 'Средний',
max: 'Максимальный',
}
/**
* Вкладка «Настройки» админки — раздела нет в макете `admin.html`, построена
* по паттерну того же макета (`section-tab`/`table-card` → карточки-секции
* с `toggle-row`/radio).
* Тумблер «Транскрибация и суммаризация» — единый переключатель AI-модуля
* (см. `SettingsOut.transcription_enabled`).
*
* Форма (`AdminSettingsForm`) вынесена отдельно и монтируется только после
* загрузки `data` — локальное состояние инициализируется прямо из пропсов
* при монтировании, без `useEffect`-синхронизации (react-hooks/set-state-in-effect).
*/
export function AdminSettingsTab() {
const { data, isLoading } = useQuery({ queryKey: ['admin', 'settings'], queryFn: getAdminSettings })
if (isLoading || !data) {
return <p className="field-hint">Загрузка настроек</p>
}
return <AdminSettingsForm data={data} />
}
function AdminSettingsForm({ data }: { data: SettingsOut }) {
const queryClient = useQueryClient()
const toast = useToast()
const { user } = useAuth()
const [chatEnabled, setChatEnabled] = useState(data.chat_enabled)
const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled)
const [aiLevel, setAiLevel] = useState<AiLevel>(data.ai_level)
const [recipients, setRecipients] = useState<SummaryRecipientsMode>(data.summary_recipients)
const [timezone, setTimezone] = useState(data.display_timezone)
const [teamChoiceEnabled, setTeamChoiceEnabled] = useState(data.registration_team_choice)
const [domainVerificationEnabled, setDomainVerificationEnabled] = useState(data.registration_email_domain_enabled)
const [emailDomains, setEmailDomains] = useState(data.registration_email_domains)
const [newDomainInput, setNewDomainInput] = useState('')
const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled)
const [contactEmail, setContactEmail] = useState(data.contact_email ?? '')
const [publishQualityCap, setPublishQualityCap] = useState<PublishQualityCap>(data.publish_quality_cap)
const [stageMaxTiles, setStageMaxTiles] = useState(data.stage_max_tiles)
const [testEmailTo, setTestEmailTo] = useState('')
const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null)
const mutation = useMutation({
mutationFn: (payload: SettingsUpdateIn) => updateAdminSettings(payload),
onSuccess: (result) => {
queryClient.setQueryData(['admin', 'settings'], result)
toast.show('Настройки сохранены', 'success')
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 400) {
toast.show(errorDetail(err) ?? 'Недоступное значение — проверьте уровень AI, таймзону и домен почты', 'error')
} else {
toast.show('Не удалось сохранить настройки', 'error')
}
},
})
const testEmailMutation = useMutation({
mutationFn: () => sendTestEmail(testEmailTo.trim() ? { to: testEmailTo.trim() } : {}),
onSuccess: (result) => setTestEmailResult(result),
onError: (err: unknown) => {
setTestEmailResult({
success: false,
message: (err instanceof ApiError && errorDetail(err)) || 'Не удалось отправить тестовое письмо',
smtp_host: null,
smtp_port: null,
})
},
})
function addDomain() {
const domain = newDomainInput.trim().toLowerCase().replace(/^@/, '')
if (!domain || emailDomains.includes(domain)) {
setNewDomainInput('')
return
}
setEmailDomains([...emailDomains, domain])
setNewDomainInput('')
}
function removeDomain(domain: string) {
setEmailDomains(emailDomains.filter((d) => d !== domain))
}
function handleSave() {
// Отправляем только реально изменённые поля (`SettingsUpdateIn` — набор
// опциональных полей именно для этого): иначе, например, ai_level
// уходил бы в PUT нетронутым при каждом сохранении и на слабом железе
// валился бы в 400, блокируя правку вообще любой другой настройки.
const payload: SettingsUpdateIn = {}
if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled
if (aiEnabled !== data.transcription_enabled) payload.transcription_enabled = aiEnabled
if (aiLevel !== data.ai_level) payload.ai_level = aiLevel
if (recipients !== data.summary_recipients) payload.summary_recipients = recipients
if (timezone.trim() !== data.display_timezone) payload.display_timezone = timezone.trim()
if (teamChoiceEnabled !== data.registration_team_choice) {
payload.registration_team_choice = teamChoiceEnabled
}
if (domainVerificationEnabled !== data.registration_email_domain_enabled) {
payload.registration_email_domain_enabled = domainVerificationEnabled
}
if (JSON.stringify(emailDomains) !== JSON.stringify(data.registration_email_domains)) {
payload.registration_email_domains = emailDomains
}
if (contactEmailEnabled !== data.contact_email_enabled) {
payload.contact_email_enabled = contactEmailEnabled
}
const trimmedContactEmail = contactEmail.trim() || null
if (trimmedContactEmail !== (data.contact_email ?? null)) {
payload.contact_email = trimmedContactEmail
}
if (publishQualityCap !== data.publish_quality_cap) payload.publish_quality_cap = publishQualityCap
if (stageMaxTiles !== data.stage_max_tiles) payload.stage_max_tiles = stageMaxTiles
mutation.mutate(payload)
}
return (
<div className="settings-grid">
<section className="settings-card">
<h2>Модули</h2>
<p className="desc">Включение и отключение возможностей инстанса переключатели плагинов из поставки.</p>
<div className="settings-card-body settings-card-body--spread">
<div className="toggle-row" style={{ borderTop: 'none', paddingTop: 0 }}>
<div className="toggle-copy">
<strong>Чат конференции</strong>
<span>Текстовый чат внутри комнаты для участников</span>
</div>
<label className="switch">
<input type="checkbox" checked={chatEnabled} onChange={(e) => setChatEnabled(e.target.checked)} />
<span className="slider" />
</label>
</div>
<div className="toggle-row">
<div className="toggle-copy">
<strong>Транскрибация и суммаризация (AI)</strong>
<span>Батч-обработка записи после конференции: расшифровка речи и саммари</span>
{aiEnabled && !data.transcription_queue_served && (
<span className="ai-level-reason">
<AlertTriangle style={{ width: 13, height: 13 }} aria-hidden="true" />
AI включён, но обработка очереди транскрибации недоступна сеансы не будут обработаны
</span>
)}
</div>
<label className="switch">
<input type="checkbox" checked={aiEnabled} onChange={(e) => setAiEnabled(e.target.checked)} />
<span className="slider" />
</label>
</div>
<div className="toggle-row">
<div className="toggle-copy">
<strong>Разрешить выбор команды при регистрации</strong>
<span>На экране регистрации появится поле «Команда» со списком команд инстанса</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={teamChoiceEnabled}
onChange={(e) => setTeamChoiceEnabled(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
<div className="toggle-row">
<div className="toggle-copy">
<strong>Верификация по домену почты</strong>
<span>Регистрация только с email эталонного домена (см. плитку «Эталон mail-домена»)</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={domainVerificationEnabled}
onChange={(e) => setDomainVerificationEnabled(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
{/*
Анонс записи конференций. Чекбокс не имеет состояния и не
участвует в сохранении формы — функция появится в одной из
следующих версий, здесь только заглушка-анонс в общем стиле
«Модулей».
*/}
<div className="toggle-row">
<div className="toggle-copy">
<strong>Возможность записи конференций</strong>
<span>Функция появится в ближайших версиях</span>
</div>
<label className="switch">
<input type="checkbox" checked={false} disabled readOnly />
<span className="slider" />
</label>
</div>
</div>
</section>
<div className="settings-stack">
<section className="settings-card">
<h2>Уровень AI</h2>
<p className="desc">Качество распознавания и суммаризации определяет размер моделей и требования к железу.</p>
<div className="settings-card-body">
<div className="ai-level-list">
{data.ai_levels.map((levelStatus) => (
<label
key={levelStatus.level}
className={`ai-level-option${!levelStatus.available ? ' is-disabled' : ''}`}
>
<input
type="radio"
name="ai-level"
checked={aiLevel === levelStatus.level}
disabled={!levelStatus.available || !aiEnabled}
onChange={() => setAiLevel(levelStatus.level)}
/>
<div>
<strong>{AI_LEVEL_LABEL[levelStatus.level]}</strong>
{!levelStatus.available && levelStatus.reason && (
<span className="ai-level-reason">
<AlertTriangle style={{ width: 13, height: 13 }} aria-hidden="true" />
{levelStatus.reason}
</span>
)}
</div>
</label>
))}
</div>
</div>
</section>
<section className="settings-card">
<h2>Эталон mail-домена</h2>
<p className="desc">Домены, с любым из которых сверяется email при регистрации, если включена верификация в «Модулях».</p>
<div className="settings-card-body">
{emailDomains.length > 0 && (
<div className="participants-chips">
{emailDomains.map((domain) => (
<span className="participant-chip" key={domain}>
{domain}
<button
type="button"
className="participant-chip-remove"
aria-label={`Убрать домен: ${domain}`}
onClick={() => removeDomain(domain)}
disabled={!domainVerificationEnabled}
>
<X style={{ width: 12, height: 12 }} aria-hidden="true" />
</button>
</span>
))}
</div>
)}
<div className="field" style={{ marginBottom: 0 }}>
<label htmlFor="settings-email-domain">Домен корпоративной почты</label>
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
<input
id="settings-email-domain"
type="text"
placeholder="company.ru"
value={newDomainInput}
disabled={!domainVerificationEnabled}
onChange={(e) => setNewDomainInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addDomain()
}
}}
/>
<button
type="button"
className="btn btn-secondary"
disabled={!domainVerificationEnabled || !newDomainInput.trim()}
onClick={addDomain}
>
Добавить
</button>
</div>
</div>
</div>
</section>
</div>
<section className="settings-card">
<h2>Рассылка саммари</h2>
<p className="desc">Кому по умолчанию отправляется письмо с саммари после конференции (переопределяется в карточке конференции).</p>
<div className="settings-card-body settings-card-body--center">
<div className="field" style={{ marginBottom: 0 }}>
<label id="settings-recipients-label" htmlFor="settings-recipients">
Получатели по умолчанию
</label>
<Select
id="settings-recipients"
aria-labelledby="settings-recipients-label"
value={recipients}
onChange={(v) => setRecipients(v as SummaryRecipientsMode)}
options={SUMMARY_RECIPIENTS_OPTIONS}
/>
</div>
</div>
</section>
<section className="settings-card">
<h2>Часовой пояс отображения</h2>
<p className="desc">IANA-таймзона для времени в письмах и .ics-приглашениях (в БД время всегда хранится в UTC).</p>
<div className="settings-card-body settings-card-body--center">
<div className="field" style={{ marginBottom: 0 }}>
<label htmlFor="settings-timezone">Таймзона</label>
<input
id="settings-timezone"
type="text"
list="timezone-options"
placeholder="Europe/Moscow"
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
/>
<datalist id="timezone-options">
<option value="Europe/Moscow" />
<option value="Europe/Kaliningrad" />
<option value="Europe/Samara" />
<option value="Asia/Yekaterinburg" />
<option value="Asia/Novosibirsk" />
<option value="Asia/Krasnoyarsk" />
<option value="Asia/Irkutsk" />
<option value="Asia/Vladivostok" />
<option value="UTC" />
</datalist>
</div>
</div>
</section>
<section className="settings-card">
<h2>Нагрузка</h2>
<p className="desc">
Рычаги для инстансов на слабом канале/железе режут исходящий трафик и нагрузку на
устройство участника. Дефолты сохраняют прежнее поведение (без ограничений).
</p>
<div className="settings-card-body settings-card-body--spread">
<div className="field" style={{ marginBottom: 0 }}>
<label id="settings-quality-cap-label" htmlFor="settings-quality-cap">
Потолок качества публикации видео
</label>
<Select
id="settings-quality-cap"
aria-labelledby="settings-quality-cap-label"
value={publishQualityCap}
onChange={(v) => setPublishQualityCap(v as PublishQualityCap)}
options={PUBLISH_QUALITY_CAP_OPTIONS}
/>
<p className="field-hint">
Ограничивает битрейт исходящего видео публикующего снижает нагрузку на его канал и
устройство, независимо от размера плитки у смотрящих (adaptiveStream режет с их
стороны отдельно)
</p>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label id="settings-max-tiles-label" htmlFor="settings-max-tiles">
Максимум плиток на экране
</label>
<Select
id="settings-max-tiles"
aria-labelledby="settings-max-tiles-label"
value={String(stageMaxTiles)}
onChange={(v) => setStageMaxTiles(Number(v))}
options={STAGE_MAX_TILES_OPTIONS}
/>
<p className="field-hint">
Сверх лимита участники уходят на следующую страницу сетки вместо подписки меньше
одновременных видеопотоков на канал и экран участника
</p>
</div>
</div>
</section>
<section className="settings-card">
<h2>Контактный адрес</h2>
<p className="desc">
Адрес для ответов на письма от инстанса (уходят от no-reply этот адрес подставляется
в заголовок «Reply-To»).
</p>
<div className="settings-card-body">
<div className="toggle-row" style={{ borderTop: 'none', paddingTop: 0 }}>
<div className="toggle-copy">
<strong>Контактный адрес включён</strong>
<span>Подставляется в Reply-To подтверждения регистрации, приглашений и саммари</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={contactEmailEnabled}
onChange={(e) => setContactEmailEnabled(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
<div className="field" style={{ marginBottom: 0, marginTop: 'var(--space-4)' }}>
<label htmlFor="settings-contact-email">Email</label>
<input
id="settings-contact-email"
type="email"
placeholder="contact@vidconf.ru"
value={contactEmail}
disabled={!contactEmailEnabled}
onChange={(e) => setContactEmail(e.target.value)}
/>
</div>
</div>
</section>
<section className="settings-card">
<h2>Тестовое письмо</h2>
<p className="desc">Отправить проверочное письмо синхронно, чтобы сразу увидеть результат почтовой конфигурации.</p>
<div className="settings-card-body">
<div className="field">
<label htmlFor="settings-test-email-to">Получатель</label>
<input
id="settings-test-email-to"
type="email"
placeholder={user?.email ?? 'you@example.com'}
value={testEmailTo}
onChange={(e) => setTestEmailTo(e.target.value)}
/>
</div>
<button
type="button"
className="btn btn-secondary"
disabled={testEmailMutation.isPending}
onClick={() => testEmailMutation.mutate()}
>
{testEmailMutation.isPending ? 'Отправляем…' : 'Отправить тестовое письмо'}
</button>
{testEmailResult && (
<p
className="field-hint"
style={{ color: testEmailResult.success ? 'var(--color-success)' : 'var(--color-danger)' }}
>
{testEmailResult.message}
{testEmailResult.smtp_host && ` (${testEmailResult.smtp_host}:${testEmailResult.smtp_port})`}
</p>
)}
</div>
</section>
<div className="settings-actions">
<button type="button" className="btn btn-primary" disabled={mutation.isPending} onClick={handleSave}>
{mutation.isPending ? 'Сохраняем…' : 'Сохранить настройки'}
</button>
</div>
</div>
)
}