node-exporter переведён в host-сеть (иначе отдавал сетевые метрики
контейнера вместо серверных), и порт 9100 теперь слушается на хосте —
а значит Prometheus из docker-сети упирается в политику ufw по умолчанию.
Без этого правила таргет `node` остаётся down с `context deadline exceeded`.
Правило узкое: только из внутренних docker-подсетей (172.16.0.0/12 не
маршрутизируется в интернете) и только на 9100. Снаружи порт закрыт.
Инсталлятор firewall не настраивает — это ручной шаг документации,
поэтому строка добавлена именно сюда, иначе на новой инсталляции
мониторинг хоста молча останется без сетевых метрик.
`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 не завязаны.
В `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`.
Пул создавался с дефолтом SQLAlchemy (5 + 10) и под нагрузкой выгребался
за секунды. Теперь параметры заданы явно и вынесены в настройки:
DB_POOL_SIZE=10, DB_MAX_OVERFLOW=10, DB_POOL_TIMEOUT=10. Таймаут снижен с
дефолтных 30 секунд намеренно — пусть запрос падает быстро и показывает
проблему, а не висит полминуты.
Backend запускался одним процессом uvicorn: любой блокирующий вызов
останавливал и параллельные запросы, и WS-чат всех участников. Добавлен
UVICORN_WORKERS с дефолтом 2 — не по числу ядер, потому что на
четырёхъядерном сервере ядра делятся с LiveKit, а медиа важнее API.
Бюджет соединений считается на весь инстанс: каждый воркер держит свой
пул, поэтому UVICORN_WORKERS × (DB_POOL_SIZE + DB_MAX_OVERFLOW) должно
оставаться заметно ниже max_connections у Postgres.
Многопроцессность безопасна: бутстрап настроек в lifespan идемпотентен
(INSERT ... ON CONFLICT DO NOTHING), а WS-чат разносит сообщения через
Redis pub/sub и состояния в памяти процесса не держит.
Обработчик `track_published` вызывал `start_track_egress` внутри своей
транзакции. На инстансе без профиля `transcribe` egress-сервиса нет, и
LiveKit ждал ответа воркера через Redis до собственного таймаута psrpc —
20–25 секунд на каждый микрофонный трек. Всё это время webhook удерживал
соединение с БД и открытую транзакцию.
На нагрузочном тесте с 19 участниками (28.07.2026) это дало 226 ошибок
`QueuePool limit of size 5 overflow 10 reached` и 37 ответов 500 на путях
входа в конференцию, а со стороны LiveKit — 33 дропнутых webhook при
очереди доставки до 56 секунд.
Что изменилось:
- запуск ушёл в фоновую задачу `run_track_egress` со своей сессией БД;
обработчик только планирует её и отвечает 200 сразу;
- добавлен ранний выход по `transcriber.enabled` — симметрично guard'у,
который уже был в `room_finished`;
- запуск ограничен таймаутом `egress_start_timeout_s` (по умолчанию 3 с).
Идемпотентность сохранена: проверка «трек уже пишется» осталась в
обработчике, а `AudioTrackRepository.create` — это INSERT ... ON CONFLICT
DO NOTHING.
Попутно: `test_room_finished_enqueues_pipeline` падал в зависимости от
того, что осталось в локальной БД, — теперь выставляет `transcriber`
явно, как и остальные тесты этой группы.
В Chrome мини-плеер показывал самого пользователя вместо того, что он видел
крупно. Сцена в мини-окне — отдельный экземпляр RoomStage (портал в PiP-окно),
и состояние фокуса он начинал с нуля: демонстрации нет, никто прямо сейчас не
говорит — pickStageFocus доходил до последнего фолбэка localKey, то есть до
«показать себя». В Safari бага не было видно: там Document PiP не
используется, а video-фолбэк берёт <video> прямо из фокус-плитки основного
окна.
Ключ фокуса теперь передаётся наружу (onFocusKeyChange), живёт в состоянии
RoomPage и достаётся следующему экземпляру сцены (initialFocusKey). Работает
в обе стороны — возврат из мини-плеера тоже не сбрасывает фокус. Правила
выбора фокуса (pickStageFocus) не тронуты.
Раскладка сцены больше не выбирается автоматически — пользователь выбирает
один из трёх режимов:
- «Стандарт» — как раньше: крупная плитка плюс карусель остальных сбоку,
кого показать крупно, по-прежнему решает pickStageFocus;
- «Плитки» — все участники равными плитками, без выделенного крупного;
- «Живые плитки» — сетка только из тех, у кого включена камера, остальные
в карусели сбоку; если камеру не включил никто, сетка была бы пустой —
режим вырождается в «Плитки».
Демонстрация экрана перебивает выбранный режим: пока в комнате есть активный
шэр, сцена ведёт себя как «Стандарт» (смысл плиточных режимов — равноправие
участников, а демонстрация неравноправна по определению). Режим при этом
живёт в состоянии RoomPage, поэтому по завершении шэра вид сам возвращается
к выбранному.
Скрытие остальных убирает карусель, основная область занимает сцену целиком.
Скрыть можно из меню «Вид», из шторки настроек на мобильном и кнопкой прямо
над колонкой миниатюр; вернуть — кнопкой «Показать остальных (N)» на сцене,
которая видна всё время, пока кто-то скрыт. В режиме «Плитки» скрывать
нечего, переключатель там заблокирован с пояснением.
Переключатель на широком экране — кнопка «Вид» с поповером; на мобильном её
нет (тулбар там и так ужат до пяти кнопок), те же настройки идут секцией в
шторке. Видимость решается условным рендерингом в React, а не новым
CSS-правилом поверх медиазапроса — в room.css за это уже была битва
специфичности.
Режим сохраняется между заходами в комнату (localStorage, своим модулем —
LocalUserChoices у LiveKit фиксированной структуры, поля под раскладку там
нет). Скрытие не сохраняется: разовое действие по ходу разговора, войти в
новую конференцию без половины участников — сюрприз, а не удобство.
Сетка собрана своим StageGrid поверх тех же публичных хуков, что использует
GridLayout: библиотечный компонент не пробрасывает gridLayouts, а её набор
раскладок требует 560px уже для 2x2 — на телефоне это две плитки на страницу.
Свой набор даёт 2x2 от 360px и портретную 2x3. Индикатор страниц тоже свой:
PaginationControl/PaginationIndicator из пакета не экспортируются.
Размер аватара при выключенной камере считался как width/height в процентах
от РАЗНЫХ сторон плитки, а круглым `.avatar` делает только border-radius —
на неквадратной плитке (то есть почти всегда) получался эллипс. Плюс
max-*: 96px не давал иконке вырасти на большой плитке фокуса.
Размер теперь берётся от узкой стороны плитки через container-запросы:
контейнером объявлен .lk-participant-placeholder (он position: absolute с
inset: 0, то есть повторяет плитку и уже имеет обе размерности), аватар —
width в cqmin с aspect-ratio: 1. Крупная плитка — 50cqmin, миниатюры
карусели — во всю узкую сторону минус 5px с каждого края.
Контейнер именно на placeholder-обёртке, а не на самой плитке: size
containment на плитке отрезал бы её содержимое (видео, метаданные) от
влияния на авторазмер.
Новый раздел про container-exporter/docker-socket-proxy: зачем свой
экспортер вместо cAdvisor, какие метрики отдаёт, почему доступ к
докер-сокету через прокси безопасен. Дополнена таблица метрик backend
(vidconf_host_info).
Info-метрика (значение всегда 1, лейблы cpus/ram_mb/gpu_name/vram_mb) —
источник GPU для новой панели «Характеристики сервера» в дашборде «Хост
и контейнеры» (живые CPU/RAM/диск там же берутся из node-exporter,
GPU node-exporter не знает). Данные — уже читаемые Settings.hw_* из .env,
которые install.sh пишет по ADR-004. «—» вместо None/пустой строки —
однозначный прочерк на панели вместо пустого текста.
Разбивка по контейнерам (CPU/память/сеть) — то, что не смог дать
cAdvisor из-за несовместимости с containerd-снапшоттером сервера 1gb
(убран в 7f5c888). Свой минимальный сервис container-exporter (Python/
aiohttp/prometheus_client) опрашивает Docker Engine API параллельно в
фоновой задаче, не завязываясь на scrape-интервал Prometheus.
Доступ к докер-сокету изолирован через docker-socket-proxy: экспортеру
разрешены только GET /containers/json и /containers/*/stats, любые
изменяющие запросы блокируются на уровне прокси (POST=0) — полная
компрометация экспортера не даёт управлять Docker. Ни один из двух
сервисов не публикует портов наружу.
В host.json возвращены панели «Топ контейнеров по CPU/памяти» на новых
метриках vidconf_container_* (в 0.0.9 их убрали вместе с cAdvisor).
На сервере 1gb Docker Engine использует containerd-снапшоттер
(driver-type: io.containerd.snapshotter.v1), а не классический overlay2.
cAdvisor (проверено на v0.49.2 и свежей v0.52.1, с --docker_only и через
--containerd/--containerd-namespace=moby) не может определить read-write
layer контейнеров — падает с «failed to identify the read-write layer
ID», метрики только по корневому cgroup, без разбивки по контейнерам.
Открытая проблема совместимости, флагами не решается.
Убран сервис cadvisor, job в prometheus.yml, панели «топ контейнеров» в
host.json (Prometheus иначе резолвит cadvisor:8080 в никуда — Grafana
показывала бы «No data» вечно). node-exporter метрики хоста (CPU/RAM/
диск/сеть) при этом покрывает полностью, без изменений.
Правка конфигурации мониторинга, без изменения пользовательского
поведения — версия не бампается.
Документация мониторинга описывала только старые компоненты (prometheus,
postgres/redis-exporter, дашборд пайплайнов) — актуализирована под
node-exporter/cAdvisor, дашборд host.json и три новых алерта.
Конференции открывались на «Все» — неинформативная сборная вкладка вместо
актуальных «Запланированные». Заодно перенесена вкладка «Все» в конец
списка фильтров (после «Завершённые»), чтобы порядок шёл от актуального
к общему.
Пользователи открывались на «Все» вместо «Активные» — админ по умолчанию
видел вперемешку с заблокированными.
Дашборд «Пайплайны пост-обработки» покрывал только прикладную логику —
нехватка памяти/CPU на сервере была видна только косвенно, по латентности API.
Добавлены node-exporter (метрики хоста) и cAdvisor (метрики по контейнерам,
профиль monitoring) — оба без публикации портов наружу, Prometheus ходит
к ним по внутренней сети compose. Новый дашборд host.json («Хост и
контейнеры») и три алерта (HostMemoryLow/HostDiskLow/HostCpuHigh) с
порогами под сервер 1gb (8 ГБ RAM, 4 CPU, 50 ГБ диска).
AdminUserRepository.list_paginated ищет теперь и по названию команды
(LEFT JOIN teams, как и раньше) — пользователи без команды по-прежнему
не пропадают из общей выдачи, просто не совпадают с этой частью поиска.
Плейсхолдер поля поиска в админке обновлён под новое поведение.
Задача 3 переопределена оператором: вместо окончательного удаления
пользователя (упёрлось в CHECK-constraint'ы participant/chat_messages,
требующие миграции схемы — решили отложить) добавлены вкладки
«Активные»/«Заблокированные»/«Все» перед полем поиска в админке —
список фильтруется по `is_blocked` на бэкенде (GET /admin/users?status=).
Настройка «Эталон mail-домена» теперь хранит список доменов вместо
одного — email при регистрации принимается, если совпадает с любым из
них. Старое значение в БД ({"domain": str|None}) читается прозрачно
(обратная совместимость без Alembic-миграции) и переписывается в новую
форму ({"domains": [...]}) при первом же сохранении настроек. В админке
добавление/удаление доменов — списком чипов; на экране регистрации
подсказка о несовпадении домена перечисляет все эталонные варианты.
Валидация ai_level срабатывала на любой PUT /admin/settings, включая
случаи, когда фронт отправлял уже сохранённый (не изменённый) уровень —
на слабом железе это намертво блокировало правку любых других настроек.
Бэкенд теперь сравнивает patch.ai_level с текущим значением и проверяет
доступность только при фактической смене уровня; фронт дополнительно
отправляет в PUT только реально изменённые поля.
На ширине ≤600px (брейкпоинт комнаты) DeviceSettingsDialog рендерит те же
поля не модалкой по центру, а шторкой снизу (`room-sheet-overlay`/
`room-sheet-panel`) — по образцу решения оператора: так быстрее и привычнее
с телефона. Десктоп не меняется.
Закрытие — клик вне панели (было и раньше), Escape и возврат фокуса через
существующий useModalDismiss, и свайп вниз за ручку (JS считает смещение
пальца и либо закрывает панель, либо возвращает её на место пружиной).
Третий селект в DeviceSettingsDialog (динамики/наушники/bluetooth) рядом
с уже существующими микрофоном и камерой — на useMediaDeviceSelect(kind:
'audiooutput'). Выбор персистится отдельно от usePersistentUserChoices
(LocalUserChoices LiveKit про аудиовыход не знает) и применяется заново
при следующем подключении через RoomOptions.audioOutput.
Поддержка определяется по наличию setSinkId в рантайме, не по User-Agent:
там, где браузер не умеет переключать вывод (iOS Safari), список скрыт,
вместо него — подсказка, что маршрутизация звука на стороне системы.
Копирайт целиком переехал в общий футер (решение оператора), в панели
строка больше не нужна. У `.brand-panel` осталось два потомка вместо трёх,
поэтому `.brand-copy` получил `margin-block: auto` — иначе
`justify-content: space-between` прижал бы текст к низу панели.
В 0.0.4 «г.» убрали из подписи периода, но дату дня FullCalendar
подставляет ещё и в служебную подпись ячейки (`aria-label`, у navLinks —
`title`): видна как всплывающая подсказка и в accessibility-дереве.
Формат этой подписи библиотека наружу не отдаёт — `buildNavLinkAttrs`
форматирует дату жёстко зашитым `{year,month:'long',day:'numeric'}`, а
локаль `ru` всегда даёт «20 июля 2026 г.», — поэтому чистим уже
отрендеренный атрибут.
Чистка идёт через MutationObserver, а не через `dayCellDidMount`/
`dayHeaderDidMount`: при переключении недельного вида на месячный
FullCalendar переиспользует смонтированные ячейки, `didMount` для них
повторно не зовётся, и «г.» возвращалась в строке переиспользованных ячеек
(поймано на живой проверке).
Проверено в обоих видах и при навигации вперёд/назад/«Сегодня»: подписей с
«г.» в документе не остаётся.
Правила выбора фокуса сцены (`pickStageFocus`) дополнены двумя входами:
- `pinnedKey` — участник, закреплённый булавкой на плитке. Держит фокус
вопреки говорящим, но уступает любой активной демонстрации экрана; как
только демонстрация закончилась, фокус возвращается именно на
закреплённого (правило стоит выше удержания предыдущего фокуса).
Состояние — в `RoomStage`, повторное нажатие снимает, выход закреплённого
из комнаты тоже. Закреплённая плитка помечена рамкой и подсвеченной
булавкой; на тач-устройствах булавка видна без наведения.
- `holdScreenShare` — живая демонстрация в фокусе не уступает заговорившему
(основное окно). PiP не затронут: там по-прежнему всегда виден говорящий.
Основное окно теперь следует за говорящим (`followSpeaker`) поверх
`useSpeakingParticipants()` вместо дребезжащего `participant.isSpeaking`, с
удержанием состава в 1.2 с (`useSteadySpeakers`) — короткие реплики фокус не
уводят. Среди одновременно говорящих предпочитается тот, у кого включена
камера (`cameraKeysWithVideo`).
`stageTrackKey` переехал в `stageFocus.ts` — ключ плитки нужен и сцене, и
самой плитке (в карусели/гриде она рендерится шаблоном, без пропсов).
Поле включения/адреса в карточке «Контактный адрес» и кнопка «Отправить
тестовое письмо» с выводом результата — рядом, по образцу соседних
настроек AdminSettingsTab.
Покрытие валидации/сохранения contact_email, простановки Reply-To в
письмах регистрации/приглашений/саммари при включённом и выключенном
контактном адресе, и эндпоинта тестовой отправки (успех, дефолтный
получатель, сбой транспорта без утечки логина/пароля).
Новая настройка instance_settings.contact_email (включён/адрес, с
валидацией формата) — подставляется в заголовок Reply-To писем
подтверждения регистрации, приглашений и саммари. Админ-эндпоинт
POST /admin/settings/test-email отправляет проверочное письмо синхронно
и возвращает внятный результат (успех либо текст ошибки транспорта),
не раскрывая логин/пароль SMTP.
ConferenceOccurrenceDialog расширен под общий информационный диалог (клик по
чипу — и для чужих, и для своих конференций): статус-бейдж (общая логика
occurrenceBadge вынесена в lib/occurrenceStatus.ts, переиспользуется с
ConferenceCalendar), кнопка «Редактировать», открывающая модалку формы —
только когда передан ownedConference (текущий пользователь — организатор).
Плюс Escape и возврат фокуса (useModalDismiss).
В renderEventContent убраны бейдж статуса и время (переехали в
ConferenceOccurrenceDialog) — остались название с обрезкой по многоточию
(min-width:0/overflow:hidden по цепочке флекс-контейнеров, чинит вылезание
текста за границы ячейки) и, у владельца конференции, карандашик
редактирования (myConferenceById/onEditClick, stopPropagation, чтобы не
срабатывал клик по чипу). Клик по чипу — всегда информационное окно, включая
свои конференции.
titleFormat для dayGridWeek/dayGridMonth построен на Intl.DateTimeFormat
вместо ruLocale-форматтера — тот всегда добавляет «г.» после года.
Раньше ConferenceFormCard всегда висела в сайдбаре двухколоночной сетки
.layout-grid; теперь открывается в модалке (по образцу
ConferenceOccurrenceDialog: modal-overlay/modal-panel, закрытие по клику на
оверлей, Escape и возврат фокуса — новый общий хук useModalDismiss).
.layout-grid стала одноколоночной — календарь занимает всю ширину.
Правки для узких экранов стояли в середине room.css, а `.tb-btn`/
`.room-toolbar` объявлены ниже — при равной специфичности побеждает порядок
в исходнике, поэтому тулбар на мобильном не менялся вовсе (скрылся только
инвайт-чип, объявленный выше блока). Блок перенесён в конец файла.
В топбаре теперь скрываются название конференции и статус (слева), а чип
«№ … / Пригласить» остаётся — с телефона он нужнее (скопировать ссылку и
позвать участника). Тулбару добавлены flex-wrap и min-width: 0, чтобы он не
вылезал за ширину окна ни при каких подписях кнопок.
.btn держит white-space: nowrap — на остальных кнопках экрана join текст
короткий, а на экране «конференция завершена» кнопка не обёрнута в форму
(в отличие от Продолжить/Войти) и не получает от неё ширину, поэтому
длинная фраза без переноса выталкивала кнопку за правый край карточки на
узких экранах.
На мобильном в таблице остаются только колонки «Конференция» и
«Владелец» — название конференции с номером стало кнопкой, открывающей
то же окно редактирования, что и карандаш в скрытой на мобильном колонке
действий. Туда же добавлена кнопка «Удалить» (с подтверждением) — раньше
удаление было доступно только из строки таблицы, которая на мобильном
теперь скрыта.
На мобильном в таблице остаётся только колонка «Пользователь» — роль,
команда, регистрация, статус и блокировка уже редактируются в карточке
профиля, которая открывается кликом по имени (AdminUserProfileDialog).
.page-head h1 (общий заголовок оболочки) — тот же баг с фиксированным
кеглем 34px, что и в auth.css, чинится тем же способом (clamp), лечит заодно
любую другую длинную шапку на страницах оболочки. В админке дополнительно:
поясняющий текст под заголовком скрыт на мобильном (не несёт функции), 4
вкладки переносятся в 2 ряда вместо горизонтального оверфлоу всей страницы.
room-toolbar с 8 кнопками (520px) не помещался в мобильную ширину (375px) —
крайние кнопки обрезались без возможности до них доскроллить. На мобильном
убираем демонстрацию экрана/полноэкранный режим/мини-окно (редко нужны с
телефона) и уплотняем оставшиеся 5 кнопок. В топбаре убираем инвайт-чип
(номер + копирование ссылки) — сверху остаются только название конференции
и статус.
font: var(--text-display-lg) даёт фиксированные 34px — слово не помещалось
в брендовую панель на узких экранах (375px), а overflow:hidden панели
обрезал его вместо переноса. font-size: clamp() уменьшает кегль на узких
вьюпортах вместо обрезки.