12 Commits

Author SHA1 Message Date
54fd1a26d7 release: версия 0.0.9
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-28 00:26:40 +03:00
456cc58b25 docs(monitoring): описать node-exporter/cAdvisor и алерты по железу
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Документация мониторинга описывала только старые компоненты (prometheus,
postgres/redis-exporter, дашборд пайплайнов) — актуализирована под
node-exporter/cAdvisor, дашборд host.json и три новых алерта.
2026-07-28 00:22:08 +03:00
dccc369d0b fix(admin): дефолтные подвкладки конференций и пользователей
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Конференции открывались на «Все» — неинформативная сборная вкладка вместо
актуальных «Запланированные». Заодно перенесена вкладка «Все» в конец
списка фильтров (после «Завершённые»), чтобы порядок шёл от актуального
к общему.

Пользователи открывались на «Все» вместо «Активные» — админ по умолчанию
видел вперемешку с заблокированными.
2026-07-28 00:21:06 +03:00
77ee26014d feat(monitoring): метрики CPU/RAM/диска хоста и контейнеров
Дашборд «Пайплайны пост-обработки» покрывал только прикладную логику —
нехватка памяти/CPU на сервере была видна только косвенно, по латентности API.

Добавлены node-exporter (метрики хоста) и cAdvisor (метрики по контейнерам,
профиль monitoring) — оба без публикации портов наружу, Prometheus ходит
к ним по внутренней сети compose. Новый дашборд host.json («Хост и
контейнеры») и три алерта (HostMemoryLow/HostDiskLow/HostCpuHigh) с
порогами под сервер 1gb (8 ГБ RAM, 4 CPU, 50 ГБ диска).
2026-07-28 00:21:01 +03:00
413789ba22 release: версия 0.0.8
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-27 21:10:08 +03:00
c4721fcf99 feat(admin): поиск пользователей по названию команды
AdminUserRepository.list_paginated ищет теперь и по названию команды
(LEFT JOIN teams, как и раньше) — пользователи без команды по-прежнему
не пропадают из общей выдачи, просто не совпадают с этой частью поиска.
Плейсхолдер поля поиска в админке обновлён под новое поведение.
2026-07-27 21:04:26 +03:00
09215da22a feat(admin): вкладки фильтра по статусу в списке пользователей
Задача 3 переопределена оператором: вместо окончательного удаления
пользователя (упёрлось в CHECK-constraint'ы participant/chat_messages,
требующие миграции схемы — решили отложить) добавлены вкладки
«Активные»/«Заблокированные»/«Все» перед полем поиска в админке —
список фильтруется по `is_blocked` на бэкенде (GET /admin/users?status=).
2026-07-27 20:59:29 +03:00
25ffd9e678 feat(auth): несколько эталонных mail-доменов для верификации регистрации
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Настройка «Эталон mail-домена» теперь хранит список доменов вместо
одного — email при регистрации принимается, если совпадает с любым из
них. Старое значение в БД ({"domain": str|None}) читается прозрачно
(обратная совместимость без Alembic-миграции) и переписывается в новую
форму ({"domains": [...]}) при первом же сохранении настроек. В админке
добавление/удаление доменов — списком чипов; на экране регистрации
подсказка о несовпадении домена перечисляет все эталонные варианты.
2026-07-27 20:40:23 +03:00
e55a6aebe7 fix(admin): не блокировать сохранение настроек недоступным уровнем AI
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Валидация ai_level срабатывала на любой PUT /admin/settings, включая
случаи, когда фронт отправлял уже сохранённый (не изменённый) уровень —
на слабом железе это намертво блокировало правку любых других настроек.
Бэкенд теперь сравнивает patch.ai_level с текущим значением и проверяет
доступность только при фактической смене уровня; фронт дополнительно
отправляет в PUT только реально изменённые поля.
2026-07-27 20:25:31 +03:00
4b92f89efe release: версия 0.0.7
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-27 17:42:03 +03:00
b11feac529 feat(room): компактная шторка настроек устройств на мобильном
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
На ширине ≤600px (брейкпоинт комнаты) DeviceSettingsDialog рендерит те же
поля не модалкой по центру, а шторкой снизу (`room-sheet-overlay`/
`room-sheet-panel`) — по образцу решения оператора: так быстрее и привычнее
с телефона. Десктоп не меняется.

Закрытие — клик вне панели (было и раньше), Escape и возврат фокуса через
существующий useModalDismiss, и свайп вниз за ручку (JS считает смещение
пальца и либо закрывает панель, либо возвращает её на место пружиной).
2026-07-27 17:32:42 +03:00
e07217657f feat(room): выбор устройства аудиовывода в настройках
Третий селект в DeviceSettingsDialog (динамики/наушники/bluetooth) рядом
с уже существующими микрофоном и камерой — на useMediaDeviceSelect(kind:
'audiooutput'). Выбор персистится отдельно от usePersistentUserChoices
(LocalUserChoices LiveKit про аудиовыход не знает) и применяется заново
при следующем подключении через RoomOptions.audioOutput.

Поддержка определяется по наличию setSinkId в рантайме, не по User-Agent:
там, где браузер не умеет переключать вывод (iOS Safari), список скрыт,
вместо него — подсказка, что маршрутизация звука на стороне системы.
2026-07-27 17:31:35 +03:00
29 changed files with 1165 additions and 134 deletions

View File

@@ -98,7 +98,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.6
VIDCONF_VERSION=0.0.9
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,

View File

@@ -3,6 +3,68 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
## [0.0.9] — 2026-07-28
Метрики CPU/RAM/диска хоста и контейнеров в Grafana; дефолтные подвкладки
конференций и пользователей в админке.
### Добавлено
- Мониторинг железа: `node-exporter` (CPU/RAM/диск/сеть/load average хоста)
и `cAdvisor` (метрики по каждому контейнеру) — профиль compose
`monitoring`, без публикации портов наружу. Новый дашборд Grafana «Хост и
контейнеры». Три новых алерта Prometheus: `HostMemoryLow`, `HostDiskLow`,
`HostCpuHigh` (пороги — под сервер `1gb`: 8 ГБ RAM, 4 CPU, 50 ГБ диска).
### Изменено
- Админка, вкладка «Конференции»: вкладка «Все» перенесена в конец списка
фильтров (после «Завершённые»), по умолчанию открываются «Запланированные».
- Админка, вкладка «Пользователи»: по умолчанию открываются «Активные»
(было «Все»).
## [0.0.8] — 2026-07-27
Админка: несколько mail-доменов для регистрации, фильтр и поиск по команде в
списке пользователей, исправлен баг сохранения настроек.
### Исправлено
- Сохранение любой настройки инстанса блокировалось ошибкой «уровень AI
недоступен», даже если AI-модуль (транскрибация/суммаризация) выключен и
пользователь уровень не трогал — фронт отправлял в `PUT /admin/settings`
весь набор полей целиком, включая уже сохранённый `ai_level`. Теперь
доступность уровня проверяется только при его фактической смене; фронт
отправляет в запросе лишь реально изменённые поля.
### Добавлено
- «Эталон mail-домена» в настройках инстанса поддерживает несколько
доменов вместо одного — регистрация проходит с email с любого домена из
списка. Старое значение из БД (один домен) читается без миграции и
переписывается в новый формат при первом же сохранении.
- В списке пользователей админки — вкладки «Активные»/«Заблокированные»/
«Все» перед полем поиска.
- Поиск по пользователям учитывает и название команды — пользователи без
команды по-прежнему остаются в общей выдаче.
## [0.0.7] — 2026-07-27
Настройки устройств: выбор аудиовывода и компактная шторка на мобильном.
### Добавлено
- Выбор устройства аудиовывода (динамики/наушники/Bluetooth-гарнитура) в
«Настройках устройств» рядом с микрофоном и камерой. Выбор переживает
перезаход в комнату. Список показывается только там, где браузер умеет
им управлять (Android Chrome и другие Chromium на десктопе/мобильном);
в iOS Safari поддержки нет вовсе — вместо списка короткая подсказка, что
переключение звука там на стороне системы.
- На мобильной ширине «Настройки устройств» открываются компактной шторкой
снизу вместо модалки по центру экрана — закрывается свайпом вниз, тапом
вне панели или Escape. На десктопе поведение не изменилось.
### Примечания по эксплуатации
- Управлять маршрутизацией звука (принудительно включить громкую связь или
перекинуть звук на Bluetooth) из браузера нельзя в принципе — это делает
ОС, веб-страница может только выбрать устройство вывода там, где браузер
это позволяет (`setSinkId`).
## [0.0.6] — 2026-07-27
Управление фокусом основного окна конференции: участника можно закрепить, а
@@ -162,6 +224,9 @@
Первоначальная версия VidConf.
[0.0.9]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.9
[0.0.8]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.8
[0.0.7]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.7
[0.0.6]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.6
[0.0.5]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.5
[0.0.4]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.4

View File

@@ -1 +1 @@
0.0.6
0.0.9

View File

