15 Commits

Author SHA1 Message Date
0162cc8a6d release: версия 0.0.15
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-30 00:27:51 +03:00
5d39e0f076 feat(room): кнопка микрофона в мини-окне (PiP)
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Document PiP не показывает тулбар комнаты — во время звонка в мини-окне
нельзя было замьютить себя, не разворачивая основное окно. Кнопка лежит
поверх единственной плитки мини-плеера и использует тот же useTrackToggle,
что и тулбар: React-контекст LiveKitRoom не разрывается порталом в
pipWindow.document.body, поэтому состояние читается и меняется одинаково
что в основном окне, что в мини-плеере.
2026-07-30 00:25:19 +03:00
43cc1ae3e8 feat(room): закрепление участника доступно из любого режима показа
Булавка закрепления раньше рендерилась только в «Стандарте» — в «Плитках»
и «Живых плитках» её не было вовсе, хотя крупной плитки для показа
закреплённого там и так нет. Теперь кнопка есть на любой плитке в любом
режиме, а сам клик закрепления (не открепления) переключает вид на
«Стандарт» — иначе закреплённого негде показать крупно.

Открепление обратно на предыдущий вид не переключает — это разовое
действие, а не устойчивый выбор пользователя, дёргать раскладку туда-обратно
не нужно. Переключение при закреплении не сохраняется в localStorage
(в отличие от явного выбора режима в тулбаре/настройках) по той же причине.

Заодно закрывает задачу «закрепление на мобильном»: раньше кнопки не было
в плиточных режимах даже там, а на тач-устройствах она и так всегда видна
(`@media (hover: none)`), отдельной мобильной доработки не потребовалось.
2026-07-30 00:25:02 +03:00
82204553e2 feat(chat): выбор эмодзи в панели чата
Кнопка со смайликом слева от поля ввода открывает поповер с небольшим
собственным набором популярных эмодзи (без библиотеки-пикера) — выбор
вставляется в позицию курсора. Поповер открывается вверх от кнопки, поэтому
на мобильном никогда не перекрывает textarea; закрытие — по клику вне и
по Escape (переиспользован useModalDismiss).
2026-07-30 00:23:53 +03:00
3290eb5d07 feat(room): показывать название конференции вместо технического room_name
RoomTopbar рендерил `JoinOut.room_name` — техническое имя комнаты LiveKit
вида `hC-Dmos9KEM`, которое туда попало по ошибке (перепутано с
человекочитаемым title конференции). Теперь топбар получает `title`
(из резолва номера/ссылки или из карточки конференции в лобби) и падает
на «Конференция без названия», если организатор его не задал. Номер и
slug остаются на своих местах — в инвайт-чипе и в адресе, где они нужны
функционально.
2026-07-30 00:23:18 +03:00
f627d827af feat(room): выключенные микрофон и камера при входе в конференцию
Раньше LiveKitRoom публиковал оба трека сразу при подключении (audio/video
без значения — булев true). Теперь участник заходит с выключенными
микрофоном и камерой и включает их сам уже в комнате; кнопки тулбара
(useTrackToggle) отражают фактическое состояние и работают штатно.
2026-07-30 00:22:44 +03:00
5e6c4f8bfd chore(monitoring): глубина логов и срок хранения метрик под наблюдение неделями
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Готовим стенд к сбору статистики по реальным конференциям вместо разового
нагрузочного теста. В прежней конфигурации данные не дожили бы до разбора.

Логи: 10 МБ × 3 → 50 МБ × 5 на контейнер. При полусотне участников логи
LiveKit перезаписывались за часы, а по ним восстанавливается то, чего нет
в метриках: сколько камер работало одновременно, кого и почему отключило,
как шли события congestion. Именно так был уточнён профиль теста 28.07
(оказалось 17 камер, а не 8).

Prometheus: retention 15 суток (дефолт) → 30. Первые два флага в `command`
дублируют дефолт образа намеренно — `command` перекрывает CMD целиком, без
них Prometheus не найдёт конфиг.

Расход: 250 МБ логов на контейнер и рост TSDB со 100 МБ; на сервере
свободно 15 ГБ.
2026-07-29 13:32:18 +03:00
270926cc96 release: версия 0.0.14
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-29 00:17:19 +03:00
e018837a1d fix(livekit): анонсировать клиентам внешний TURN — relay не работал совсем
coturn поднимался, был healthy и слушал 3478 — но клиенты о нём никогда не
узнавали: встроенный TURN выключен (`turn.enabled: false`), внешний в
конфигурации не объявлен, фронтенд `iceServers` не задаёт. За всё время
работы сервера в логах coturn нет ни одной аллокации.

Следствие: у участников из сетей, где прямое UDP-соединение не проходит,
не было relay-фолбэка вообще — только прямой UDP и TCP 7881. На
нагрузочном тесте 28.07 все разрывы `PEER_CONNECTION_DISCONNECTED`
пришлись на внешних участников и ни одного — на офисных.

Добавлена секция `rtc.turn_servers` (UDP и TCP на 3478). Эти серверы
только анонсируются клиенту в списке ICE — сам SFU через них не ходит
(см. iceServersForParticipant в LiveKit). Credentials генерируются по
механизму TURN REST API из общего `TURN_STATIC_AUTH_SECRET`, поэтому
`render-templates.sh` теперь подставляет его и `TURN_EXTERNAL_IP` также в
конфигурацию LiveKit.

TLS (5349/443) намеренно не анонсируется: сертификаты в coturn не
смонтированы, а неработающий `turns:` заставил бы клиента ждать таймаута
перед переходом к рабочему кандидату. Что нужно для его включения —
описано в разделе 8 руководства.

Там же исправлено умолчание в правилах ufw: помимо 3478 нужен диапазон
relay-аллокаций `49160:49200/udp`. Без него TURN отвечает на запросы, но
релей не работает, причём в логах coturn при этом тишина.
2026-07-29 00:17:19 +03:00
705f160912 release: версия 0.0.13
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-28 23:26:41 +03:00
7a5e9d2d8a perf(deploy): не пересобирать окружение uv в рантайме контейнеров
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Образ собран с `uv sync --frozen --no-dev`, но `uv run` перед каждым запуском
заново синхронизирует venv и подтягивает dev-группу. В логах старта
vidconf-backend-1 и vidconf-worker-1 на проде это видно как «Downloading ruff /
mypy / pygments» и «Installed 12 packages». Хуже всего healthcheck'и: они
выполняют ту же синхронизацию каждые 15 секунд всю жизнь контейнера.

Проверено на локально собранном образе, одна и та же команда:

  uv run             — качает 12 пакетов, venv 456 → 574 МБ
  uv run --no-sync   — не качает ничего, venv остаётся 456 МБ

Флаг добавлен во все вызовы в прод-путях: CMD образа, command/entrypoint/
healthcheck всех сервисов compose, миграции и seed в install.sh, те же команды
в docs/deploy. Локальная разработка (dev-setup.md, backend/README.md, CI) не
затронута — там dev-зависимости нужны. Заодно убрано устаревшее объяснение
ретрая `up -d --wait`: первый старт больше не синхронизирует окружение.

Версия uv в образе — 0.11.33, `--no-sync` поддерживается.
2026-07-28 23:25:56 +03:00
eb4e5ea83f perf(frontend): включить adaptiveStream и dynacast
Оба флага в LiveKit по умолчанию выключены: каждый клиент был подписан на
полное качество всех чужих треков независимо от размера плитки, а каждый
паблишер слал все слои симулкаста, даже если их никто не смотрит. На тесте
28.07 (19 участников, ~8 камер) это дало устойчивые 140–169 Мбит/с исходящего
трафика при пике 240, 662 события `remote bwe: channel congestion detected` и
146 переходов аллокатора STABLE → DEFICIENT — то есть видимый участникам лаг.

Замер на локальном стенде (9 участников, паблишеры 720p, одинаковый состав
комнаты, приращение bytesReceived по getStats клиента):

  без флагов   5 потоков 1280x720 @33 fps  — 11 110 кбит/с
  с флагами    4 потока   320x150 @17 fps  —    778 кбит/с

Заодно начинает экономить уже написанный код, который до сих пор не давал
выигрыша: «скрыть остальных» не рендерит карусель (TX контейнера LiveKit
0.76 → 0.03 Мбит/с), пагинация StageGrid рендерит только текущую страницу,
а pauseVideoInBackground (дефолт true) работает лишь при adaptiveStream.

Демонстрация экрана, режимы показа и листание страниц проверены — регрессий нет.
2026-07-28 23:25:43 +03:00
b528785249 docs(deploy): правило ufw для скрейпа node-exporter из docker-сети
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
node-exporter переведён в host-сеть (иначе отдавал сетевые метрики
контейнера вместо серверных), и порт 9100 теперь слушается на хосте —
а значит Prometheus из docker-сети упирается в политику ufw по умолчанию.
Без этого правила таргет `node` остаётся down с `context deadline exceeded`.

Правило узкое: только из внутренних docker-подсетей (172.16.0.0/12 не
маршрутизируется в интернете) и только на 9100. Снаружи порт закрыт.

Инсталлятор firewall не настраивает — это ручной шаг документации,
поэтому строка добавлена именно сюда, иначе на новой инсталляции
мониторинг хоста молча останется без сетевых метрик.
2026-07-28 19:25:33 +03:00
a53ba7c827 fix(monitoring): node-exporter отдавал сетевые метрики контейнера вместо хоста
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
`node_network_*` показывал трафик собственного `eth0` экспортера (56 МБ)
вместо хостового `enp3s0` (39.8 ГБ). При разборе нагрузочного теста 28.07
сетевых метрик хоста не оказалось вовсе — весь анализ трафика пришлось
вести по метрикам контейнеров.

Причина не в конфигурации экспортера, а в устройстве procfs: bind-mount
`/proc` хоста достаточен для CPU, памяти и диска, но `/proc/net` — это
симлинк на `self/net`, который резолвится в сетевом namespace читающего
процесса. Никакое монтирование это не обходит, нужен host network
namespace. Прежний комментарий в compose утверждал обратное — исправлен.

Порт 9100 теперь слушается на хосте, наружу не торчит: ufw пропускает
только 22/80/443/3478/7881/51820 и UDP-диапазон LiveKit. Prometheus
обращается к экспортеру через `host.docker.internal` (`extra_hosts:
host-gateway`), потому что по имени сервиса в docker-сети он больше не
резолвится.

Дашборд `host.json` правок не требует: сетевые панели фильтруют
интерфейсы по исключению (`device!~"lo|veth.*|docker.*|br-.*"`), под
которое `enp3s0` не подпадает. Алерты на имя instance не завязаны.
2026-07-28 19:22:36 +03:00
71f150d1b6 feat(monitoring): собирать метрики LiveKit в Prometheus
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
В `livekit.yaml` порт метрик (6789) объявлен с самого начала, но job'а в
Prometheus не было — метрики SFU просто не собирались. Из-за этого разбор
нагрузочного теста 28.07.2026 пришлось вести по логам: `container-exporter`
показывает CPU, память и суммарный трафик контейнера, но не знает, что
внутри этого трафика.

Теперь доступны, в частности:
- `livekit_track_subscribed_total` / `livekit_track_published_total` —
  подписки против публикаций, то есть прямой эффект adaptiveStream;
- `livekit_participant_total`, `livekit_room_total` — нагрузка в участниках;
- `livekit_quality_score`, `livekit_packet_loss_percent`, `livekit_rtt_ms`,
  `livekit_jitter_us`, `livekit_nack_total`, `livekit_pli_total` — качество
  связи у клиентов вместо догадок по событиям congestion в логах;
- `livekit_webhook_queue_length` — очередь доставки вебхуков в backend.

Job включён, а не закомментирован, как `llm`: профиль `media` входит в
дефолтный набор COMPOSE_PROFILES. Конфиг проверен `promtool check config`.
2026-07-28 19:15:38 +03:00
19 changed files with 568 additions and 82 deletions

View File

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

View File