@@ -13,7 +13,7 @@
import uuid
from pathlib import Path
from typing import Annotated
from typing import Annotated, Literal
import anyio
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
@@ -174,12 +174,16 @@ async def send_conference_invitations(
async def list_users(
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
status: Annotated[Literal["active", "blocked"] | None, Query()] = None,
q: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query(gt=0, le=MAX_LIMIT)] = DEFAULT_LIMIT,
offset: Annotated[int, Query(ge=0)] = 0,
) -> AdminUserListOut:
"""Список всех пользователей инстанса с текстовым поиском по email/имени."""
rows, total = await AdminUserRepository(session).list_paginated(q=q, limit=limit, offset=offset)
"""Список пользователей инстанса — фильтр по статусу (`active`/`blocked`, без
параметра — все) и текстовый поиск по email/имени/названию команды."""
rows, total = await AdminUserRepository(session).list_paginated(
status=status, q=q, limit=limit, offset=offset
)
media_root = _media_root()
items = [
_to_admin_user_out(user, team_name=team_name, media_root=media_root)
@@ -460,7 +464,7 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
display_timezone=cfg.display_timezone,
registration_team_choice=cfg.registration_team_choice,
registration_email_domain_enabled=cfg.registration_email_domain_enabled,
registration_email_domain=cfg.registration_email_domain,
registration_email_domains=cfg.registration_email_domains,
contact_email_enabled=cfg.contact_email_enabled,
contact_email=cfg.contact_email,
)

View File

@@ -53,17 +53,17 @@ async def registration_options(
Список команд отдаётся только при включённой настройке инстанса
`registration_team_choice` — иначе пустой массив (справочник команд не
раскрывается, пока выбор выключен). `email_domain` — эталонный домен при
включённой настройке `registration_email_domain`, иначе `None`.
раскрывается, пока выбор выключен). `email_domains` — эталонные домены при
включённой настройке `registration_email_domain`, иначе пустой список.
"""
cfg = await InstanceSettingsService(session).get()
teams: list[RegistrationTeamOptionOut] = []
if cfg.registration_team_choice:
items, _ = await TeamRepository(session).list_all()
teams = [RegistrationTeamOptionOut(id=team.id, name=team.name) for team in items]
email_domain = cfg.registration_email_domain if cfg.registration_email_domain_enabled else None
email_domains = cfg.registration_email_domains if cfg.registration_email_domain_enabled else []
return RegistrationOptionsOut(
team_choice_enabled=cfg.registration_team_choice, teams=teams, email_domain=email_domain
team_choice_enabled=cfg.registration_team_choice, teams=teams, email_domains=email_domains
)

View File

@@ -77,11 +77,11 @@ class InstanceConfig(BaseModel):
# — см. `services/instance_settings.py`.
registration_team_choice: bool = False
# Верификация регистрирующихся по домену email: при включении
# `POST /auth/register` принимает только
# email с доменом `registration_email_domain` — см.
# `POST /auth/register` принимает только email с доменом из
# `registration_email_domains` (любым из списка) — см.
# `services/instance_settings.py`.
registration_email_domain_enabled: bool = False
registration_email_domain: str | None = None
registration_email_domains: list[str] = Field(default_factory=list)
# Контактный адрес инстанса — подставляется в `Reply-To` исходящих писем
# (сами письма уходят от `no-reply@`, отвечать на них некуда без этого
# адреса) — см. `services/instance_settings.py`, `services/email.py`.

View File

@@ -2,7 +2,7 @@
import uuid
from sqlalchemy import func, or_, select
from sqlalchemy import ColumnElement, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from models.conference import Conference
@@ -56,25 +56,37 @@ class AdminConferenceRepository:
class AdminUserRepository:
"""Постраничный список пользователей с текстовым поиском по email/имени."""
"""Постраничный список пользователей с фильтром по статусу и текстовым поиском."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_paginated(
self, *, q: str | None, limit: int, offset: int
self, *, status: str | None, q: str | None, limit: int, offset: int
) -> tuple[list[tuple[User, str | None]], int]:
"""Вернуть страницу пользователей (+ имя команды) и общее число совпадений.
`LEFT JOIN` на `teams` — имя команды нужно карточке профиля/таблице
админки, у пользователя без команды — `None`.
`status` — `"active"`/`"blocked"` (фильтр по `is_blocked`), `None` —
без фильтра (все пользователи). `q` ищет по email/имени пользователя
И по названию команды (`LEFT JOIN` на `teams` — нужен и для этого
поиска, и для колонки «Команда» в таблице админки; `LEFT`, а не
`INNER`, — пользователи без команды не должны пропадать из общей
выдачи, только не совпадать с поиском по названию команды).
"""
filters = []
filters: list[ColumnElement[bool]] = []
if status == "active":
filters.append(User.is_blocked.is_(False))
elif status == "blocked":
filters.append(User.is_blocked.is_(True))
if q:
like = f"%{q}%"
filters.append(or_(User.email.ilike(like), User.name_user.ilike(like)))
filters.append(
or_(User.email.ilike(like), User.name_user.ilike(like), Team.name.ilike(like))
)
count_stmt = select(func.count()).select_from(User)
count_stmt = (
select(func.count()).select_from(User).outerjoin(Team, User.team_id == Team.id)
)
items_stmt = (
select(User, Team.name)
.outerjoin(Team, User.team_id == Team.id)

View File

@@ -155,7 +155,7 @@ class SettingsOut(BaseModel):
display_timezone: str
registration_team_choice: bool
registration_email_domain_enabled: bool
registration_email_domain: str | None = None
registration_email_domains: list[str] = Field(default_factory=list)
contact_email_enabled: bool
contact_email: str | None = None

View File

@@ -103,11 +103,11 @@ class RegistrationOptionsOut(BaseModel):
`teams` отдаётся только при `team_choice_enabled=True` — иначе пустой
список (справочник команд не раскрывается, пока выбор выключен).
`email_domain` — эталонный домен при включённой верификации регистрации
`email_domains` — эталонные домены при включённой верификации регистрации
по домену email (настройка инстанса `registration_email_domain`), иначе
`None`.
пустой список.
"""
team_choice_enabled: bool
teams: list[RegistrationTeamOptionOut]
email_domain: str | None = None
email_domains: list[str] = Field(default_factory=list)

View File

@@ -106,9 +106,9 @@ class AuthService:
существует — иначе `InvalidTeamSelectionError` (публичный
эндпоинт, деталей не раскрываем). Если включена верификация домена
email (`registration_email_domain_enabled`), домен `email` (часть
после `@`, без учёта регистра) должен совпадать с эталонным —
иначе `InvalidEmailDomainError`. Обе проверки — до создания
пользователя.
после `@`, без учёта регистра) должен совпадать с одним из
эталонных доменов (`registration_email_domains`) — иначе
`InvalidEmailDomainError`. Обе проверки — до создания пользователя.
"""
existing = await self._users.get_by_email(email)
if existing is not None:
@@ -118,7 +118,7 @@ class AuthService:
if cfg.registration_email_domain_enabled:
email_domain = email.rsplit("@", 1)[-1].lower()
if email_domain != cfg.registration_email_domain:
if email_domain not in cfg.registration_email_domains:
raise InvalidEmailDomainError(email)
if team_id is not None:

View File

@@ -63,7 +63,12 @@ _DEFAULT_AI_LEVEL_VALUE = {"level": "min"}
_DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"}
_DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"}
_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE = {"enabled": False}
_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "domain": None}
_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "domains": []}
"""Формат значения ключа `registration_email_domain` в БД. До версии с
несколькими доменами хранилась форма `{"enabled": bool, "domain": str|None}`
(один домен) — читающий код (`_extract_email_domains`) понимает обе формы
для обратной совместимости с уже развёрнутыми инстансами; при первом же
`update()` значение переписывается в новую форму (см. `update`)."""
_DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": None}
# Простой паттерн доменного имени: минимум один символ, минимум одна точка,
@@ -95,7 +100,7 @@ class SettingsUpdateIn(BaseModel):
display_timezone: str | None = None
registration_team_choice: bool | None = None
registration_email_domain_enabled: bool | None = None
registration_email_domain: str | None = None
registration_email_domains: list[str] | None = None
contact_email_enabled: bool | None = None
contact_email: str | None = None
@@ -237,7 +242,20 @@ class InstanceSettingsService:
rows = await self._load_rows()
cfg = _build_config(rows)
if patch.ai_level is not None:
if patch.ai_level is not None and patch.ai_level != cfg.ai_level:
# Валидация только при фактической смене уровня (сравнение с уже
# сохранённым cfg.ai_level) — иначе фронт, отправляющий текущий
# ai_level вместе с любой другой правкой (см. `AdminSettingsTab`),
# блокировал бы сохранение несвязанных настроек на слабом железе,
# где текущий (давно и легитимно сохранённый) уровень недоступен
# по факту заново переоценённых требований (RAM/модели).
#
# Не ослабляем проверку и при отключённых transcriber.enabled/
# summarizer.enabled (когда уровень AI сейчас ни на что не
# влияет): если проверять по факту переключения — это осознанное
# намерение администратора сменить уровень, и молчаливое
# сохранение недоступного значения подставит администратора при
# последующем включении AI неработающей конфигурацией.
statuses = {status.level: status for status in detect_ai_levels(cfg)}
if not statuses[patch.ai_level].available:
raise InvalidAiLevelError(
@@ -267,26 +285,28 @@ class InstanceSettingsService:
if (
patch.registration_email_domain_enabled is not None
or patch.registration_email_domain is not None
or patch.registration_email_domains is not None
):
enabled = (
patch.registration_email_domain_enabled
if patch.registration_email_domain_enabled is not None
else cfg.registration_email_domain_enabled
)
raw_domain = (
patch.registration_email_domain
if patch.registration_email_domain is not None
else cfg.registration_email_domain
raw_domains = (
patch.registration_email_domains
if patch.registration_email_domains is not None
else cfg.registration_email_domains
)
domain = _normalize_email_domain(raw_domain) if raw_domain else None
if enabled and domain is None:
domains = _normalize_email_domains(raw_domains)
if enabled and not domains:
raise InvalidEmailDomainError(
"нельзя включить верификацию домена email без указания домена"
"нельзя включить верификацию домена email без указания хотя бы одного домена"
)
cfg.registration_email_domain_enabled = enabled
cfg.registration_email_domain = domain
await self._set(_KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "domain": domain})
cfg.registration_email_domains = domains
await self._set(
_KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "domains": domains}
)
if patch.contact_email_enabled is not None or patch.contact_email is not None:
contact_enabled = (
@@ -402,6 +422,32 @@ def _normalize_email_domain(domain: str) -> str:
return normalized
def _normalize_email_domains(domains: list[str]) -> list[str]:
"""Нормализовать список доменов: strip/lower/убрать «@» на каждом (см.
`_normalize_email_domain`), отбросить пустые строки, убрать дубликаты
(с сохранением порядка первого вхождения)."""
normalized: list[str] = []
for raw in domains:
if not raw.strip():
continue
domain = _normalize_email_domain(raw)
if domain not in normalized:
normalized.append(domain)
return normalized
def _extract_email_domains(value: dict[str, Any]) -> list[str]:
"""Достать список доменов из значения ключа `registration_email_domain`,
понимая и текущую форму (`domains: [...]`), и форму до многодоменной
поддержки (`domain: str | None`, один домен) — на проде уже записано
именно старое значение, миграция БД для этого не нужна: следующий же
`update()` перепишет строку в новую форму (см. docstring `update`)."""
if "domains" in value:
return list(value["domains"])
legacy_domain = value.get("domain")
return [legacy_domain] if legacy_domain else []
def _normalize_contact_email(email: str) -> str:
"""Нормализовать контактный email (strip, lower) и провалидировать формат."""
normalized = email.strip().lower()
@@ -436,9 +482,9 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
registration_email_domain_enabled=rows.get(
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
).get("enabled", False),
registration_email_domain=rows.get(
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
).get("domain"),
registration_email_domains=_extract_email_domains(
rows.get(_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE)
),
contact_email_enabled=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get(
"enabled", False
),

View File

@@ -381,6 +381,67 @@ async def test_list_users_returns_all(client: httpx.AsyncClient, db_session: Asy
assert all("email_verified" in item for item in items)
async def test_list_users_filters_by_status(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""`status=active`/`blocked` фильтрует по `is_blocked`; без параметра — все."""
admin = await _make_user(db_session, role="admin")
active_user = await _make_user(db_session)
blocked_user = await _make_user(db_session)
blocked_user.is_blocked = True
await db_session.commit()
active_response = await client.get(
"/api/v1/admin/users", params={"status": "active"}, headers=_auth_headers(admin)
)
assert active_response.status_code == 200, active_response.text
active_ids = [item["id"] for item in active_response.json()["items"]]
assert str(active_user.id) in active_ids
assert str(blocked_user.id) not in active_ids
blocked_response = await client.get(
"/api/v1/admin/users", params={"status": "blocked"}, headers=_auth_headers(admin)
)
assert blocked_response.status_code == 200, blocked_response.text
blocked_ids = [item["id"] for item in blocked_response.json()["items"]]
assert str(blocked_user.id) in blocked_ids
assert str(active_user.id) not in blocked_ids
all_response = await client.get("/api/v1/admin/users", headers=_auth_headers(admin))
all_ids = [item["id"] for item in all_response.json()["items"]]
assert str(active_user.id) in all_ids
assert str(blocked_user.id) in all_ids
async def test_list_users_search_matches_team_name(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""`q` находит пользователей по (части) названия их команды; пользователи
без команды при этом не пропадают из общей (без поиска) выдачи."""
admin = await _make_user(db_session, role="admin")
suffix = uuid.uuid4().hex[:8]
team = Team(name=f"Rocket-{suffix} Squad")
db_session.add(team)
await db_session.flush()
teamed_user = await _make_user(db_session)
teamed_user.team_id = team.id
teamless_user = await _make_user(db_session)
await db_session.commit()
response = await client.get(
"/api/v1/admin/users", params={"q": f"Rocket-{suffix}"}, headers=_auth_headers(admin)
)
assert response.status_code == 200, response.text
ids = [item["id"] for item in response.json()["items"]]
assert str(teamed_user.id) in ids
assert str(teamless_user.id) not in ids
assert str(admin.id) not in ids
all_response = await client.get("/api/v1/admin/users", headers=_auth_headers(admin))
all_ids = [item["id"] for item in all_response.json()["items"]]
assert str(teamless_user.id) in all_ids
async def test_patch_user_role_and_block(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:

View File

@@ -238,7 +238,7 @@ async def test_registration_options_disabled_by_default(
body = response.json()
assert body["team_choice_enabled"] is False
assert body["teams"] == []
assert body["email_domain"] is None
assert body["email_domains"] == []
async def test_registration_options_enabled_returns_teams_sorted_by_name(
@@ -343,29 +343,30 @@ async def test_register_with_unknown_team_id_returns_400(
# --- Верификация регистрирующихся по домену email -------------------------------------
async def test_registration_options_returns_email_domain_when_enabled(
async def test_registration_options_returns_email_domains_when_enabled(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""При включённой верификации домена `registration-options` отдаёт эталонный домен."""
"""При включённой верификации домена `registration-options` отдаёт все эталонные домены."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="example.com"
registration_email_domain_enabled=True,
registration_email_domains=["example.com", "corp.example"],
)
)
await db_session.commit()
response = await client.get("/api/v1/auth/registration-options")
assert response.status_code == 200, response.text
assert response.json()["email_domain"] == "example.com"
assert response.json()["email_domains"] == ["example.com", "corp.example"]
async def test_registration_options_email_domain_null_when_disabled(
async def test_registration_options_email_domains_empty_when_disabled(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Выключенная верификация домена — `email_domain` всегда `null`, даже если домен сохранён."""
"""Выключенная верификация домена — `email_domains` всегда пуст, даже если домены сохранены."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="example.com"
registration_email_domain_enabled=True, registration_email_domains=["example.com"]
)
)
await InstanceSettingsService(db_session).update(
@@ -375,7 +376,7 @@ async def test_registration_options_email_domain_null_when_disabled(
response = await client.get("/api/v1/auth/registration-options")
assert response.status_code == 200, response.text
assert response.json()["email_domain"] is None
assert response.json()["email_domains"] == []
async def test_register_with_foreign_domain_when_verification_enabled_returns_400(
@@ -383,7 +384,8 @@ async def test_register_with_foreign_domain_when_verification_enabled_returns_40
) -> None:
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="example.com"
registration_email_domain_enabled=True,
registration_email_domains=["example.com", "corp.example"],
)
)
await db_session.commit()
@@ -405,7 +407,7 @@ async def test_register_with_matching_domain_case_insensitive_succeeds(
) -> None:
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="example.com"
registration_email_domain_enabled=True, registration_email_domains=["example.com"]
)
)
await db_session.commit()
@@ -421,6 +423,29 @@ async def test_register_with_matching_domain_case_insensitive_succeeds(
assert response.status_code == 201, response.text
async def test_register_with_second_domain_in_list_succeeds(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Email подходит, если совпадает с ЛЮБЫМ доменом из списка — не только с первым."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(
registration_email_domain_enabled=True,
registration_email_domains=["example.com", "corp.example"],
)
)
await db_session.commit()
response = await client.post(
"/api/v1/auth/register",
json={
"email": "dave@corp.example",
"name_user": "Dave",
"password": "supersecret1",
},
)
assert response.status_code == 201, response.text
async def test_register_any_domain_allowed_when_verification_disabled(
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
) -> None:

View File

@@ -134,7 +134,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
assert cfg.display_timezone == "Europe/Moscow"
assert cfg.registration_team_choice is False
assert cfg.registration_email_domain_enabled is False
assert cfg.registration_email_domain is None
assert cfg.registration_email_domains == []
assert cfg.contact_email_enabled is False
assert cfg.contact_email is None
@@ -326,7 +326,7 @@ async def test_registration_team_choice_toggle(
async def test_registration_email_domain_enable_without_domain_rejected(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Включение верификации без домена (ни в патче, ни ранее сохранённого) → 400."""
"""Включение верификации без доменов (ни в патче, ни ранее сохранённых) → 400."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
@@ -335,7 +335,24 @@ async def test_registration_email_domain_enable_without_domain_rejected(
cfg = await service.get()
assert cfg.registration_email_domain_enabled is False
assert cfg.registration_email_domain is None
assert cfg.registration_email_domains == []
async def test_registration_email_domain_enable_with_empty_list_rejected(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Пустой список доменов при включении верификации — та же ошибка, что и
отсутствие поля (не молчаливое отключение проверки)."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidEmailDomainError):
await service.update(
SettingsUpdateIn(registration_email_domain_enabled=True, registration_email_domains=[])
)
cfg = await service.get()
assert cfg.registration_email_domain_enabled is False
async def test_registration_email_domain_rejects_invalid_pattern(
@@ -345,48 +362,106 @@ async def test_registration_email_domain_rejects_invalid_pattern(
await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidEmailDomainError):
await service.update(SettingsUpdateIn(registration_email_domain="not a domain"))
await service.update(SettingsUpdateIn(registration_email_domains=["not a domain"]))
cfg = await service.get()
assert cfg.registration_email_domain is None
assert cfg.registration_email_domains == []
async def test_registration_email_domain_normalizes_input(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""`@Corp.RU ` нормализуется в `corp.ru` (strip, убрать «@», lower)."""
"""`@Corp.RU ` нормализуется в `corp.ru` (strip, убрать «@», lower); пустые строки
отбрасываются, дубликаты схлопываются."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
cfg = await service.update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="@Corp.RU "
registration_email_domain_enabled=True,
registration_email_domains=["@Corp.RU ", "", "corp.ru", "Acme.IO"],
)
)
assert cfg.registration_email_domain_enabled is True
assert cfg.registration_email_domain == "corp.ru"
assert cfg.registration_email_domains == ["corp.ru", "acme.io"]
reloaded = await service.get()
assert reloaded.registration_email_domain == "corp.ru"
assert reloaded.registration_email_domains == ["corp.ru", "acme.io"]
async def test_registration_email_domain_can_be_disabled_keeping_stored_domain(
async def test_registration_email_domain_can_be_disabled_keeping_stored_domains(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Выключение верификации без передачи домена не требует домена и не роняет валидацию."""
"""Выключение верификации без передачи доменов не требует их и не роняет валидацию."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
await service.update(
SettingsUpdateIn(
registration_email_domain_enabled=True, registration_email_domain="acme.io"
registration_email_domain_enabled=True, registration_email_domains=["acme.io"]
)
)
cfg = await service.update(SettingsUpdateIn(registration_email_domain_enabled=False))
assert cfg.registration_email_domain_enabled is False
assert cfg.registration_email_domain == "acme.io"
assert cfg.registration_email_domains == ["acme.io"]
async def test_registration_email_domain_reads_legacy_single_domain_shape(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""До многодоменной поддержки в БД хранилась форма `{"domain": str|None}` (один
домен, без миграции на проде) — чтение должно разворачивать её в список из
одного элемента (см. `_extract_email_domains`)."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
stmt = (
pg_insert(InstanceSetting)
.values(
key="registration_email_domain",
value={"enabled": True, "domain": "legacy.example"},
)
.on_conflict_do_update(
index_elements=["key"],
set_={"value": {"enabled": True, "domain": "legacy.example"}},
)
)
await db_session.execute(stmt)
await db_session.commit()
cfg = await service.get()
assert cfg.registration_email_domain_enabled is True
assert cfg.registration_email_domains == ["legacy.example"]
async def test_registration_email_domain_rewrites_legacy_shape_on_update(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Первое же сохранение после чтения старой формы переписывает строку в
новую (`domains: [...]`), а не оставляет legacy `domain` рядом."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
stmt = (
pg_insert(InstanceSetting)
.values(
key="registration_email_domain",
value={"enabled": True, "domain": "legacy.example"},
)
.on_conflict_do_update(
index_elements=["key"],
set_={"value": {"enabled": True, "domain": "legacy.example"}},
)
)
await db_session.execute(stmt)
await db_session.commit()
await service.update(SettingsUpdateIn(registration_email_domains=["new.example"]))
row = await db_session.get(InstanceSetting, "registration_email_domain")
assert row is not None
assert row.value == {"enabled": True, "domains": ["new.example"]}
async def test_contact_email_enable_without_email_rejected(
@@ -478,6 +553,52 @@ async def test_update_rejects_unavailable_ai_level(
assert cfg.ai_level == "min"
async def test_update_unrelated_setting_passes_when_current_ai_level_unavailable(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Баг: фронт всегда отправляет текущий `ai_level` вместе с любой другой
правкой (см. `AdminSettingsTab.handleSave`) — если уже сохранённый
уровень стал недоступен (слабое железо/модель не докачана), это не
должно блокировать сохранение несвязанной настройки. Валидация нужна
только при фактической смене уровня (см. docstring `update`)."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
stmt = (
pg_insert(InstanceSetting)
.values(key="ai_level", value={"level": "medium"})
.on_conflict_do_update(index_elements=["key"], set_={"value": {"level": "medium"}})
)
await db_session.execute(stmt)
await db_session.commit()
assert not detect_ai_levels(await service.get())[1].available # "medium" недоступен
# ai_level в патче совпадает с уже сохранённым — не должно проверяться.
cfg = await service.update(
SettingsUpdateIn(ai_level="medium", registration_team_choice=True)
)
assert cfg.ai_level == "medium"
assert cfg.registration_team_choice is True
async def test_update_still_rejects_switching_to_unavailable_ai_level(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Фактическая попытка ПЕРЕКЛЮЧИТЬ уровень на недоступный по-прежнему
отклоняется — даже если AI (транскрибация/суммаризация) выключен: иначе
администратор молча сохранит нерабочую конфигурацию, которая проявится
только при последующем включении AI."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
await service.update(SettingsUpdateIn(transcription_enabled=False))
with pytest.raises(InvalidAiLevelError):
await service.update(SettingsUpdateIn(ai_level="medium"))
cfg = await service.get()
assert cfg.ai_level == "min"
async def test_update_rejects_invalid_timezone(
db_session: AsyncSession, clean_instance_settings: None
) -> None:

View File

@@ -82,7 +82,7 @@ services:
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.6}
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.9}
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
# только backend/), поэтому plugins.yaml монтируется отдельно.
volumes:
@@ -704,6 +704,73 @@ services:
profiles: ["monitoring"]
logging: *default-logging
node-exporter:
# Метрики железа хоста (CPU, память, диск, сеть, load average) — то,
# чего нет ни в одном из приложенческих экспортеров выше. Без
# `network_mode: host` (не нужен: читаем /proc,/sys,/ хоста через
# bind-mount, а Prometheus достаёт их по имени сервиса во внутренней
# сети compose — так безопаснее, не расширяет сетевой доступ контейнера).
image: prom/node-exporter:v1.8.2
restart: unless-stopped
pid: host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- '--path.procfs=/host/proc'
- '--path.sysfs=/host/sys'
- '--path.rootfs=/rootfs'
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
# Не публикуем порт наружу вообще (не 127.0.0.1:9100, а совсем без
# ports) — Prometheus ходит к нему по внутренней сети compose
# (`node-exporter:9100`), публикация на хост для этого не нужна.
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9100/metrics >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
profiles: ["monitoring"]
logging: *default-logging
cadvisor:
# Метрики по контейнерам (CPU/память каждого) — отвечает на вопрос
# «какой из контейнеров ест ресурсы», в отличие от node-exporter
# (только хост целиком). Дороже node-exporter по CPU/RAM (собственные
# ~60-120 МБ RSS + периодический обход cgroups) — на сервере 8 ГБ/4 CPU
# с ~13 контейнерами это приемлемо, но не бесплатно; `--docker_only`
# и `--disable_metrics` ниже отключают часть коллекторов сверх дефолта
# cAdvisor (который уже отключает cpu_topology/hugetlb/memory_numa/
# process/sched/tcp/udp и т.п.) — здесь дополнительно per-core CPU,
# perf_event и дисковые метрики (диск и так покрыт node-exporter'ом,
# дублировать через cAdvisor незачем). Список допустимых значений —
# `cadvisor -help` (в версии образа меняется, не все имена стабильны
# между релизами — при апгрейде образа сверяться с `-help`).
image: gcr.io/cadvisor/cadvisor:v0.49.2
restart: unless-stopped
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
# Только чтение — cAdvisor не должен иметь возможность управлять
# контейнерами, ему нужен только список и статистика.
- /var/run/docker.sock:/var/run/docker.sock:ro
command:
- '--docker_only=true'
- '--disable_metrics=percpu,perf_event,diskIO,disk,app'
# Без ports вообще — тот же принцип, что и у node-exporter: Prometheus
# достаёт метрики по имени сервиса (`cadvisor:8080`) внутри сети compose.
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:8080/healthz >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 10
start_period: 15s
profiles: ["monitoring"]
logging: *default-logging
grafana:
image: grafana/grafana:13.1.0
restart: unless-stopped

View File

@@ -60,3 +60,58 @@ groups:
# суммаризация встанет (задачи будут копиться в очереди summarize,
# см. также алерт QueueGrowing). Проверить
# `docker compose ps llm` / `llm-gpu` и `docker compose logs llm`.
# Железо хоста (job `node` — node-exporter). Пороги подобраны под
# конкретный сервер 1gb: 8 ГБ RAM, 4 CPU, 50 ГБ диска — если сервер
# сменится, пересчитать.
- name: vidconf-host
rules:
# MemAvailable — уже честная оценка Linux с учётом того, что легко
# освобождаемый page cache/buffers не в счёт (в отличие от naive
# used = total - free). 10% от 8 ГБ ≈ 800 МБ — `for: 10m`, чтобы не
# дёргать на кратковременный всплеск (например, разовый всплеск
# transcribe/summarize), но успеть среагировать до OOM killer.
- alert: HostMemoryLow
expr: (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100 < 10
for: 10m
labels:
severity: warning
annotations:
summary: "Мало свободной памяти на хосте"
description: >-
Свободно {{ $value | printf "%.1f" }}% RAM дольше 10 минут
(порог 10% ≈ 800 МБ из 8 ГБ). Смотреть, какой контейнер ест
память — дашборд «Хост и контейнеры», топ по памяти (cAdvisor).
# 50 ГБ диска — 10% ≈ 5 ГБ. `for: 15m` (не мгновенно): диск не растёт
# так же резко, как память, ложные срабатывания на всплеск не грозят,
# но и разовая проверка на границе некритична — 15 минут отсекает шум.
- alert: HostDiskLow
expr: (node_filesystem_avail_bytes{mountpoint="/",fstype!="tmpfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!="tmpfs"}) * 100 < 10
for: 15m
labels:
severity: warning
annotations:
summary: "Мало места на диске хоста"
description: >-
Свободно {{ $value | printf "%.1f" }}% диска дольше 15 минут
(порог 10% ≈ 5 ГБ из 50 ГБ). Частые причины на этом проекте —
записи транскрибации (`recordings`), логи docker, образы/слои
после пересборки — проверить `docker system df`.
# 4 CPU. Порог 90% и `for: 15m` — сознательно строже по времени, чем
# у памяти/диска: кратковременные пики от пайплайна пост-обработки
# (транскрибация/суммаризация) — это ожидаемая, не аварийная нагрузка,
# алерт должен ловить именно устойчивую перегрузку, а не обычный всплеск.
- alert: HostCpuHigh
expr: 100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 90
for: 15m
labels:
severity: warning
annotations:
summary: "Устойчиво высокая загрузка CPU хоста"
description: >-
Загрузка CPU {{ $value | printf "%.1f" }}% дольше 15 минут
подряд (порог 90% из 4 ядер). Смотреть топ контейнеров по CPU
(дашборд «Хост и контейнеры», cAdvisor) и латентность API —
возможно, не хватает уровня AI/ресурсов под нагрузку.

View File

@@ -0,0 +1,245 @@
{
"title": "Хост и контейнеры",
"description": "CPU/RAM/диск/сеть хоста (node-exporter) и разбивка по контейнерам (cAdvisor) — профиль compose monitoring. Пороги на панелях согласованы с deploy/monitoring/alerts.yml (группа vidconf-host).",
"uid": "vidconf-host",
"editable": false,
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"time": { "from": "now-6h", "to": "now" },
"refresh": "30s",
"tags": ["vidconf", "host"],
"panels": [
{
"id": 1,
"title": "Загрузка CPU",
"description": "100 - idle, усреднено по всем ядрам. Порог алерта HostCpuHigh — 90% дольше 15 минут, см. alerts.yml.",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "unit": "percent", "max": 100, "min": 0, "custom": { "drawStyle": "line", "fillOpacity": 10 } },
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
"legendFormat": "CPU busy",
"refId": "A"
}
]
},
{
"id": 2,
"title": "Load average",
"description": "node_load1/5/15 — на 4-ядерном сервере устойчивое значение выше ~4 означает очередь на CPU.",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "custom": { "drawStyle": "line", "fillOpacity": 5 } },
"overrides": []
},
"targets": [
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "node_load1", "legendFormat": "1 мин", "refId": "A" },
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "node_load5", "legendFormat": "5 мин", "refId": "B" },
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "node_load15", "legendFormat": "15 мин", "refId": "C" }
]
},
{
"id": 3,
"title": "Использование RAM",
"description": "(1 - MemAvailable/MemTotal) — MemAvailable уже учитывает легко освобождаемый page cache/buffers. Порог алерта HostMemoryLow — доступно <10% дольше 10 минут, см. alerts.yml.",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "unit": "percent", "max": 100, "min": 0, "custom": { "drawStyle": "line", "fillOpacity": 10 } },
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100",
"legendFormat": "RAM used",
"refId": "A"
}
]
},
{
"id": 4,
"title": "Своп (использовано)",
"description": "SwapTotal - SwapFree. На этом сервере swap не заведён под мониторинг отдельным алертом — рост от нуля сам по себе сигнал, что памяти уже не хватает (см. HostMemoryLow).",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "unit": "decbytes", "custom": { "drawStyle": "line", "fillOpacity": 10 } },
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes",
"legendFormat": "swap used",
"refId": "A"
}
]
},
{
"id": 5,
"title": "Свободно на диске (/)",
"description": "node_filesystem_avail_bytes для корня. Порог алерта HostDiskLow — <10% (≈5 ГБ из 50 ГБ) дольше 15 минут.",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 16 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "decbytes",
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 5368709120 }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "node_filesystem_avail_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"}",
"refId": "A"
}
]
},
{
"id": 6,
"title": "RAM сейчас, %",
"description": "Текущий снимок панели «Использование RAM». Порог совпадает с HostMemoryLow (used > 90%).",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 16 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "percent",
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 90 }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100",
"refId": "A"
}
]
},
{
"id": 7,
"title": "Диск сейчас, %",
"description": "Текущий снимок использования корневого раздела. Порог совпадает с HostDiskLow (used > 90%).",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 16 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "percent",
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 90 }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "(1 - node_filesystem_avail_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"} / node_filesystem_size_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"}) * 100",
"refId": "A"
}
]
},
{
"id": 8,
"title": "CPU сейчас, %",
"description": "Текущий снимок загрузки CPU. Порог совпадает с HostCpuHigh (>90%).",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 16 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "percent",
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 90 }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
"refId": "A"
}
]
},
{
"id": 9,
"title": "Сетевой трафик",
"description": "rx/tx по физическим интерфейсам хоста (исключены lo/veth/docker/br — это внутренние интерфейсы контейнеров, а не реальный сетевой трафик сервера).",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 20 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "unit": "Bps", "custom": { "drawStyle": "line", "fillOpacity": 5 } },
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "rate(node_network_receive_bytes_total{device!~\"lo|veth.*|docker.*|br-.*\"}[5m])",
"legendFormat": "rx {{device}}",
"refId": "A"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "rate(node_network_transmit_bytes_total{device!~\"lo|veth.*|docker.*|br-.*\"}[5m])",
"legendFormat": "tx {{device}}",
"refId": "B"
}
]
},
{
"id": 10,
"title": "Топ контейнеров по CPU",
"description": "Источник — cAdvisor (job cadvisor). Отвечает на вопрос «кто из контейнеров грузит CPU» при срабатывании HostCpuHigh.",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 20 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "unit": "percentunit", "custom": { "drawStyle": "line", "fillOpacity": 10, "stacking": { "mode": "normal" } } },
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "topk(5, sum by (name) (rate(container_cpu_usage_seconds_total{name!=\"\"}[5m])))",
"legendFormat": "{{name}}",
"refId": "A"
}
]
},
{
"id": 11,
"title": "Топ контейнеров по памяти",
"description": "container_memory_working_set_bytes — это то, что реально учитывает cgroup OOM killer (в отличие от RSS), источник — cAdvisor.",
"type": "timeseries",
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 28 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "unit": "decbytes", "custom": { "drawStyle": "line", "fillOpacity": 10, "stacking": { "mode": "normal" } } },
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "topk(5, container_memory_working_set_bytes{name!=\"\"})",
"legendFormat": "{{name}}",
"refId": "A"
}
]
}
]
}