@@ -3,6 +3,85 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](https://semver.org/lang/ru/). проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
## [0.0.15] — 2026-07-30
Шесть доработок UI комнаты конференции.
### Добавлено
- Выбор эмодзи в чате — поповер с небольшим собственным набором популярных
эмодзи (без библиотеки-пикера), вставка в позицию курсора.
- Кнопка микрофона в мини-окне (Document PiP) — раньше в PiP не было
никакого способа замьютить себя, не разворачивая основное окно.
- Закрепление участника доступно из любого режима показа («Стандарт»,
«Плитки», «Живые плитки») — раньше булавка рендерилась только в
«Стандарте». Само закрепление автоматически переключает вид на «Стандарт»
(закреплённого иначе негде показать крупно); открепление вид назад не
меняет.
### Изменено
- Участник заходит в конференцию с выключенными микрофоном и камерой,
включает их сам уже в комнате.
- В топбаре комнаты вместо технического `room_name` LiveKit (вида
`hC-Dmos9KEM`) показывается название конференции; при его отсутствии —
«Конференция без названия». Номер и ссылка-приглашение не задеты.
## [0.0.14] — 2026-07-28
TURN-фолбэк для участников из сетей с жёстким NAT.
### Исправлено
- LiveKit теперь анонсирует клиентам внешний TURN-сервер (`rtc.turn_servers`
в конфигурации, UDP и TCP на 3478). Раньше coturn поднимался и был healthy,
но клиенты о нём не знали: внешний TURN в конфиге объявлен не был, встроенный
выключен, а фронтенд `iceServers` не задаёт. За всё время работы в логах
coturn не было ни одной аллокации — то есть relay не использовался никогда,
и участники из сетей, где прямое UDP-соединение не проходит, теряли связь
(`PEER_CONNECTION_DISCONNECTED`). На нагрузочном тесте 28.07 все такие
разрывы пришлись на внешних участников и ни одного — на офисных.
- Credentials TURN генерируются по механизму TURN REST API из общего
`TURN_STATIC_AUTH_SECRET`, то есть тот же секрет, что и у coturn.
### Изменено
- `deploy/render-templates.sh` подставляет `TURN_EXTERNAL_IP` и
`TURN_STATIC_AUTH_SECRET` также в конфигурацию LiveKit.
- Раздел 8 руководства по развёртыванию переписан: TURN больше не «план на
будущее», а рабочая конфигурация. Отдельно описано правило ufw для
relay-диапазона `49160:49200/udp` — без него TURN отвечает на запросы, но
релей не работает, причём в логах coturn при этом тишина.
TURN over TLS (5349/443) по-прежнему не настроен: требует монтирования
сертификата в coturn. Неработающий `turns:` намеренно не анонсируется, иначе
клиент ждал бы таймаута перед переходом к рабочему кандидату.
## [0.0.13] — 2026-07-28
Снижение нагрузки на сеть: клиент перестаёт получать полное качество всех
чужих камер независимо от того, какого размера плитка на экране.
### Изменено
- Включены `adaptiveStream` и `dynacast` в опциях комнаты. Оба флага в LiveKit
выключены по умолчанию, из-за чего каждый участник был подписан на полное
качество всех чужих треков, а каждый паблишер слал все слои симулкаста, даже
когда их никто не смотрит. Теперь качество подписки выбирается по фактическому
размеру плитки, а неотрисованные треки уходят в паузу. На локальном стенде
(9 участников, паблишеры 720p) входящий поток одного клиента упал с
11 110 до 778 кбит/с.
Вместе с этим начинает экономить уже написанный код, который до сих пор не
давал выигрыша: «скрыть остальных» не рендерит карусель (исходящий трафик
LiveKit 0.76 → 0.03 Мбит/с), пагинация сетки участников рендерит только
текущую страницу, а пауза чужого видео в свёрнутой вкладке работает лишь
при включённом `adaptiveStream`.
- Контейнеры больше не пересобирают окружение Python при запуске: во все
вызовы `uv run` в прод-путях (CMD образа, `command`/`entrypoint`/`healthcheck`
сервисов, миграции и seed в `install.sh`) добавлен `--no-sync`. Раньше
окружение, собранное на этапе build с `--no-dev`, при каждом старте
синхронизировалось заново и подтягивало dev-группу: ~26 МБ загрузок,
замедленный старт и ruff/mypy/pytest в рантайме. Healthcheck'и делали то же
самое каждые 15 секунд всю жизнь контейнера. Размер `/app/.venv` после
запуска: 574 → 456 МБ. Локальная разработка не затронута — там dev-группа
нужна и ставится как раньше.
## [0.0.12] — 2026-07-28 ## [0.0.12] — 2026-07-28
Разблокировка backend под нагрузкой: вход в конференцию перестаёт отваливаться, Разблокировка backend под нагрузкой: вход в конференцию перестаёт отваливаться,

View File

@@ -1 +1 @@
0.0.12 0.0.15

View File

@@ -45,5 +45,12 @@ HEALTHCHECK --interval=10s --timeout=5s --retries=10 --start-period=15s \
# #
# `sh -c` нужен ради подстановки переменной (exec-форма её не делает), # `sh -c` нужен ради подстановки переменной (exec-форма её не делает),
# `exec` — чтобы uvicorn получил PID 1 и корректно принимал SIGTERM. # `exec` — чтобы uvicorn получил PID 1 и корректно принимал SIGTERM.
#
# `--no-sync`: окружение уже собрано выше (`uv sync --frozen --no-dev`), и
# пересобирать его в рантайме незачем. Без флага `uv run` перед каждым
# запуском заново синхронизирует venv И ПОДТЯГИВАЕТ dev-группу (ruff, mypy,
# pytest — ~30 МБ загрузок на каждый старт контейнера, dev-инструменты в
# проде и раздутый venv). То же касается healthcheck'ов в
# deploy/docker-compose.yml, которые дёргают `uv run` каждые 15 секунд.
CMD ["sh", "-c", \ CMD ["sh", "-c", \
"exec uv run uvicorn main:create_app --factory --host 0.0.0.0 --port 8000 --workers ${UVICORN_WORKERS:-2}"] "exec uv run --no-sync uvicorn main:create_app --factory --host 0.0.0.0 --port 8000 --workers ${UVICORN_WORKERS:-2}"]

View File

@@ -1,10 +1,17 @@
name: vidconf name: vidconf
# Глубина логов рассчитана на РАЗБОР ИНЦИДЕНТОВ, а не только на просмотр
# последних сообщений. При 10 МБ × 3 (прежнее значение) логи LiveKit на
# конференции в полсотни человек перезаписывались за часы — а именно по ним
# восстанавливаются вещи, которых нет в метриках: сколько камер было включено
# одновременно, кого и почему отключило, какие события congestion шли.
# 50 МБ × 5 = 250 МБ на контейнер; на сервере с 15 ГБ свободного места это
# незаметно, зато ретроспектива живёт неделями.
x-logging: &default-logging x-logging: &default-logging
driver: json-file driver: json-file
options: options:
max-size: "10m" max-size: "50m"
max-file: "3" max-file: "5"
services: services:
postgres: postgres:
@@ -82,7 +89,7 @@ services:
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media} MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение # Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health. # из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.12} VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.15}
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан # Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение, # на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
# проверьте бюджет соединений с БД: каждый воркер держит свой пул # проверьте бюджет соединений с БД: каждый воркер держит свой пул
@@ -125,7 +132,13 @@ services:
context: ../backend context: ../backend
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker", "-B", # `--no-sync` во ВСЕХ вызовах `uv run` в этом файле: окружение собрано на
# этапе build образа (`uv sync --frozen --no-dev`, backend/Dockerfile), а
# без флага `uv run` синхронизирует venv заново при каждом запуске — и
# тянет dev-группу (ruff, mypy, pytest), которой в проде делать нечего.
# Для healthcheck'ов это особенно дорого: они дёргаются каждые 15 секунд
# всю жизнь контейнера.
command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker", "-B",
"-Q", "celery,summarize,notify", "--loglevel=info"] "-Q", "celery,summarize,notify", "--loglevel=info"]
env_file: env_file:
- ../.env - ../.env
@@ -152,7 +165,7 @@ services:
# нет HTTP-сервера на 8000. Проверяем воркер через `celery ... inspect # нет HTTP-сервера на 8000. Проверяем воркер через `celery ... inspect
# ping`, как рекомендует документация Celery. # ping`, как рекомендует документация Celery.
healthcheck: healthcheck:
test: ["CMD", "uv", "run", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5"] test: ["CMD", "uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5"]
interval: 15s interval: 15s
timeout: 10s timeout: 10s
retries: 5 retries: 5
@@ -186,7 +199,7 @@ services:
build: build:
context: ../backend context: ../backend
dockerfile: Dockerfile dockerfile: Dockerfile
entrypoint: ["uv", "run", "python", "/download-model.py"] entrypoint: ["uv", "run", "--no-sync", "python", "/download-model.py"]
environment: environment:
WHISPER_MODEL: ${WHISPER_MODEL:-small} WHISPER_MODEL: ${WHISPER_MODEL:-small}
WHISPER_MODELS_ROOT: /models/whisper WHISPER_MODELS_ROOT: /models/whisper
@@ -221,7 +234,7 @@ services:
# `worker`, и `celery inspect ping` без `--destination` опросит ВЕСЬ # `worker`, и `celery inspect ping` без `--destination` опросит ВЕСЬ
# кластер — упавший worker-transcriber остался бы "healthy", потому что # кластер — упавший worker-transcriber остался бы "healthy", потому что
# ответил бы базовый worker. # ответил бы базовый worker.
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker", command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker",
"-Q", "transcription", "--pool=solo", "--concurrency=1", "-Q", "transcription", "--pool=solo", "--concurrency=1",
"--hostname=worker-transcriber@localhost", "--loglevel=info"] "--hostname=worker-transcriber@localhost", "--loglevel=info"]
env_file: env_file:
@@ -252,7 +265,7 @@ services:
# HTTP-эндпоинта нет — пинг celery, но именно этого узла (см. --hostname # HTTP-эндпоинта нет — пинг celery, но именно этого узла (см. --hostname
# в command выше), а не первого ответившего в общем кластере. # в command выше), а не первого ответившего в общем кластере.
healthcheck: healthcheck:
test: ["CMD", "uv", "run", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber@localhost"] test: ["CMD", "uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber@localhost"]
interval: 15s interval: 15s
timeout: 10s timeout: 10s
retries: 5 retries: 5
@@ -277,7 +290,7 @@ services:
args: args:
WITH_GPU_EXTRA: "true" WITH_GPU_EXTRA: "true"
restart: unless-stopped restart: unless-stopped
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker", command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker",
"-Q", "transcription", "--pool=solo", "--concurrency=1", "-Q", "transcription", "--pool=solo", "--concurrency=1",
"--hostname=worker-transcriber-gpu@localhost", "--loglevel=info"] "--hostname=worker-transcriber-gpu@localhost", "--loglevel=info"]
env_file: env_file:
@@ -310,7 +323,7 @@ services:
whisper-model-init: whisper-model-init:
condition: service_completed_successfully condition: service_completed_successfully
healthcheck: healthcheck:
test: ["CMD", "uv", "run", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber-gpu@localhost"] test: ["CMD", "uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber-gpu@localhost"]
interval: 15s interval: 15s
timeout: 10s timeout: 10s
retries: 5 retries: 5
@@ -658,10 +671,26 @@ services:
# Тег закреплён по версии (не `:latest`) — та же причина, что и у llm-образов. # Тег закреплён по версии (не `:latest`) — та же причина, что и у llm-образов.
image: prom/prometheus:v3.13.1 image: prom/prometheus:v3.13.1
restart: unless-stopped restart: unless-stopped
# Первые два флага — дефолт образа; повторяем их явно, потому что
# `command` перекрывает CMD целиком. Третий — срок хранения: дефолтных
# 15 суток мало, когда нагрузку набирают неделями (наблюдение за
# реальными конференциями вместо разового теста), и разбирать её потом
# приходится задним числом. 30 суток при нынешних 100 МБ TSDB стоят
# копеек — база растёт медленнее, чем кажется.
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=30d'
volumes: volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./monitoring/alerts.yml:/etc/prometheus/alerts.yml:ro - ./monitoring/alerts.yml:/etc/prometheus/alerts.yml:ro
- prometheus_data:/prometheus - prometheus_data:/prometheus
# node-exporter живёт в host-сети (см. комментарий у него) и по имени
# сервиса в docker-сети больше не резолвится. `host-gateway` — штатный
# способ дать контейнеру адрес хоста, не завязываясь на конкретный IP
# docker-моста.
extra_hosts:
- "host.docker.internal:host-gateway"
# Loopback-only: админ-доступ по ssh-туннелю, наружу не публикуется. # Loopback-only: админ-доступ по ssh-туннелю, наружу не публикуется.
ports: ports:
- "127.0.0.1:9090:9090" - "127.0.0.1:9090:9090"
@@ -711,13 +740,27 @@ services:
node-exporter: node-exporter:
# Метрики железа хоста (CPU, память, диск, сеть, load average) — то, # Метрики железа хоста (CPU, память, диск, сеть, load average) — то,
# чего нет ни в одном из приложенческих экспортеров выше. Без # чего нет ни в одном из приложенческих экспортеров выше.
# `network_mode: host` (не нужен: читаем /proc,/sys,/ хоста через #
# bind-mount, а Prometheus достаёт их по имени сервиса во внутренней # `network_mode: host` ОБЯЗАТЕЛЕН, и вот почему (проверено 2026-07-28,
# сети compose — так безопаснее, не расширяет сетевой доступ контейнера). # до этого экспортер работал в bridge-сети и отдавал неверные данные).
# Bind-mount'а `/proc` достаточно для CPU, памяти и диска, но НЕ для сети:
# `/proc/net` — это симлинк на `self/net`, который резолвится в сетевом
# namespace ЧИТАЮЩЕГО процесса. В bridge-сети экспортер видел собственные
# `lo` и `eth0` (56 МБ трафика) вместо хостового `enp3s0` (39.8 ГБ), то
# есть `node_network_*` показывал трафик контейнера, а не сервера. При
# разборе нагрузочного теста 28.07 сетевых метрик хоста не оказалось
# вовсе — см. .forcc/LOAD-FINDINGS.md.
#
# Порт 9100 при этом слушается на хосте. Наружу он не торчит: ufw
# пропускает только 22/80/443/3478/7881/51820 и UDP-диапазон LiveKit
# (проверено `ufw status`). Prometheus обращается к нему через
# `host.docker.internal` (см. `extra_hosts` у сервиса prometheus и
# таргет `node` в deploy/monitoring/prometheus.yml).
image: prom/node-exporter:v1.8.2 image: prom/node-exporter:v1.8.2
restart: unless-stopped restart: unless-stopped
pid: host pid: host
network_mode: host
volumes: volumes:
- /proc:/host/proc:ro - /proc:/host/proc:ro
- /sys:/host/sys:ro - /sys:/host/sys:ro
@@ -727,9 +770,8 @@ services:
- '--path.sysfs=/host/sys' - '--path.sysfs=/host/sys'
- '--path.rootfs=/rootfs' - '--path.rootfs=/rootfs'
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)' - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
# Не публикуем порт наружу вообще (не 127.0.0.1:9100, а совсем без # Секции `ports` нет и с host-сетью быть не может: контейнер слушает
# ports) — Prometheus ходит к нему по внутренней сети compose # прямо на интерфейсах хоста. От внешнего мира порт закрывает ufw.
# (`node-exporter:9100`), публикация на хост для этого не нужна.
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9100/metrics >/dev/null || exit 1"] test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9100/metrics >/dev/null || exit 1"]
interval: 10s interval: 10s