View File

@@ -1,6 +1,7 @@
# Конфигурация Prometheus (devops) — сбор метрик backend,
# PostgreSQL, Redis и локального LLM-сервера. Поднимается compose-профилем
# `monitoring` (deploy/docker-compose.yml, сервис `prometheus`).
# PostgreSQL, Redis, железа хоста, контейнеров и локального LLM-сервера.
# Поднимается compose-профилем `monitoring` (deploy/docker-compose.yml,
# сервис `prometheus`).
#
# Имена метрик backend (`vidconf_http_request_duration_seconds`,
# `vidconf_pipeline_sessions`, `vidconf_celery_queue_depth`) — КОНТРАКТ с
@@ -33,6 +34,22 @@ scrape_configs:
static_configs:
- targets: ["redis-exporter:9121"]
# Хост целиком: CPU, память, диск, сеть, load average (профиль monitoring
# — сервис node-exporter). Единственный источник, который покажет
# нехватку памяти/CPU на сервере, если она не проявится как рост
# латентности API (см. дашборд host.json).
- job_name: node
static_configs:
- targets: ["node-exporter:9100"]
# Метрики по каждому контейнеру (CPU/память отдельно у backend, worker,
# postgres и т.д. — профиль monitoring, сервис cadvisor). Отвечает на
# вопрос «какой из контейнеров ест ресурсы», в отличие от job `node`
# (только хост целиком).
- job_name: cadvisor
static_configs:
- targets: ["cadvisor:8080"]
# Локальный LLM-сервер (llama.cpp, LLAMA_ARG_ENDPOINT_METRICS=1). Адрес
# `llm:8080` разрешается ОДНИМ из двух compose-сервисов в зависимости от
# выбранного при установке пресета — `llm` (CPU, профиль `llm`, уровни