View File

@@ -32,6 +32,40 @@ rtc:
use_external_ip: ${LIVEKIT_USE_EXTERNAL_IP} use_external_ip: ${LIVEKIT_USE_EXTERNAL_IP}
node_ip: ${LIVEKIT_NODE_IP} node_ip: ${LIVEKIT_NODE_IP}
# Внешний TURN (сервис coturn, профиль `media`) — АНОНС КЛИЕНТАМ.
# Сам SFU через эти серверы не ходит: LiveKit лишь отдаёт их браузеру в
# списке ICE-серверов при подключении (см. iceServersForParticipant в
# pkg/service/roommanager.go), а клиент уже решает, нужен ли ему relay.
#
# Зачем. До 28.07.2026 coturn работал, но КЛИЕНТЫ О НЁМ НЕ ЗНАЛИ: секция
# `turn` ниже выключена (встроенный TURN не поднимаем), внешний в конфиге
# объявлен не был, а фронтенд `iceServers` не задаёт. За всё время работы
# в логах coturn — ноль ALLOCATE. Итог: у клиентов из сетей с жёстким NAT
# не было relay-фолбэка вообще, только прямой UDP и TCP 7881. Именно так
# объясняются `PEER_CONNECTION_DISCONNECTED` на нагрузочном тесте — все
# у внешних участников, ни одного у офисных (.forcc/LOAD-FINDINGS.md,
# причина C).
#
# `secret` обязан совпадать с `static-auth-secret` в turnserver.conf —
# оба рендерятся из одного TURN_STATIC_AUTH_SECRET (deploy/render-templates.sh).
# Логин/пароль LiveKit генерирует сам по механизму TURN REST API.
#
# UDP и TCP на 3478 — оба порта уже открыты в ufw. TLS (5349) намеренно не
# объявляем: в turnserver.conf сертификаты не смонтированы, и анонс
# неработающего `turns:` заставил бы клиента впустую ждать таймаута,
# прежде чем перейти к рабочему кандидату.
turn_servers:
- host: ${TURN_EXTERNAL_IP}
port: 3478
protocol: udp
secret: ${TURN_STATIC_AUTH_SECRET}
ttl: 14400
- host: ${TURN_EXTERNAL_IP}
port: 3478
protocol: tcp
secret: ${TURN_STATIC_AUTH_SECRET}
ttl: 14400
# Redis обязателен для сервиса egress (см. deploy/egress/) — он использует # Redis обязателен для сервиса egress (см. deploy/egress/) — он использует
# его как pub/sub и key-value хранилище состояния запущенных записей; # его как pub/sub и key-value хранилище состояния запущенных записей;
# без него egress не может получать room/track-события от LiveKit # без него egress не может получать room/track-события от LiveKit

View File

@@ -38,9 +38,16 @@ scrape_configs:
# — сервис node-exporter). Единственный источник, который покажет # — сервис node-exporter). Единственный источник, который покажет
# нехватку памяти/CPU на сервере, если она не проявится как рост # нехватку памяти/CPU на сервере, если она не проявится как рост
# латентности API (см. дашборд host.json). # латентности API (см. дашборд host.json).
#
# Адрес `host.docker.internal`, а не `node-exporter:9100`: с 2026-07-28
# экспортер работает в host-сети и по имени сервиса в docker-сети не
# резолвится. Причина перевода — сетевые метрики: `/proc/net` это симлинк
# на `self/net`, поэтому в bridge-сети экспортер отдавал трафик
# собственного `eth0` вместо хостового `enp3s0`. Имя резолвится через
# `extra_hosts: host-gateway` у сервиса prometheus (deploy/docker-compose.yml).
- job_name: node - job_name: node
static_configs: static_configs:
- targets: ["node-exporter:9100"] - targets: ["host.docker.internal:9100"]
# Метрики по каждому контейнеру (CPU/память/сеть отдельно у backend, # Метрики по каждому контейнеру (CPU/память/сеть отдельно у backend,
# worker, postgres и т.д. — профиль monitoring, сервис # worker, postgres и т.д. — профиль monitoring, сервис
@@ -53,6 +60,32 @@ scrape_configs:
static_configs: static_configs:
- targets: ["container-exporter:9419"] - targets: ["container-exporter:9419"]
# LiveKit SFU (профиль `media`). Порт объявлен в самом LiveKit —
# `deploy/livekit/livekit.yaml.template`, секция `prometheus: port: 6789`;
# наружу он не публикуется, скрейп идёт по имени сервиса внутри docker-сети.
#
# Почему это важно отдельно от `container-exporter`: тот показывает CPU,
# память и суммарный трафик контейнера, но ничего не знает о том, ЧТО внутри
# этого трафика. Разбор нагрузочного теста 28.07.2026 пришлось делать по
# логам именно потому, что job'а здесь не было (см. .forcc/LOAD-FINDINGS.md).
#
# Ключевое, что отсюда появляется:
# livekit_track_subscribed_total / livekit_track_published_total —
# подписки против публикаций, то есть прямой эффект adaptiveStream;
# livekit_participant_total, livekit_room_total — нагрузка в участниках;
# livekit_quality_score, livekit_packet_loss_percent, livekit_rtt_ms,
# livekit_jitter_us, livekit_nack_total, livekit_pli_total — качество
# связи у клиентов, а не догадки по событиям congestion в логах;
# livekit_webhook_queue_length, livekit_webhook_dispatch_total — очередь
# доставки вебхуков в backend (на тесте она росла до 56 секунд).
#
# Профиль `media` входит в дефолтный набор COMPOSE_PROFILES (см. .env.example),
# поэтому job включён, а не закомментирован, как `llm`. На инсталляции без
# профиля `media` таргет не резолвится — закомментируйте секцию.
- job_name: livekit
static_configs:
- targets: ["livekit:6789"]
# Локальный LLM-сервер (llama.cpp, LLAMA_ARG_ENDPOINT_METRICS=1). Адрес # Локальный LLM-сервер (llama.cpp, LLAMA_ARG_ENDPOINT_METRICS=1). Адрес
# `llm:8080` разрешается ОДНИМ из двух compose-сервисов в зависимости от # `llm:8080` разрешается ОДНИМ из двух compose-сервисов в зависимости от
# выбранного при установке пресета — `llm` (CPU, профиль `llm`, уровни # выбранного при установке пресета — `llm` (CPU, профиль `llm`, уровни

View File

@@ -49,7 +49,10 @@ envsubst '${TURN_STATIC_AUTH_SECRET} ${TURN_REALM} ${TURN_EXTERNAL_IP}' \
< "$SCRIPT_DIR/coturn/turnserver.conf.template" > "$SCRIPT_DIR/coturn/turnserver.conf" < "$SCRIPT_DIR/coturn/turnserver.conf.template" > "$SCRIPT_DIR/coturn/turnserver.conf"
echo "[render] deploy/coturn/turnserver.conf готов" echo "[render] deploy/coturn/turnserver.conf готов"
envsubst '${LIVEKIT_USE_EXTERNAL_IP} ${LIVEKIT_NODE_IP} ${LIVEKIT_API_KEY} ${REDIS_PASSWORD}' \ # TURN_EXTERNAL_IP и TURN_STATIC_AUTH_SECRET нужны и здесь: с 0.0.14 LiveKit
# анонсирует клиентам внешний coturn (секция `rtc.turn_servers`), и секрет
# обязан совпадать с `static-auth-secret` в turnserver.conf выше.
envsubst '${LIVEKIT_USE_EXTERNAL_IP} ${LIVEKIT_NODE_IP} ${LIVEKIT_API_KEY} ${REDIS_PASSWORD} ${TURN_EXTERNAL_IP} ${TURN_STATIC_AUTH_SECRET}' \
< "$SCRIPT_DIR/livekit/livekit.yaml.template" > "$SCRIPT_DIR/livekit/livekit.yaml" < "$SCRIPT_DIR/livekit/livekit.yaml.template" > "$SCRIPT_DIR/livekit/livekit.yaml"
echo "[render] deploy/livekit/livekit.yaml готов" echo "[render] deploy/livekit/livekit.yaml готов"

View File