View File

@@ -10,11 +10,25 @@
| `prometheus` | `prom/prometheus:v3.13.1` | `9090` | сбор и хранение метрик, оценка правил алертинга |
| `postgres-exporter` | `quay.io/prometheuscommunity/postgres-exporter:v0.20.1` | — (внутренний) | метрики PostgreSQL |
| `redis-exporter` | `oliver006/redis_exporter:v1.87.0-alpine` | — (внутренний) | метрики Redis |
| `grafana` | `grafana/grafana:13.1.0` | `3001` (внутри контейнера `3000`) | дашборд «Пайплайны пост-обработки» |
| `node-exporter` | `prom/node-exporter:v1.8.2` | — (внутренний) | метрики хоста: CPU, память, диск, сеть, load average |
| `cadvisor` | `gcr.io/cadvisor/cadvisor:v0.49.2` | — (внутренний) | метрики по каждому контейнеру (CPU/память) |
| `grafana` | `grafana/grafana:13.1.0` | `3001` (внутри контейнера `3000`) | дашборды «Пайплайны пост-обработки» и «Хост и контейнеры» |
`node-exporter`/`cadvisor` не публикуют порт на хост вообще (не только
127.0.0.1) — Prometheus ходит к ним по имени сервиса во внутренней сети
compose, публикация на хост для этого не нужна. `cadvisor` смонтирован
к `/var/run/docker.sock` только на чтение (`:ro`) и запущен с
`--docker_only` + урезанным набором коллекторов (`--disable_metrics`) —
он заметно дороже `node-exporter` по CPU/RAM, урезание снижает накладные
расходы. Если на конкретном сервере это всё равно избыточно —
`cadvisor` можно убрать из `deploy/docker-compose.yml`, оставив только
`node-exporter` (метрики хоста при этом не пострадают, пропадёт только
разбивка по контейнерам).
Файлы: `deploy/monitoring/prometheus.yml`, `deploy/monitoring/alerts.yml`,
`deploy/monitoring/grafana/provisioning/` (datasource + провайдер
дашбордов), `deploy/monitoring/grafana/dashboards/pipelines.json`.
дашбордов), `deploy/monitoring/grafana/dashboards/pipelines.json`,
`deploy/monitoring/grafana/dashboards/host.json`.
## 2. Запуск
@@ -37,9 +51,9 @@ ssh -L 9090:127.0.0.1:9090 -L 3001:127.0.0.1:3001 <user>@<host>
```
и открывайте `http://localhost:9090` / `http://localhost:3001` у себя.
Дашборд «Пайплайны пост-обработки» (папка VidConf в Grafana) появляется
сразу — источник данных и дашборд провижинятся из файлов, без ручной
настройки.
Дашборды «Пайплайны пост-обработки» и «Хост и контейнеры» (папка VidConf
в Grafana) появляются сразу — источник данных и дашборды провижинятся из
файлов, без ручной настройки.
## 3. Метрики backend
@@ -78,6 +92,13 @@ Job `llm` в `prometheus.yml` скрейпит `llm:8080/metrics`
| `PipelineFailed` | рост числа сеансов в статусе `failed` за 15 минут | critical |
| `QueueGrowing` | глубина очереди растёт 15 минут подряд и превышает 10 задач | warning |
| `LlmDown` | `up{job="llm"} == 0` дольше 2 минут | critical (закомментирован по умолчанию) |
| `HostMemoryLow` | свободно <10% RAM (≈800 МБ из 8 ГБ) дольше 10 минут | warning |
| `HostDiskLow` | свободно <10% диска (≈5 ГБ из 50 ГБ) дольше 15 минут | warning |
| `HostCpuHigh` | загрузка CPU >90% дольше 15 минут подряд | warning |
Пороги трёх алертов по железу подобраны под конкретный сервер `1gb`
(8 ГБ RAM, 4 CPU, 50 ГБ диска) — при смене сервера пересчитать
(`deploy/monitoring/alerts.yml`, группа `vidconf-host`).
`LlmDown` актуален только на инсталляциях с профилем `llm`/`llm-gpu`
(пресеты 35) — по умолчанию (профили `media,monitoring`, без AI) правило

View File

@@ -32,8 +32,9 @@ export interface SettingsOut {
registration_team_choice: boolean
/** Включена ли верификация регистрации по домену корпоративной почты. */
registration_email_domain_enabled: boolean
/** Эталонный домен для верификации (напр. `company.ru`) — `null`, если верификация выключена. */
registration_email_domain: string | null
/** Эталонные домены для верификации (напр. `['company.ru']`) — email подходит, если
* совпадает с любым из списка; пуст, если верификация выключена. */
registration_email_domains: string[]
/** Включён ли контактный адрес инстанса (подставляется в `Reply-To` исходящих писем). */
contact_email_enabled: boolean
/** Контактный адрес — `null`, если не задан/выключен. */
@@ -49,9 +50,9 @@ export interface SettingsUpdateIn {
summary_recipients?: SummaryRecipientsMode
display_timezone?: string
registration_team_choice?: boolean
/** Включение без домена или невалидный домен — backend отвечает 400. */
/** Включение с пустым списком или невалидным доменом — backend отвечает 400. */
registration_email_domain_enabled?: boolean
registration_email_domain?: string | null
registration_email_domains?: string[]
/** Включение без email или невалидный email — backend отвечает 400. */
contact_email_enabled?: boolean
contact_email?: string | null
@@ -136,6 +137,8 @@ export type AdminUserDetailOut = AdminUserOut
/** Параметры выборки списка пользователей. */
export interface AdminUserQuery {
/** Фильтр по блокировке — без параметра отдаются все пользователи. */
status?: 'active' | 'blocked'
q?: string
limit?: number
offset?: number
@@ -220,7 +223,7 @@ export async function sendConferenceInvitations(id: string, emails?: string[]):
/** Список пользователей для админки — с поиском и пагинацией. */
export async function listAdminUsers(query: AdminUserQuery = {}): Promise<PagedResult<AdminUserOut>> {
const qs = toQueryString({ q: query.q, limit: query.limit, offset: query.offset })
const qs = toQueryString({ status: query.status, q: query.q, limit: query.limit, offset: query.offset })
return apiRequest<PagedResult<AdminUserOut>>(`/admin/users${qs}`)
}

View File

@@ -21,8 +21,8 @@ export interface RegistrationTeamOption {
export interface RegistrationOptions {
team_choice_enabled: boolean
teams: RegistrationTeamOption[]
/** Эталонный домен почты при включённой верификации, иначе `null`. */
email_domain: string | null
/** Эталонные домены почты при включённой верификации (email подходит под любой), иначе пуст. */
email_domains: string[]
}
export interface CurrentUser {

View File

@@ -18,10 +18,10 @@ import { formatRecurrenceSummary } from '@/lib/recurrenceFormat'
const PAGE_SIZE = 10
const STATUS_FILTERS: { value: ConferenceStatus | 'all'; label: string }[] = [
{ value: 'all', label: 'Все' },
{ value: 'scheduled', label: 'Запланированные' },
{ value: 'active', label: 'Активные' },
{ value: 'ended', label: 'Завершённые' },
{ value: 'all', label: 'Все' },
]
function StatusBadge({ conference }: { conference: AdminConferenceOut }) {
@@ -287,7 +287,7 @@ function InviteModal({ conference, onClose }: { conference: AdminConferenceOut;
* блок «table-card», адаптированный под сущность «конференция»).
*/
export function AdminConferencesTab() {
const [statusFilter, setStatusFilter] = useState<ConferenceStatus | 'all'>('all')
const [statusFilter, setStatusFilter] = useState<ConferenceStatus | 'all'>('scheduled')
const [searchInput, setSearchInput] = useState('')
const [search, setSearch] = useState('')
const [offset, setOffset] = useState(0)

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle } from 'lucide-react'
import { AlertTriangle, X } from 'lucide-react'
import {
getAdminSettings,
sendTestEmail,
@@ -60,7 +60,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
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 [emailDomain, setEmailDomain] = useState(data.registration_email_domain ?? '')
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 [testEmailTo, setTestEmailTo] = useState('')
@@ -94,19 +95,48 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
},
})
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() {
mutation.mutate({
chat_enabled: chatEnabled,
transcription_enabled: aiEnabled,
ai_level: aiLevel,
summary_recipients: recipients,
display_timezone: timezone.trim(),
registration_team_choice: teamChoiceEnabled,
registration_email_domain_enabled: domainVerificationEnabled,
registration_email_domain: emailDomain.trim() || null,
contact_email_enabled: contactEmailEnabled,
contact_email: contactEmail.trim() || null,
})
// Отправляем только реально изменённые поля (`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
}
mutation.mutate(payload)
}
return (
@@ -229,19 +259,53 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
<section className="settings-card">
<h2>Эталон mail-домена</h2>
<p className="desc">Домен, с которым сверяется email при регистрации, если включена верификация в «Модулях».</p>
<p className="desc">Домены, с любым из которых сверяется email при регистрации, если включена верификация в «Модулях».</p>
<div className="settings-card-body settings-card-body--center">
<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>
<input
id="settings-email-domain"
type="text"
placeholder="company.ru"
value={emailDomain}
disabled={!domainVerificationEnabled}
onChange={(e) => setEmailDomain(e.target.value)}
/>
<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>

View File

@@ -13,6 +13,12 @@ const PAGE_SIZE = 10
/** Верхний предел выборки команд для селекта — без отдельной пагинации в этом контексте. */
const TEAMS_LIMIT = 200
const STATUS_FILTERS: { value: 'active' | 'blocked' | 'all'; label: string }[] = [
{ value: 'active', label: 'Активные' },
{ value: 'blocked', label: 'Заблокированные' },
{ value: 'all', label: 'Все' },
]
/**
* Вкладка «Пользователи» админки — список с поиском, пагинацией, сменой
* роли, блокировкой и командой (design/mockups/admin.html,
@@ -28,6 +34,7 @@ const TEAMS_LIMIT = 200
*/
export function AdminUsersTab() {
const { user: currentUser } = useAuth()
const [statusFilter, setStatusFilter] = useState<'active' | 'blocked' | 'all'>('active')
const [searchInput, setSearchInput] = useState('')
const [search, setSearch] = useState('')
const [offset, setOffset] = useState(0)
@@ -45,8 +52,14 @@ export function AdminUsersTab() {
}, [searchInput])
const { data, isLoading } = useQuery({
queryKey: ['admin', 'users', search, offset],
queryFn: () => listAdminUsers({ q: search || undefined, limit: PAGE_SIZE, offset }),
queryKey: ['admin', 'users', statusFilter, search, offset],
queryFn: () =>
listAdminUsers({
status: statusFilter === 'all' ? undefined : statusFilter,
q: search || undefined,
limit: PAGE_SIZE,
offset,
}),
})
const { data: teamsData } = useQuery({
@@ -89,13 +102,29 @@ export function AdminUsersTab() {
return (
<>
<div className="section-tabs-inline">
{STATUS_FILTERS.map((f) => (
<button
key={f.value}
type="button"
className={`chip${statusFilter === f.value ? ' is-active' : ''}`}
onClick={() => {
setStatusFilter(f.value)
setOffset(0)
}}
>
{f.label}
</button>
))}
</div>
<div className="toolbar-row">
<div className="toolbar-left">
<div className="search-wrap">
<Search className="icon" style={{ width: 16, height: 16 }} aria-hidden="true" />
<input
type="text"
placeholder="Поиск по имени или email…"
placeholder="Поиск по имени, email или команде…"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>

View File

@@ -1,23 +1,51 @@
import { useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { useMediaDeviceSelect, usePersistentUserChoices } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider'
import { useModalDismiss } from '@/hooks/useModalDismiss'
import { isAudioOutputSelectable, saveAudioOutputDeviceId } from '@/lib/audioOutputDevice'
interface DeviceSettingsDialogProps {
onClose: () => void
}
/** Совпадает с мобильным брейкпоинтом комнаты (`room.css`, `max-width: 600px`) — ниже него панель рендерится шторкой снизу вместо модалки. */
const COMPACT_VIEWPORT_QUERY = '(max-width: 600px)'
/** Свайп ручки шторки вниз дальше этого порога (px) закрывает панель, меньше — она возвращается на место. */
const SHEET_DISMISS_THRESHOLD_PX = 80
/** Человекочитаемая подпись пункта списка устройств — `label` пуст, пока нет разрешения на медиа. */
function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string): string {
return device.label || `${fallback} ${index + 1}`
}
/** Живое отслеживание мобильной ширины — та же схема, что системная тема в `useTheme.ts` (matchMedia + change-листенер). */
function useIsCompactViewport(): boolean {
const [isCompact, setIsCompact] = useState(() => window.matchMedia(COMPACT_VIEWPORT_QUERY).matches)
useEffect(() => {
const media = window.matchMedia(COMPACT_VIEWPORT_QUERY)
const handleChange = (event: MediaQueryListEvent) => setIsCompact(event.matches)
media.addEventListener('change', handleChange)
return () => media.removeEventListener('change', handleChange)
}, [])
return isCompact
}
/**
* Диалог «Настройки устройств» — два селекта
* на хуках `@livekit/components-react`: список устройств и переключение —
* целиком в `useMediaDeviceSelect` (сама подписана на
* `RoomEvent.MediaDevicesChanged`), персист выбора — в `usePersistentUserChoices`
* (localStorage, читается заново при следующем входе в комнату — см.
* `RoomPage.tsx`, `options` пропс `LiveKitRoom`).
* Панель «Настройки устройств» — три селекта на хуках `@livekit/components-react`:
* список устройств и переключение целиком в `useMediaDeviceSelect` (сама
* подписана на `RoomEvent.MediaDevicesChanged`), персист выбора микрофона/камеры —
* в `usePersistentUserChoices` (localStorage, читается заново при следующем
* входе в комнату — см. `RoomPage.tsx`, `options` пропс `LiveKitRoom`).
* Аудиовыход персистится отдельно (`lib/audioOutputDevice.ts`) — `LocalUserChoices`
* LiveKit про него не знает, а `RoomOptions.audioOutput` читает сохранённый
* `deviceId` при следующем подключении.
*
* На мобильной ширине (`useIsCompactViewport`) рендерится шторкой снизу вместо
* модалки — по клику вне, Escape (`useModalDismiss`) и свайпу вниз за ручку.
* Десктоп не меняется.
*
* ДОЛЖЕН рендериться внутри `<LiveKitRoom>`: `useMediaDeviceSelect` без явно
* переданного `room` берёт активную комнату из `RoomContext` — вне контекста
@@ -29,6 +57,15 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
const { saveAudioInputDeviceId, saveVideoInputDeviceId } = usePersistentUserChoices()
const mic = useMediaDeviceSelect({ kind: 'audioinput' })
const camera = useMediaDeviceSelect({ kind: 'videoinput' })
const speaker = useMediaDeviceSelect({ kind: 'audiooutput' })
const speakerSelectable = isAudioOutputSelectable()
const isCompact = useIsCompactViewport()
useModalDismiss(onClose)
const dragStartYRef = useRef<number | null>(null)
const [dragOffset, setDragOffset] = useState(0)
const [isDragging, setIsDragging] = useState(false)
async function handleMicChange(deviceId: string) {
try {
@@ -49,15 +86,62 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
}
}
async function handleSpeakerChange(deviceId: string) {
try {
await speaker.setActiveMediaDevice(deviceId)
saveAudioOutputDeviceId(deviceId)
} catch {
toast.show('Не удалось переключить вывод звука — устройство занято или отключено', 'error')
}
}
function handleHandleTouchStart(e: React.TouchEvent<HTMLDivElement>) {
dragStartYRef.current = e.touches[0].clientY
setIsDragging(true)
}
function handleHandleTouchMove(e: React.TouchEvent<HTMLDivElement>) {
if (dragStartYRef.current === null) return
const delta = e.touches[0].clientY - dragStartYRef.current
if (delta > 0) setDragOffset(delta)
}
function handleHandleTouchEnd() {
if (dragOffset > SHEET_DISMISS_THRESHOLD_PX) {
onClose()
return
}
dragStartYRef.current = null
setIsDragging(false)
setDragOffset(0)
}
return (
<div
className="room-modal-overlay"
className={isCompact ? 'room-sheet-overlay' : 'room-modal-overlay'}
role="dialog"
aria-modal="true"
aria-labelledby="device-settings-title"
onClick={onClose}
>
<div className="room-modal-panel" onClick={(e) => e.stopPropagation()}>
<div
className={isCompact ? 'room-sheet-panel' : 'room-modal-panel'}
onClick={(e) => e.stopPropagation()}
style={
isCompact && dragOffset
? { transform: `translateY(${dragOffset}px)`, transition: isDragging ? 'none' : undefined }
: undefined
}
>
{isCompact && (
<div
className="room-sheet-handle"
onTouchStart={handleHandleTouchStart}
onTouchMove={handleHandleTouchMove}
onTouchEnd={handleHandleTouchEnd}
/>
)}
<div className="room-modal-head">
<h2 id="device-settings-title">Настройки устройств</h2>
<button type="button" className="room-modal-close" aria-label="Закрыть" onClick={onClose}>
@@ -104,6 +188,34 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
))}
</select>
</div>
{speakerSelectable ? (
<div className="room-field">
<label htmlFor="device-settings-speaker">Динамики</label>
<select
id="device-settings-speaker"
value={speaker.activeDeviceId}
onChange={(e) => void handleSpeakerChange(e.target.value)}
>
<option value="" disabled>
Определяется
</option>
{speaker.devices.map((device, index) => (
<option key={device.deviceId} value={device.deviceId}>
{deviceLabel(device, index, 'Динамик')}
</option>
))}
</select>
</div>
) : (
<div className="room-field">
<span className="room-field-label">Динамики</span>
<p className="room-field-hint">
Вывод звука (динамик, наушники, Bluetooth) переключается средствами системы этот
браузер не позволяет управлять им со страницы.
</p>
</div>
)}
</div>
</div>
)

View File

@@ -0,0 +1,33 @@
const STORAGE_KEY = 'vidconf-audio-output-device'
/**
* Поддержка выбора устройства аудиовывода — определяем по наличию
* `setSinkId` в рантайме, НЕ по User-Agent: в iOS Safari метода нет вообще
* (маршрутизация звука там целиком на стороне ОС), в Android Chrome есть.
*/
export function isAudioOutputSelectable(): boolean {
return typeof HTMLMediaElement !== 'undefined' && 'setSinkId' in HTMLMediaElement.prototype
}
/**
* Персист выбранного устройства вывода — отдельно от `usePersistentUserChoices`
* LiveKit: их `LocalUserChoices` знает только про вход (микрофон/камера),
* поля для аудиовыхода там нет.
*/
export function loadAudioOutputDeviceId(): string {
try {
return localStorage.getItem(STORAGE_KEY) ?? ''
} catch {
// localStorage недоступен (приватный режим/политики браузера) — без
// сохранённого выбора, устройство по умолчанию.
return ''
}
}
export function saveAudioOutputDeviceId(deviceId: string): void {
try {
localStorage.setItem(STORAGE_KEY, deviceId)
} catch {
// Сохранение недоступно — выбор продержится до конца сессии в комнате.
}
}

View File

@@ -6,19 +6,21 @@ import { AuthLayout } from '@/components/auth/AuthLayout'
import { getRegistrationOptions, register } from '@/api/auth'
import { ApiError, errorDetail } from '@/api/client'
/** Текст предупреждения о несовпадении домена почты с эталонным. */
function domainMismatchMessage(domain: string): string {
return `Укажите рабочую почту — регистрация доступна только для домена @${domain}`
/** Текст предупреждения о несовпадении домена почты с эталонными. */
function domainMismatchMessage(domains: string[]): string {
const list = domains.map((domain) => `@${domain}`).join(' или ')
return `Укажите рабочую почту — регистрация доступна только для домена ${list}`
}
/**
* Экран регистрации (см. design/mockups/auth.html, блок «регистрация»).
* Поле «Команда» показывается только если выбор команды включён в
* настройках инстанса (`GET /auth/registration-options`, публичный эндпоинт).
* Поле «Рабочая почта» дополнительно сверяется с эталонным доменом
* (`registration-options.email_domain`), если в админке включена
* верификация по домену — проверка идёт по blur и при сабмите, backend
* при включённой верификации и чужом домене отвечает 400 `invalid_email_domain`.
* Поле «Рабочая почта» дополнительно сверяется с эталонными доменами
* (`registration-options.email_domains` — подходит совпадение с ЛЮБЫМ),
* если в админке включена верификация по домену — проверка идёт по blur и
* при сабмите, backend при включённой верификации и чужом домене отвечает
* 400 `invalid_email_domain`.
* После успешной регистрации показывает состояние «подтвердите почту»
* (письмо со ссылкой backend в dev-режиме печатает в консоль).
*/
@@ -38,16 +40,16 @@ export function RegisterPage() {
})
const teamChoiceEnabled = registrationOptions?.team_choice_enabled ?? false
const teams = registrationOptions?.teams ?? []
const expectedEmailDomain = registrationOptions?.email_domain ?? null
const expectedEmailDomains = registrationOptions?.email_domains ?? []
/** Домен после «@» не совпадает (без учёта регистра) с эталонным — `null`, если сверять не с чем. */
/** Домен после «@» не совпадает (без учёта регистра) ни с одним эталонным — `null`, если сверять не с чем. */
function checkEmailDomain(value: string): string | null {
if (!expectedEmailDomain) return null
if (expectedEmailDomains.length === 0) return null
const atIndex = value.lastIndexOf('@')
if (atIndex === -1) return null
const domain = value.slice(atIndex + 1).trim().toLowerCase()
if (domain !== expectedEmailDomain.toLowerCase()) {
return domainMismatchMessage(expectedEmailDomain)
if (!expectedEmailDomains.some((expected) => domain === expected.toLowerCase())) {
return domainMismatchMessage(expectedEmailDomains)
}
return null
}
@@ -68,7 +70,7 @@ export function RegisterPage() {
if (err instanceof ApiError && err.status === 409) {
setError('Пользователь с таким email уже зарегистрирован')
} else if (err instanceof ApiError && err.status === 400 && errorDetail(err) === 'invalid_email_domain') {
setEmailDomainError(expectedEmailDomain ? domainMismatchMessage(expectedEmailDomain) : 'Регистрация с этим доменом почты недоступна')
setEmailDomainError(expectedEmailDomains.length > 0 ? domainMismatchMessage(expectedEmailDomains) : 'Регистрация с этим доменом почты недоступна')
} else {
setError('Не удалось зарегистрироваться. Проверьте данные и попробуйте снова')
}

View File

@@ -17,6 +17,7 @@ import { RoomStage } from '@/components/room/RoomStage'
import { RoomToolbar } from '@/components/room/RoomToolbar'
import { ChatPanel } from '@/components/room/ChatPanel'
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
interface RoomJoinState {
livekitUrl: string
@@ -163,6 +164,12 @@ export function RoomPage() {
() => ({
audioCaptureDefaults: { deviceId: userChoices.audioDeviceId || undefined },
videoCaptureDefaults: { deviceId: userChoices.videoDeviceId || undefined },
// Аудиовыход (колонки/наушники/bluetooth) — отдельный персист, не через
// usePersistentUserChoices: LocalUserChoices LiveKit про него не знает
// (см. lib/audioOutputDevice.ts). Читаем один раз при монтировании —
// как и с audio/videoDeviceId, смена применяется через DeviceSettingsDialog
// (setActiveMediaDevice), а не пересозданием roomOptions.
audioOutput: { deviceId: loadAudioOutputDeviceId() || undefined },
}),
[userChoices],
)

View File

@@ -378,6 +378,48 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
font-family: var(--font-body);
}
.room-field select:focus { outline: none; border-color: var(--color-room-focus-ring); }
.room-field-label { display: block; font: var(--text-body); font-weight: 600; color: var(--color-room-text-primary); margin-bottom: var(--space-2); }
.room-field-hint { font: var(--text-body); color: var(--color-room-text-secondary); margin: 0; }
/*
* ---------- Шторка «Настройки устройств» на мобильном ----------
* Тот же диалог (`DeviceSettingsDialog`), что модалка выше, — только контейнер
* снизу вместо центра экрана: `.room-sheet-overlay`/`.room-sheet-panel` вместо
* `.room-modal-overlay`/`.room-modal-panel`, разметка полей (`.room-field` и
* ниже) общая. Ручка `.room-sheet-handle` — свайп вниз для закрытия (JS считает
* смещение пальца и сам решает, закрывать или вернуть панель на место;
* `transition` тут — только пружина возврата, во время самого драга
* компонент подставляет инлайновый `transition: none`).
*/
.room-sheet-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 200;
}
.room-sheet-panel {
background: var(--color-room-surface-raised);
border: 1px solid var(--color-room-tile-border);
border-bottom: none;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
box-shadow: var(--shadow-room-panel);
padding: var(--space-4) var(--space-6) calc(var(--space-6) + env(safe-area-inset-bottom));
width: 100%;
max-height: 80vh;
overflow-y: auto;
transition: transform 160ms ease-out;
}
.room-sheet-handle {
width: 40px;
height: 4px;
border-radius: 2px;
background: var(--color-room-tile-border);
margin: 0 auto var(--space-4);
touch-action: none;
}
/*
* ---------- Чип «Вы демонстрируете экран» ----------