@@ -82,9 +82,25 @@ ufw allow 80/tcp # HTTP (редирект на HTTPS + ACME-challenge)
ufw allow 443/tcp # HTTPS ufw allow 443/tcp # HTTPS
ufw allow 7881/tcp # LiveKit RTC TCP fallback (профиль media) ufw allow 7881/tcp # LiveKit RTC TCP fallback (профиль media)
ufw allow 54000:54100/udp # LiveKit WebRTC media (ICE), см. docker-compose.yml ufw allow 54000:54100/udp # LiveKit WebRTC media (ICE), см. docker-compose.yml
# TURN (coturn) — только если включаете раздел 8: # TURN (coturn) — только если включаете раздел 8. Нужны ОБА пункта:
# сигнальные порты И диапазон relay-аллокаций (min-port/max-port из
# deploy/coturn/turnserver.conf). Без второго TURN отвечает на запросы, но
# сам релей не работает — клиент получает кандидата и не может им
# воспользоваться, а в логах coturn при этом тишина.
# ufw allow 3478/tcp # ufw allow 3478/tcp
# ufw allow 3478/udp # ufw allow 3478/udp
# ufw allow 49160:49200/udp
# Мониторинг (профиль `monitoring`): node-exporter работает в host-сети —
# иначе он отдаёт сетевые метрики собственного контейнера вместо метрик
# сервера (`/proc/net` — симлинк на `self/net`, bind-mount `/proc` этого не
# обходит; см. комментарий у сервиса в deploy/docker-compose.yml). Порт
# слушается на хосте, поэтому Prometheus в docker-сети упирается в
# политику ufw по умолчанию. Правило разрешает скрейп ТОЛЬКО из внутренних
# docker-подсетей — снаружи 9100 остаётся закрыт (172.16.0.0/12 не
# маршрутизируется в интернете):
ufw allow from 172.16.0.0/12 to any port 9100 proto tcp comment 'node-exporter: скрейп Prometheus из docker-сети'
ufw enable ufw enable
``` ```
@@ -309,29 +325,48 @@ firewall) хватает для подавляющего большинства
(характерный симптом — конференция подключается по signaling, `connection (характерный симптом — конференция подключается по signaling, `connection
state: connected`, но собеседник не видит видео/не слышит звук). state: connected`, но собеседник не видит видео/не слышит звук).
По умолчанию `deploy/livekit/livekit.yaml.template` содержит **С версии 0.0.14 LiveKit анонсирует coturn клиентам** — секция
`turn.enabled: false`, и `rtc.turn_servers` не задан — standalone coturn `rtc.turn_servers` в `deploy/livekit/livekit.yaml.template` (UDP и TCP на
поднимается (профиль `media`), но LiveKit не раздаёт его клиентам как 3478, credentials по механизму TURN REST API из общего
ICE-фолбэк. `TURN_STATIC_AUTH_SECRET`). Встроенный TURN LiveKit при этом остаётся
выключенным (`turn.enabled: false`), чтобы не поднимать два TURN-сервера.
Включение (правки шаблона `deploy/livekit/livekit.yaml.template` + ⚠️ **Чем это было до 0.0.14, если вы обновляетесь со старой версии.** coturn
редеплой; **код-фикс не входит в это руководство без запроса** — обсудите поднимался и был healthy, но клиенты о нём не знали: в конфиге LiveKit
с командой перед изменением): внешний TURN объявлен не был, а фронтенд `iceServers` не задаёт. За всё
время работы в логах coturn не было ни одного ALLOCATE — то есть relay не
использовался никогда, и участники из сетей с жёстким NAT просто теряли
соединение (`PEER_CONNECTION_DISCONNECTED`).
1. Открыть 443 для TURN/TLS (наиболее надёжный фолбэк — TURN через тот же Что нужно проверить на своей инсталляции:
порт, что и остальной HTTPS-трафик, редко блокируется firewall'ами):
потребует отдельного TLS-сертификата для coturn (`cert-file`/`pkey-file` 1. **Порты в ufw — оба пункта** (см. шаг 1): `3478/tcp` + `3478/udp` для
в `deploy/coturn/turnserver.conf.template`) — можно переиспользовать тот сигнализации и `49160:49200/udp` для relay-аллокаций. Диапазон должен
же Let's Encrypt сертификат, что и nginx (тот же `/etc/letsencrypt`, уже совпадать с `min-port`/`max-port` в
смонтированный в nginx — coturn сейчас его не монтирует, потребуется `deploy/coturn/turnserver.conf.template`. Без него TURN отвечает на
доп. volume). запросы, но релей не работает — самый неприятный вариант, потому что в
2. В `livekit.yaml.template` включить `turn.enabled: true` и/или явно логах coturn при этом тишина.
прописать `rtc.turn_servers` со статическими credentials 2. **`TURN_EXTERNAL_IP` в `.env`** — реальный внешний IP или домен сервера.
(`TURN_STATIC_AUTH_SECRET` уже есть в `.env`). Именно это значение уезжает клиентам как адрес TURN-сервера, поэтому
3. `ufw allow 3478/tcp` + `ufw allow 3478/udp` (шаг 1, закомментированные `127.0.0.1` из dev-дефолта сделает анонс бесполезным.
строки) — сейчас coturn поднят, но порт не проверялся как обязательный. 3. После правок — `./deploy/render-templates.sh` (перерендерит конфиги из
4. Передеплой (`docker compose ... up -d --force-recreate livekit coturn`) шаблонов), затем `docker compose ... up -d --force-recreate livekit`.
и повторный кросс-сетевой тест именно с проблемной сетью. ⚠️ Перезапуск LiveKit **разрывает все активные конференции** — выбирайте
окно.
4. Проверка, что релей заработал: провести звонок из проблемной сети и
убедиться, что в логах появились аллокации:
`docker logs vidconf-coturn-1 --since 10m 2>&1 | grep -ci allocate`.
Ноль при живом звонке из-за NAT означает, что до coturn не дошли —
смотрите ufw и `TURN_EXTERNAL_IP`.
**TURN over TLS (порт 5349 или 443) — не настроен.** Это самый надёжный
фолбэк (проходит там, где режут UDP и нестандартные порты), но требует
смонтировать в coturn TLS-сертификат: раскомментировать `cert`/`pkey` в
`deploy/coturn/turnserver.conf.template`, добавить volume с
`/etc/letsencrypt` (nginx его уже монтирует, coturn — нет), открыть порт и
не забыть про перезапуск coturn при обновлении сертификата. Пока этого нет,
`turns:` намеренно не анонсируется: анонс неработающего адреса заставил бы
клиента ждать таймаута перед переходом к рабочему кандидату.
--- ---
@@ -389,7 +424,7 @@ docker exec vidconf-postgres-1 pg_dump -U vidconf vidconf | gzip > db-$(date +%F
```bash ```bash
gunzip -c db-2026-07-25.sql.gz | docker exec -i vidconf-postgres-1 psql -U vidconf vidconf gunzip -c db-2026-07-25.sql.gz | docker exec -i vidconf-postgres-1 psql -U vidconf vidconf
# Догнать миграции, если бэкап снят на более старой версии кода: # Догнать миграции, если бэкап снят на более старой версии кода:
docker compose -f deploy/docker-compose.yml --env-file .env run --rm backend uv run alembic upgrade head docker compose -f deploy/docker-compose.yml --env-file .env run --rm backend uv run --no-sync alembic upgrade head
docker compose -f deploy/docker-compose.yml --env-file .env restart backend worker docker compose -f deploy/docker-compose.yml --env-file .env restart backend worker
``` ```

View File

@@ -63,7 +63,7 @@ CPU-only — отдельного GPU-варианта профилей для
Поведение: Поведение:
- **Дефолт (Enter):** применяет матрицу выбранного пресета к настройкам БД - **Дефолт (Enter):** применяет матрицу выбранного пресета к настройкам БД
(defs: `docker compose exec -T backend uv run python -m scripts.apply_preset_settings --force`) (defs: `docker compose exec -T backend uv run --no-sync python -m scripts.apply_preset_settings --force`)
- **Отказ (`n`):** сохраняет ручные правки админа; переменные `BOOTSTRAP_*` в `.env` - **Отказ (`n`):** сохраняет ручные правки админа; переменные `BOOTSTRAP_*` в `.env`
обновляются, но скрипт применения настроек НЕ запускается обновляются, но скрипт применения настроек НЕ запускается
- **Флаг `--yes`:** автоматически применяет пресет без вопроса (для CI/CD) - **Флаг `--yes`:** автоматически применяет пресет без вопроса (для CI/CD)
@@ -111,7 +111,7 @@ Grafana/Prometheus не поднимаются автоматически — с
собирает фронтенд-SPA и вкомпилирует статику, `frontend/Dockerfile`). собирает фронтенд-SPA и вкомпилирует статику, `frontend/Dockerfile`).
4. Поднимает `postgres`/`redis` (`up -d --wait`) и применяет **до старта 4. Поднимает `postgres`/`redis` (`up -d --wait`) и применяет **до старта
backend** миграции и seed одноразовыми контейнерами: backend** миграции и seed одноразовыми контейнерами:
`docker compose run --rm backend uv run alembic upgrade head` + `docker compose run --rm backend uv run --no-sync alembic upgrade head` +
`... python -m scripts.seed`. Порядок критичен: `backend.lifespan` `... python -m scripts.seed`. Порядок критичен: `backend.lifespan`
бутстрапит `instance_settings` при каждом старте приложения, поэтому на бутстрапит `instance_settings` при каждом старте приложения, поэтому на
чистой БД таблицы обязаны существовать до первого запуска backend — иначе чистой БД таблицы обязаны существовать до первого запуска backend — иначе
@@ -122,9 +122,8 @@ Grafana/Prometheus не поднимаются автоматически — с
настройки инстанса не перетираются). настройки инстанса не перетираются).
5. Поднимает остальной стек: `docker compose <--profile ...> up -d --wait` 5. Поднимает остальной стек: `docker compose <--profile ...> up -d --wait`
(backend, worker, nginx с фронтом + сервисы активных профилей). Команда (backend, worker, nginx с фронтом + сервисы активных профилей). Команда
идемпотентна и обёрнута в ретрай (до 3 попыток): первый старт backend/worker идемпотентна и обёрнута в ретрай (до 3 попыток): на слабой/загруженной
включает `uv run` (синхронизация окружения + компиляция байткода), и на машине healthcheck может не успеть за отведённые
слабой/загруженной машине healthcheck может не успеть за отведённые
retries — повтор лишь дожидается уже стартующих контейнеров. retries — повтор лишь дожидается уже стартующих контейнеров.
6. Печатает сводку: URL фронтенда/бэкенда, учётные данные администратора, 6. Печатает сводку: URL фронтенда/бэкенда, учётные данные администратора,
команда для `--profile monitoring`. команда для `--profile monitoring`.

View File

@@ -63,7 +63,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
# Без -B: beat уже запущен на базовом worker (см. правило выше). # Без -B: beat уже запущен на базовом worker (см. правило выше).
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker", command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker",
"-Q", "summarize", "--hostname=worker-summarize-%h@%h", "--loglevel=info"] "-Q", "summarize", "--hostname=worker-summarize-%h@%h", "--loglevel=info"]
env_file: env_file:
- ../.env - ../.env

View File

@@ -1,7 +1,18 @@
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react' import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
import { AlertCircle, Send, X } from 'lucide-react' import { AlertCircle, Send, SmilePlus, X } from 'lucide-react'
import type { ChatConnectionStatus, ChatMessageOut } from '@/hooks/useChat' import type { ChatConnectionStatus, ChatMessageOut } from '@/hooks/useChat'
import { formatLocalTime } from '@/lib/localTime' import { formatLocalTime } from '@/lib/localTime'
import { useModalDismiss } from '@/hooks/useModalDismiss'
/**
* Небольшой собственный набор популярных эмодзи — вместо библиотеки-пикера на
* сотни килобайт ради десятка кнопок в поповере.
*/
const EMOJI_OPTIONS = [
'😀', '😂', '😊', '😉', '😍', '🤔', '😅', '😢',
'😮', '😎', '🙌', '👍', '👎', '👏', '🙏', '❤️',
'🔥', '🎉', '✅', '❌', '⚠️', '💡', '👀', '🤝',
]
interface ChatPanelProps { interface ChatPanelProps {
messages: ChatMessageOut[] messages: ChatMessageOut[]
@@ -38,12 +49,21 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
} }
}, [messages]) }, [messages])
// Авторасширение textarea до 4 строк, дальше — внутренний скролл. Эффектом,
// а не только в обработчике ввода, — так пересчёт срабатывает и после
// программной вставки эмодзи, и после очистки поля отправкой.
useEffect(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 96)}px`
}, [draft])
function handleSend() { function handleSend() {
const text = draft.trim() const text = draft.trim()
if (!text || status !== 'open') return if (!text || status !== 'open') return
onSend(text) onSend(text)
setDraft('') setDraft('')
if (textareaRef.current) textareaRef.current.style.height = 'auto'
} }
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) { function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
@@ -55,10 +75,27 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
function handleDraftChange(event: ChangeEvent<HTMLTextAreaElement>) { function handleDraftChange(event: ChangeEvent<HTMLTextAreaElement>) {
setDraft(event.target.value) setDraft(event.target.value)
// Авторасширение textarea до 4 строк, дальше — внутренний скролл. }
const el = event.target
el.style.height = 'auto' // Вставка эмодзи в позицию курсора, а не в конец поля, — ожидаемое поведение
el.style.height = `${Math.min(el.scrollHeight, 96)}px` // при редактировании текста в середине.
function insertEmoji(emoji: string) {
const el = textareaRef.current
if (!el) {
setDraft((current) => current + emoji)
return
}
const start = el.selectionStart ?? draft.length
const end = el.selectionEnd ?? draft.length
setDraft(draft.slice(0, start) + emoji + draft.slice(end))
// value обновится реальным React-рендером только на следующий тик —
// курсор возвращаем после него, иначе setSelectionRange встанет на
// ещё старую (более короткую) строку.
requestAnimationFrame(() => {
const pos = start + emoji.length
el.focus()
el.setSelectionRange(pos, pos)
})
} }
return ( return (
@@ -101,6 +138,7 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
handleSend() handleSend()
}} }}
> >
<EmojiPickerButton onSelect={insertEmoji} disabled={status !== 'open'} />
<textarea <textarea
ref={textareaRef} ref={textareaRef}
rows={1} rows={1}
@@ -118,3 +156,77 @@ export function ChatPanel({ messages, status, statusMessage, onSend, onClose }:
</aside> </aside>
) )
} }
interface EmojiPickerButtonProps {
onSelect: (emoji: string) => void
disabled: boolean
}
/**
* Кнопка с поповером выбора эмодзи слева от поля ввода. Поповер открывается
* ВВЕРХ от кнопки (как `.tb-menu` тулбара) — на мобильном чат-панель занята
* полем ввода снизу, а место над тулбаром свободно, так поповер никогда не
* перекрывает textarea.
*/
function EmojiPickerButton({ onSelect, disabled }: EmojiPickerButtonProps) {
const [open, setOpen] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
// Закрытие по клику вне — тем же паттерном, что меню «Вид» (StageViewMenu):
// слушатель на document, пока поповер открыт.
useEffect(() => {
if (!open) return
function handlePointerDown(event: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [open])
return (
<div className="chat-emoji-wrap" ref={wrapRef}>
<button
type="button"
className={`chat-emoji-trigger${open ? ' is-open' : ''}`}
aria-label="Вставить эмодзи"
aria-expanded={open}
aria-haspopup="dialog"
disabled={disabled}
onClick={() => setOpen((v) => !v)}
>
<SmilePlus className="lucide" aria-hidden="true" />
</button>
{open && (
<EmojiPopover
onSelect={(emoji) => {
onSelect(emoji)
setOpen(false)
}}
onClose={() => setOpen(false)}
/>
)}
</div>
)
}
interface EmojiPopoverProps {
onSelect: (emoji: string) => void
onClose: () => void
}
/** Отдельный компонент — чтобы `useModalDismiss` (Escape + возврат фокуса) жил ровно то время, пока поповер открыт. */
function EmojiPopover({ onSelect, onClose }: EmojiPopoverProps) {
useModalDismiss(onClose)
return (
<div className="chat-emoji-popover" role="dialog" aria-label="Выбор эмодзи">
{EMOJI_OPTIONS.map((emoji) => (
<button key={emoji} type="button" className="chat-emoji-option" onClick={() => onSelect(emoji)}>
{emoji}
</button>
))}
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState, type ReactNode } from 'react' import { useEffect, useState, type ReactNode } from 'react'
import { EyeOff, Users } from 'lucide-react' import { EyeOff, Mic, MicOff, Users } from 'lucide-react'
import { Track, type Participant } from 'livekit-client' import { Track, type Participant } from 'livekit-client'
import { import {
CarouselLayout, CarouselLayout,
@@ -8,6 +8,7 @@ import {
isTrackReference, isTrackReference,
useRoomContext, useRoomContext,
useSpeakingParticipants, useSpeakingParticipants,
useTrackToggle,
useTracks, useTracks,
type TrackReferenceOrPlaceholder, type TrackReferenceOrPlaceholder,
} from '@livekit/components-react' } from '@livekit/components-react'
@@ -93,6 +94,29 @@ function hasLiveVideo(track: TrackReferenceOrPlaceholder): boolean {
return isTrackReference(track) && !track.publication.isMuted return isTrackReference(track) && !track.publication.isMuted
} }
/**
* Кнопка микрофона поверх единственной плитки мини-плеера (задача A5): в
* Document PiP своего тулбара нет вовсе, а звонок продолжается — без неё
* замьютить себя можно, только развернув основное окно. `useTrackToggle` —
* тот же хук, что и в `RoomToolbar`, читает/меняет состояние ЧЕРЕЗ
* `RoomContext`, а не через DOM, поэтому работает одинаково что в основном
* окне, что в портале, рендерящем эту сцену внутрь `pipWindow.document.body`
* (React-контекст порталом не разрывается, в отличие от DOM-дерева).
*/
function PipMicToggle() {
const mic = useTrackToggle({ source: Track.Source.Microphone })
return (
<button
type="button"
{...mic.buttonProps}
className={`room-pip-mic-toggle${mic.enabled ? '' : ' is-off'}`}
aria-label={mic.enabled ? 'Выключить микрофон' : 'Включить микрофон'}
>
{mic.enabled ? <Mic className="lucide" aria-hidden="true" /> : <MicOff className="lucide" aria-hidden="true" />}
</button>
)
}
/** /**
* Основная сцена конференции: превью остальных участников + крупная плитка * Основная сцена конференции: превью остальных участников + крупная плитка
* активного спикера (FocusLayoutContainer + CarouselLayout при нескольких * активного спикера (FocusLayoutContainer + CarouselLayout при нескольких
@@ -179,6 +203,7 @@ export function RoomStage({
onHideOthers, onHideOthers,
initialFocusKey = null, initialFocusKey = null,
onFocusKeyChange, onFocusKeyChange,
onPinFocus,
}: { }: {
variant?: 'full' | 'pip' variant?: 'full' | 'pip'
/** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */ /** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */
@@ -193,6 +218,14 @@ export function RoomStage({
initialFocusKey?: string | null initialFocusKey?: string | null
/** Сообщать наружу текущий фокус, чтобы его пережил переезд сцены в мини-плеер и обратно. */ /** Сообщать наружу текущий фокус, чтобы его пережил переезд сцены в мини-плеер и обратно. */
onFocusKeyChange?: (key: string | null) => void onFocusKeyChange?: (key: string | null) => void
/**
* Участника только что закрепили (не открепили) в режиме без крупной
* плитки — сцена сама переключиться не может (режим живёт в `RoomPage`),
* поэтому просит переключить на `standard`, где закреплённого есть куда
* показать крупно. Открепление НЕ вызывает колбэк — вид назад не
* переключаем (см. докстринг `RoomPage`, обоснование решения в коммите).
*/
onPinFocus?: () => void
}) { }) {
const room = useRoomContext() const room = useRoomContext()
const isCompact = useIsCompactViewport() const isCompact = useIsCompactViewport()
@@ -337,9 +370,16 @@ export function RoomStage({
* Закрепить/открепить плитку: повторное нажатие на уже закреплённой снимает * Закрепить/открепить плитку: повторное нажатие на уже закреплённой снимает
* закрепление. Ключ приходит из самой плитки (она знает свой трек из * закрепление. Ключ приходит из самой плитки (она знает свой трек из
* контекста — в карусели/гриде плитки рендерятся шаблоном, без пропсов). * контекста — в карусели/гриде плитки рендерятся шаблоном, без пропсов).
*
* Закрепление доступно из ЛЮБОГО режима показа (задача 3.1 → 3.3): в
* плиточных режимах крупной плитки нет, поэтому само закрепление (не
* открепление) просит `RoomPage` переключить вид на `standard` —
* закреплённого иначе негде показать крупно.
*/ */
function handleTogglePin(key: string) { function handleTogglePin(key: string) {
setPinnedKey((prev) => (prev === key ? null : key)) const willPin = pinnedKey !== key
setPinnedKey(willPin ? key : null)
if (willPin) onPinFocus?.()
} }
// Мини-плеер показывает ТОЛЬКО активное окно — без карусели/ // Мини-плеер показывает ТОЛЬКО активное окно — без карусели/
@@ -350,6 +390,7 @@ export function RoomStage({
return ( return (
<section className="stage room-single-tile"> <section className="stage room-single-tile">
{focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />} {focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />}
<PipMicToggle />
<RoomAudioRenderer /> <RoomAudioRenderer />
</section> </section>
) )
@@ -372,23 +413,23 @@ export function RoomStage({
: [] : []
: carouselTracks : carouselTracks
const showCarousel = !hideOthers && sideTracks.length > 0 const showCarousel = !hideOthers && sideTracks.length > 0
// Закрепление имеет смысл только там, где есть «крупная плитка» — // Булавка закрепления есть на КАЖДОЙ плитке во всех режимах (задача 3.3):
// в плиточных режимах фокуса нет, поэтому и булавки на плитках нет. // сама кнопка вызывает переключение на `standard`, где закреплённый и
const pinProps = // попадёт в фокус (см. `handleTogglePin`/`onPinFocus`).
effectiveMode === 'standard' ? { pinnedKey, onTogglePin: handleTogglePin } : {} const pinProps = { pinnedKey, onTogglePin: handleTogglePin }
function renderMain(): ReactNode { function renderMain(): ReactNode {
if (effectiveMode === 'tiles') { if (effectiveMode === 'tiles') {
return ( return (
<StageGrid tracks={tracks}> <StageGrid tracks={tracks}>
<RoomParticipantTile /> <RoomParticipantTile {...pinProps} />
</StageGrid> </StageGrid>
) )
} }
if (effectiveMode === 'live-tiles') { if (effectiveMode === 'live-tiles') {
return ( return (
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks}> <StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks}>
<RoomParticipantTile /> <RoomParticipantTile {...pinProps} />
</StageGrid> </StageGrid>
) )
} }

View File

@@ -4,7 +4,14 @@ import { useState } from 'react'
import { pluralizeParticipants } from '@/lib/pluralize' import { pluralizeParticipants } from '@/lib/pluralize'
interface RoomTopbarProps { interface RoomTopbarProps {
roomName: string /**
* Название конференции (`ConferenceOut.title`/`ResolveOut.title`) —
* `null`/не задано, если организатор его не указал. НЕ путать с
* `JoinOut.room_name` (техническое имя комнаты LiveKit вида `hC-Dmos9KEM`,
* которое сюда раньше и попадало по ошибке, см. задачу A6) — тот нужен
* только серверу и в UI показывать нечего.
*/
title: string | null
/** Slug/номер конференции из адреса — для инвайт-чипа (копирование ссылки). */ /** Slug/номер конференции из адреса — для инвайт-чипа (копирование ссылки). */
slug?: string slug?: string
number?: string number?: string
@@ -19,7 +26,7 @@ interface RoomTopbarProps {
* тёмных токенов темы `room` (см. `--color-room-tile*`), без новых * тёмных токенов темы `room` (см. `--color-room-tile*`), без новых
* цветов и форм. * цветов и форм.
*/ */
export function RoomTopbar({ roomName, slug, number }: RoomTopbarProps) { export function RoomTopbar({ title, slug, number }: RoomTopbarProps) {
const participants = useParticipants() const participants = useParticipants()
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
@@ -39,7 +46,7 @@ export function RoomTopbar({ roomName, slug, number }: RoomTopbarProps) {
return ( return (
<header className="room-topbar"> <header className="room-topbar">
<div className="room-title-block"> <div className="room-title-block">
<h1>{roomName}</h1> <h1>{title ?? 'Конференция без названия'}</h1>
<p> <p>
<span className="rec-dot" aria-hidden="true" /> <span className="rec-dot" aria-hidden="true" />
Конференция активна · <Users className="lucide" style={{ width: 14, height: 14 }} aria-hidden="true" />{' '} Конференция активна · <Users className="lucide" style={{ width: 14, height: 14 }} aria-hidden="true" />{' '}

View File

@@ -143,7 +143,7 @@ export function JoinPage() {
state: { state: {
livekitUrl: data.livekit_url, livekitUrl: data.livekit_url,
token: data.token, token: data.token,
roomName: data.room_name, title: resolved.title,
conferenceId: data.conference_id, conferenceId: data.conference_id,
chatEnabled: data.chat_enabled, chatEnabled: data.chat_enabled,
}, },

View File

@@ -40,7 +40,7 @@ export function LobbyPage() {
state: { state: {
livekitUrl: conference.join.livekit_url, livekitUrl: conference.join.livekit_url,
token: conference.join.token, token: conference.join.token,
roomName: conference.join.room_name, title: conference.title,
conferenceId: conference.join.conference_id, conferenceId: conference.join.conference_id,
chatEnabled: conference.join.chat_enabled, chatEnabled: conference.join.chat_enabled,
number: conference.number, number: conference.number,

View File

@@ -23,7 +23,8 @@ import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '
interface RoomJoinState { interface RoomJoinState {
livekitUrl: string livekitUrl: string
token: string token: string
roomName: string /** Название конференции (`ConferenceOut.title`/`ResolveOut.title`) — для топбара, см. `RoomTopbar` и задачу A6. */
title?: string | null
conferenceId?: string conferenceId?: string
/** Номер конференции — для инвайт-чипа в топбаре (см. RoomTopbar). Есть только сразу после создания. */ /** Номер конференции — для инвайт-чипа в топбаре (см. RoomTopbar). Есть только сразу после создания. */
number?: string number?: string
@@ -92,7 +93,7 @@ export function RoomPage() {
setJoinState({ setJoinState({
livekitUrl: result.livekit_url, livekitUrl: result.livekit_url,
token: result.token, token: result.token,
roomName: result.room_name, title: info.title,
conferenceId: result.conference_id, conferenceId: result.conference_id,
chatEnabled: result.chat_enabled, chatEnabled: result.chat_enabled,
}) })
@@ -168,6 +169,15 @@ export function RoomPage() {
saveStageLayoutMode(mode) saveStageLayoutMode(mode)
}, []) }, [])
// Закрепление участника из плиточного режима переключает вид на «Стандарт»
// (см. докстринг `RoomStage`, `onPinFocus`) — но это НЕ то же самое, что
// явный выбор пользователя: переключение не сохраняется в localStorage
// (в отличие от `handleLayoutModeChange`), иначе разовое закрепление в этой
// встрече поменяло бы устойчивое предпочтение режима для всех следующих.
const handlePinFocus = useCallback(() => {
setLayoutMode('standard')
}, [])
// Ключ трека, который сцена показывает крупно. Живёт ЗДЕСЬ, а не только // Ключ трека, который сцена показывает крупно. Живёт ЗДЕСЬ, а не только
// внутри `RoomStage`, потому что при открытии мини-плеера сцена // внутри `RoomStage`, потому что при открытии мини-плеера сцена
// размонтируется в основном окне и монтируется заново в PiP-окне (портал // размонтируется в основном окне и монтируется заново в PiP-окне (портал
@@ -187,6 +197,22 @@ export function RoomPage() {
const { userChoices } = usePersistentUserChoices() const { userChoices } = usePersistentUserChoices()
const roomOptions = useMemo<RoomOptions>( const roomOptions = useMemo<RoomOptions>(
() => ({ () => ({
// Оба флага в LiveKit по умолчанию выключены, и без них каждый клиент
// подписан на полное качество всех чужих треков независимо от размера
// плитки, а каждый паблишер шлёт все слои симулкаста, даже если их никто
// не смотрит. На тесте 28.07.2026 (19 участников, ~8 камер) это дало
// устойчивые 140169 Мбит/с исходящего трафика при пике 240, 662 события
// `remote bwe: channel congestion detected` и 146 переходов аллокатора
// STABLE → DEFICIENT — то есть видимый участникам лаг.
//
// adaptiveStream: подписка на слой по фактическому размеру плитки на
// экране + пауза треков, которые сейчас не отрисованы. Именно на нём
// начинает экономить уже написанный код: «скрыть остальных»
// (RoomStage) не рендерит карусель, а пагинация StageGrid рендерит
// только текущую страницу — неприаттаченные треки считаются невидимыми.
// dynacast: паблишер прекращает отдавать слои, на которые нет подписчиков.
adaptiveStream: true,
dynacast: true,
audioCaptureDefaults: { deviceId: userChoices.audioDeviceId || undefined }, audioCaptureDefaults: { deviceId: userChoices.audioDeviceId || undefined },
videoCaptureDefaults: { deviceId: userChoices.videoDeviceId || undefined }, videoCaptureDefaults: { deviceId: userChoices.videoDeviceId || undefined },
// Аудиовыход (колонки/наушники/bluetooth) — отдельный персист, не через // Аудиовыход (колонки/наушники/bluetooth) — отдельный персист, не через
@@ -224,13 +250,13 @@ export function RoomPage() {
serverUrl={joinState.livekitUrl} serverUrl={joinState.livekitUrl}
token={joinState.token} token={joinState.token}
connect connect
audio audio={false}
video video={false}
options={roomOptions} options={roomOptions}
onDisconnected={handleDisconnected} onDisconnected={handleDisconnected}
> >
<div data-lk-theme="default" className="room-shell"> <div data-lk-theme="default" className="room-shell">
<RoomTopbar roomName={joinState.roomName} slug={slug} number={joinState.number} /> <RoomTopbar title={joinState.title ?? null} slug={slug} number={joinState.number} />
<div className="room-main"> <div className="room-main">
{pip.mode === 'document' ? ( {pip.mode === 'document' ? (
// Сцена сейчас рисуется в PiP-окне (через createPortal ниже) — // Сцена сейчас рисуется в PiP-окне (через createPortal ниже) —
@@ -250,6 +276,7 @@ export function RoomPage() {
onHideOthers={() => setHideOthers(true)} onHideOthers={() => setHideOthers(true)}
initialFocusKey={stageFocusKey} initialFocusKey={stageFocusKey}
onFocusKeyChange={setStageFocusKey} onFocusKeyChange={setStageFocusKey}
onPinFocus={handlePinFocus}
/> />
)} )}
{chatVisible && chatOpen && ( {chatVisible && chatOpen && (

View File

@@ -133,9 +133,32 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
* `.stage-tiles` в основном окне) плюс подстраховочный `min-height`, чтобы * `.stage-tiles` в основном окне) плюс подстраховочный `min-height`, чтобы
* плитка не схлопывалась, если видео ещё не выдало реальные размеры. * плитка не схлопывалась, если видео ещё не выдало реальные размеры.
*/ */
.room-single-tile { flex: 1; min-height: 0; display: flex; } .room-single-tile { position: relative; flex: 1; min-height: 0; display: flex; }
.room-single-tile .lk-participant-tile { flex: 1; min-height: 220px; width: 100%; } .room-single-tile .lk-participant-tile { flex: 1; min-height: 220px; width: 100%; }
/* Кнопка микрофона мини-плеера (задача A5) — своего тулбара в PiP нет,
кнопка лежит поверх единственной плитки левым нижним углом (правый занят
штатным `.lk-focus-toggle-button`/нашей булавкой закрепления — тут её нет,
в PiP закреплять нечего, но геометрия зеркалит `.room-pin-toggle`). */
.room-pip-mic-toggle {
position: absolute;
left: 0.5rem;
bottom: 0.5rem;
z-index: 5;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-room-mic-on);
color: #10331f;
cursor: pointer;
}
.room-pip-mic-toggle svg { width: 18px; height: 18px; }
.room-pip-mic-toggle.is-off { background: var(--color-room-danger-bg); color: #fff; }
/* /*
* ---------- Колонка миниатюр (кнопка «Скрыть» + карусель) ---------- * ---------- Колонка миниатюр (кнопка «Скрыть» + карусель) ----------
* Прямой ребёнок `.lk-focus-layout` — вместо самой карусели, потому что над * Прямой ребёнок `.lk-focus-layout` — вместо самой карусели, потому что над
@@ -497,6 +520,49 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
} }
.chat-input-row button:disabled { opacity: 0.5; cursor: default; } .chat-input-row button:disabled { opacity: 0.5; cursor: default; }
.chat-emoji-wrap { position: relative; display: flex; flex-shrink: 0; }
.chat-emoji-trigger {
width: 42px;
height: 42px;
border-radius: 50%;
background: var(--color-room-tile);
border: 1px solid var(--color-room-tile-border);
color: var(--color-room-text-secondary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.chat-emoji-trigger:hover { color: var(--color-room-text-primary); }
.chat-emoji-trigger.is-open { color: var(--color-room-mic-on); border-color: var(--color-room-speaker-ring); }
.chat-emoji-trigger:disabled { opacity: 0.5; cursor: default; }
.chat-emoji-popover {
position: absolute;
bottom: calc(100% + var(--space-2));
left: 0;
z-index: 50;
width: 224px;
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 2px;
padding: var(--space-3);
border-radius: var(--radius-lg);
border: 1px solid var(--color-room-tile-border);
background: var(--color-room-surface-raised);
box-shadow: var(--shadow-room-panel);
}
.chat-emoji-option {
background: none;
border: none;
font-size: 20px;
line-height: 1;
padding: 6px;
border-radius: var(--radius-md);
cursor: pointer;
}
.chat-emoji-option:hover { background: var(--color-room-tile); }
@media (max-width: 900px) { @media (max-width: 900px) {
.chat-panel { .chat-panel {
position: fixed; position: fixed;

View File

@@ -526,13 +526,14 @@ docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --wait postgres r
# exist" и healthcheck (--wait) никогда не проходит. `compose run` запускает # exist" и healthcheck (--wait) никогда не проходит. `compose run` запускает
# одноразовый контейнер с нужной командой, не поднимая uvicorn/lifespan. # одноразовый контейнер с нужной командой, не поднимая uvicorn/lifespan.
echo "[install] Применяю миграции Alembic и seed (админ/справочники) — до старта backend" echo "[install] Применяю миграции Alembic и seed (админ/справочники) — до старта backend"
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm backend uv run alembic upgrade head # `--no-sync`: окружение собрано в образе, повторная синхронизация в рантайме
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm backend uv run python -m scripts.seed # только тянула бы dev-группу (см. комментарий в backend/Dockerfile).
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm backend uv run --no-sync alembic upgrade head
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm backend uv run --no-sync python -m scripts.seed
echo "[install] docker compose up -d --wait (backend/worker/nginx и остальные сервисы профиля)" echo "[install] docker compose up -d --wait (backend/worker/nginx и остальные сервисы профиля)"
# Первый старт backend/worker включает `uv run` (синхронизация окружения + # На слабой/загруженной машине healthcheck может не успеть пройти за отведённые
# компиляция байткода) — на слабой/загруженной машине healthcheck может не # retries, и `--wait` вернёт "container is
# успеть пройти за отведённые retries, и `--wait` вернёт "container is
# unhealthy", хотя сервис через несколько секунд становится healthy. Команда # unhealthy", хотя сервис через несколько секунд становится healthy. Команда
# идемпотентна, поэтому повторяем её несколько раз: повтор лишь дожидается # идемпотентна, поэтому повторяем её несколько раз: повтор лишь дожидается
# уже стартующих контейнеров, ничего не пересоздавая. # уже стартующих контейнеров, ничего не пересоздавая.
@@ -573,7 +574,7 @@ if [ "$FRESH_ENV" != "1" ]; then
fi fi
if [ "$APPLY_PRESET_SETTINGS" = "1" ]; then if [ "$APPLY_PRESET_SETTINGS" = "1" ]; then
echo "[install] Применяю настройки модулей инстанса под пресет ${PRESET}" echo "[install] Применяю настройки модулей инстанса под пресет ${PRESET}"
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" exec -T backend uv run python -m scripts.apply_preset_settings --force docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" exec -T backend uv run --no-sync python -m scripts.apply_preset_settings --force
else else
echo "[install] Настройки модулей инстанса НЕ изменены — сохранены ручные правки администратора" echo "[install] Настройки модулей инстанса НЕ изменены — сохранены ручные правки администратора"
fi fi