47 Commits

Author SHA1 Message Date
7549b53ec9 release: версия 0.0.12
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-28 19:00:09 +03:00
6d65b620fe perf(backend): явный пул соединений БД и несколько воркеров uvicorn
Пул создавался с дефолтом 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 и состояния в памяти процесса не держит.
2026-07-28 19:00:05 +03:00
32949ebc66 fix(webhook): запуск egress не блокирует транзакцию track_published
Обработчик `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`
явно, как и остальные тесты этой группы.
2026-07-28 18:59:53 +03:00
b8220f682d release: версия 0.0.11
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-28 04:26:02 +03:00
29a3e78836 fix(room): мини-окно открывается на плитке из основного окна
В Chrome мини-плеер показывал самого пользователя вместо того, что он видел
крупно. Сцена в мини-окне — отдельный экземпляр RoomStage (портал в PiP-окно),
и состояние фокуса он начинал с нуля: демонстрации нет, никто прямо сейчас не
говорит — pickStageFocus доходил до последнего фолбэка localKey, то есть до
«показать себя». В Safari бага не было видно: там Document PiP не
используется, а video-фолбэк берёт <video> прямо из фокус-плитки основного
окна.

Ключ фокуса теперь передаётся наружу (onFocusKeyChange), живёт в состоянии
RoomPage и достаётся следующему экземпляру сцены (initialFocusKey). Работает
в обе стороны — возврат из мини-плеера тоже не сбрасывает фокус. Правила
выбора фокуса (pickStageFocus) не тронуты.
2026-07-28 04:25:10 +03:00
3fb3a5d42c feat(room): выбор режима показа участников и скрытие остальных
Раскладка сцены больше не выбирается автоматически — пользователь выбирает
один из трёх режимов:
  - «Стандарт» — как раньше: крупная плитка плюс карусель остальных сбоку,
    кого показать крупно, по-прежнему решает pickStageFocus;
  - «Плитки» — все участники равными плитками, без выделенного крупного;
  - «Живые плитки» — сетка только из тех, у кого включена камера, остальные
    в карусели сбоку; если камеру не включил никто, сетка была бы пустой —
    режим вырождается в «Плитки».

Демонстрация экрана перебивает выбранный режим: пока в комнате есть активный
шэр, сцена ведёт себя как «Стандарт» (смысл плиточных режимов — равноправие
участников, а демонстрация неравноправна по определению). Режим при этом
живёт в состоянии RoomPage, поэтому по завершении шэра вид сам возвращается
к выбранному.

Скрытие остальных убирает карусель, основная область занимает сцену целиком.
Скрыть можно из меню «Вид», из шторки настроек на мобильном и кнопкой прямо
над колонкой миниатюр; вернуть — кнопкой «Показать остальных (N)» на сцене,
которая видна всё время, пока кто-то скрыт. В режиме «Плитки» скрывать
нечего, переключатель там заблокирован с пояснением.

Переключатель на широком экране — кнопка «Вид» с поповером; на мобильном её
нет (тулбар там и так ужат до пяти кнопок), те же настройки идут секцией в
шторке. Видимость решается условным рендерингом в React, а не новым
CSS-правилом поверх медиазапроса — в room.css за это уже была битва
специфичности.

Режим сохраняется между заходами в комнату (localStorage, своим модулем —
LocalUserChoices у LiveKit фиксированной структуры, поля под раскладку там
нет). Скрытие не сохраняется: разовое действие по ходу разговора, войти в
новую конференцию без половины участников — сюрприз, а не удобство.

Сетка собрана своим StageGrid поверх тех же публичных хуков, что использует
GridLayout: библиотечный компонент не пробрасывает gridLayouts, а её набор
раскладок требует 560px уже для 2x2 — на телефоне это две плитки на страницу.
Свой набор даёт 2x2 от 360px и портретную 2x3. Индикатор страниц тоже свой:
PaginationControl/PaginationIndicator из пакета не экспортируются.
2026-07-28 04:24:49 +03:00
975763a3a6 fix(room): круглая иконка участника вместо эллипса
Размер аватара при выключенной камере считался как 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 на плитке отрезал бы её содержимое (видео, метаданные) от
влияния на авторазмер.
2026-07-28 04:23:10 +03:00
c5b241ae02 release: версия 0.0.10
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-28 01:43:24 +03:00
356bc57a2a docs(monitoring): описать контейнерный экспортер и модель безопасности
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Новый раздел про container-exporter/docker-socket-proxy: зачем свой
экспортер вместо cAdvisor, какие метрики отдаёт, почему доступ к
докер-сокету через прокси безопасен. Дополнена таблица метрик backend
(vidconf_host_info).
2026-07-28 01:40:04 +03:00
afd4daee65 feat(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/пустой строки —
однозначный прочерк на панели вместо пустого текста.
2026-07-28 01:39:58 +03:00
ace5bf7a0d feat(monitoring): экспортер метрик по контейнерам поверх Docker Engine API
Разбивка по контейнерам (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).
2026-07-28 01:39:42 +03:00
7f5c88869a fix(monitoring): убрать cAdvisor — несовместим с containerd-снапшоттером
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
На сервере 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/
диск/сеть) при этом покрывает полностью, без изменений.

Правка конфигурации мониторинга, без изменения пользовательского
поведения — версия не бампается.
2026-07-28 00:42:25 +03:00
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
35a1455645 refactor(auth): убрать строку «self-hosted» из брендовой панели
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Копирайт целиком переехал в общий футер (решение оператора), в панели
строка больше не нужна. У `.brand-panel` осталось два потомка вместо трёх,
поэтому `.brand-copy` получил `margin-block: auto` — иначе
`justify-content: space-between` прижал бы текст к низу панели.
2026-07-27 01:53:27 +03:00
cfb3a941ab release: версия 0.0.6
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-27 01:34:03 +03:00
f3556d7d89 fix(calendar): убрать «г.» из подписей дней в сетке
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
В 0.0.4 «г.» убрали из подписи периода, но дату дня FullCalendar
подставляет ещё и в служебную подпись ячейки (`aria-label`, у navLinks —
`title`): видна как всплывающая подсказка и в accessibility-дереве.
Формат этой подписи библиотека наружу не отдаёт — `buildNavLinkAttrs`
форматирует дату жёстко зашитым `{year,month:'long',day:'numeric'}`, а
локаль `ru` всегда даёт «20 июля 2026 г.», — поэтому чистим уже
отрендеренный атрибут.

Чистка идёт через MutationObserver, а не через `dayCellDidMount`/
`dayHeaderDidMount`: при переключении недельного вида на месячный
FullCalendar переиспользует смонтированные ячейки, `didMount` для них
повторно не зовётся, и «г.» возвращалась в строке переиспользованных ячеек
(поймано на живой проверке).

Проверено в обоих видах и при навигации вперёд/назад/«Сегодня»: подписей с
«г.» в документе не остаётся.
2026-07-27 01:32:02 +03:00
b6f57384b0 feat(ui): общий футер на всех страницах, кроме комнаты
`AppFooter` — версия инстанса из публичного `/api/health` (не требует
авторизации, поэтому работает и на экранах входа; поля `version` может не
быть — тогда версия не показывается) плюс копирайт с ТЕКУЩИМ годом и
ссылкой на профиль автора.

Подключён во все раскладки: `page-shell` (календарь, админка, профиль, мои
конференции), `AuthLayout` (вход, регистрация, подтверждение email),
`join-shell` и `lobby-shell`. В комнате конференции футера нет — там
полноэкранное видео.

Прежний футер админки (`.admin-footer`) удалён вместе со стилем — дубля не
осталось. В брендовой панели экранов auth строка «© 2026 VidConf ·
self-hosted» ужата до «self-hosted»: год там был захардкожен, а два
копирайта на одном экране — дубль.
2026-07-27 01:31:52 +03:00
5daff3b7af feat(room): закрепление участника и слежение основного окна за говорящим
Правила выбора фокуса сцены (`pickStageFocus`) дополнены двумя входами:

- `pinnedKey` — участник, закреплённый булавкой на плитке. Держит фокус
  вопреки говорящим, но уступает любой активной демонстрации экрана; как
  только демонстрация закончилась, фокус возвращается именно на
  закреплённого (правило стоит выше удержания предыдущего фокуса).
  Состояние — в `RoomStage`, повторное нажатие снимает, выход закреплённого
  из комнаты тоже. Закреплённая плитка помечена рамкой и подсвеченной
  булавкой; на тач-устройствах булавка видна без наведения.
- `holdScreenShare` — живая демонстрация в фокусе не уступает заговорившему
  (основное окно). PiP не затронут: там по-прежнему всегда виден говорящий.

Основное окно теперь следует за говорящим (`followSpeaker`) поверх
`useSpeakingParticipants()` вместо дребезжащего `participant.isSpeaking`, с
удержанием состава в 1.2 с (`useSteadySpeakers`) — короткие реплики фокус не
уводят. Среди одновременно говорящих предпочитается тот, у кого включена
камера (`cameraKeysWithVideo`).

`stageTrackKey` переехал в `stageFocus.ts` — ключ плитки нужен и сцене, и
самой плитке (в карусели/гриде она рендерится шаблоном, без пропсов).
2026-07-27 01:31:42 +03:00
28b87a8c95 release: версия 0.0.5
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-27 00:56:37 +03:00
b35209a775 feat(admin): контактный адрес и тестовое письмо в настройках админки
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Поле включения/адреса в карточке «Контактный адрес» и кнопка «Отправить
тестовое письмо» с выводом результата — рядом, по образцу соседних
настроек AdminSettingsTab.
2026-07-27 00:35:59 +03:00
84b06f78f1 test(email): контактный адрес, Reply-To и тестовая отправка письма
Покрытие валидации/сохранения contact_email, простановки Reply-To в
письмах регистрации/приглашений/саммари при включённом и выключенном
контактном адресе, и эндпоинта тестовой отправки (успех, дефолтный
получатель, сбой транспорта без утечки логина/пароля).
2026-07-27 00:35:53 +03:00
74de51dbd8 feat(email): контактный адрес инстанса, Reply-To в письмах и тестовая отправка
Новая настройка instance_settings.contact_email (включён/адрес, с
валидацией формата) — подставляется в заголовок Reply-To писем
подтверждения регистрации, приглашений и саммари. Админ-эндпоинт
POST /admin/settings/test-email отправляет проверочное письмо синхронно
и возвращает внятный результат (успех либо текст ошибки транспорта),
не раскрывая логин/пароль SMTP.
2026-07-27 00:35:47 +03:00
09a3c6c806 release: версия 0.0.4
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-07-26 23:53:44 +03:00
86f1facf61 chore: не коммитить локальные конфиги сессий (.claude/) 2026-07-26 23:52:31 +03:00
c897f40363 feat(calendar): информационное окно вхождения — статус, кнопка «Редактировать» для владельца
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
ConferenceOccurrenceDialog расширен под общий информационный диалог (клик по
чипу — и для чужих, и для своих конференций): статус-бейдж (общая логика
occurrenceBadge вынесена в lib/occurrenceStatus.ts, переиспользуется с
ConferenceCalendar), кнопка «Редактировать», открывающая модалку формы —
только когда передан ownedConference (текущий пользователь — организатор).
Плюс Escape и возврат фокуса (useModalDismiss).
2026-07-26 23:35:10 +03:00
66935b04f8 refactor(calendar): чип события — только название и карандашик, заголовок периода без «г.»
В renderEventContent убраны бейдж статуса и время (переехали в
ConferenceOccurrenceDialog) — остались название с обрезкой по многоточию
(min-width:0/overflow:hidden по цепочке флекс-контейнеров, чинит вылезание
текста за границы ячейки) и, у владельца конференции, карандашик
редактирования (myConferenceById/onEditClick, stopPropagation, чтобы не
срабатывал клик по чипу). Клик по чипу — всегда информационное окно, включая
свои конференции.

titleFormat для dayGridWeek/dayGridMonth построен на Intl.DateTimeFormat
вместо ruLocale-форматтера — тот всегда добавляет «г.» после года.
2026-07-26 23:34:49 +03:00
07e5aba9c9 refactor(calendar): форма создания/редактирования конференции — в модальное окно
Раньше ConferenceFormCard всегда висела в сайдбаре двухколоночной сетки
.layout-grid; теперь открывается в модалке (по образцу
ConferenceOccurrenceDialog: modal-overlay/modal-panel, закрытие по клику на
оверлей, Escape и возврат фокуса — новый общий хук useModalDismiss).
.layout-grid стала одноколоночной — календарь занимает всю ширину.
2026-07-26 23:33:52 +03:00
904db83450 docs: уточнить в CHANGELOG поведение топбара комнаты на мобильном
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
В записи 0.0.3 было описано обратное: скрывается не инвайт-чип, а название
конференции со строкой статуса — чип «№ … / Пригласить» остаётся.
2026-07-26 12:11:50 +03:00
525d6dde1b fix(room): топбар и тулбар на мобильном не применялись + не тот блок скрывался
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Правки для узких экранов стояли в середине room.css, а `.tb-btn`/
`.room-toolbar` объявлены ниже — при равной специфичности побеждает порядок
в исходнике, поэтому тулбар на мобильном не менялся вовсе (скрылся только
инвайт-чип, объявленный выше блока). Блок перенесён в конец файла.

В топбаре теперь скрываются название конференции и статус (слева), а чип
«№ … / Пригласить» остаётся — с телефона он нужнее (скопировать ссылку и
позвать участника). Тулбару добавлены flex-wrap и min-width: 0, чтобы он не
вылезал за ширину окна ни при каких подписях кнопок.
2026-07-26 12:01:35 +03:00
dbda749756 release: версия 0.0.3 + CHANGELOG
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Поднята версия до 0.0.3 (VERSION, .env.example, docker-compose дефолт).
CHANGELOG.md: адаптивная вёрстка для мобильных экранов — вход/регистрация,
комната конференции, админка (заголовок/вкладки/таблицы конференций и
пользователей), экран подключения к завершённой конференции.
2026-07-26 04:43:23 +03:00
3c36a45d57 fix(join): кнопка «Ввести другую ссылку или номер» вылезала за карточку
.btn держит white-space: nowrap — на остальных кнопках экрана join текст
короткий, а на экране «конференция завершена» кнопка не обёрнута в форму
(в отличие от Продолжить/Войти) и не получает от неё ширину, поэтому
длинная фраза без переноса выталкивала кнопку за правый край карточки на
узких экранах.
2026-07-26 04:38:51 +03:00
a04ae3152f fix(admin): таблица конференций вылезала за экран на мобильном
На мобильном в таблице остаются только колонки «Конференция» и
«Владелец» — название конференции с номером стало кнопкой, открывающей
то же окно редактирования, что и карандаш в скрытой на мобильном колонке
действий. Туда же добавлена кнопка «Удалить» (с подтверждением) — раньше
удаление было доступно только из строки таблицы, которая на мобильном
теперь скрыта.
2026-07-26 04:32:27 +03:00
6813464e92 fix(admin): таблица пользователей вылезала за экран на мобильном
На мобильном в таблице остаётся только колонка «Пользователь» — роль,
команда, регистрация, статус и блокировка уже редактируются в карточке
профиля, которая открывается кликом по имени (AdminUserProfileDialog).
2026-07-26 04:20:07 +03:00
ac9832c5a1 fix(admin): заголовок, подзаголовок и вкладки вылезали за экран на мобильном
.page-head h1 (общий заголовок оболочки) — тот же баг с фиксированным
кеглем 34px, что и в auth.css, чинится тем же способом (clamp), лечит заодно
любую другую длинную шапку на страницах оболочки. В админке дополнительно:
поясняющий текст под заголовком скрыт на мобильном (не несёт функции), 4
вкладки переносятся в 2 ряда вместо горизонтального оверфлоу всей страницы.
2026-07-26 04:20:01 +03:00
84e2ab8756 fix(room): топбар и тулбар комнаты вылезали за экран на мобильном
room-toolbar с 8 кнопками (520px) не помещался в мобильную ширину (375px) —
крайние кнопки обрезались без возможности до них доскроллить. На мобильном
убираем демонстрацию экрана/полноэкранный режим/мини-окно (редко нужны с
телефона) и уплотняем оставшиеся 5 кнопок. В топбаре убираем инвайт-чип
(номер + копирование ссылки) — сверху остаются только название конференции
и статус.
2026-07-26 04:19:53 +03:00
372a7a9378 fix(auth): заголовок «Ваша инфраструктура» вылезал за край на мобильном
font: var(--text-display-lg) даёт фиксированные 34px — слово не помещалось
в брендовую панель на узких экранах (375px), а overflow:hidden панели
обрезал его вместо переноса. font-size: clamp() уменьшает кегль на узких
вьюпортах вместо обрезки.
2026-07-26 04:19:45 +03:00
77 changed files with 5584 additions and 472 deletions

View File

@@ -81,6 +81,20 @@ LIVEKIT_NODE_IP=127.0.0.1
# с точкой монтирования тома в обоих сервисах. # с точкой монтирования тома в обоих сервисах.
RECORDINGS_DIR=/recordings RECORDINGS_DIR=/recordings
# --- Производительность backend ---
# Число процессов uvicorn. Один процесс означает, что любой блокирующий вызов
# в обработчике останавливает весь event loop: параллельные входы в конференцию
# и WS-чат всех участников встают в очередь. Дефолт 2 рассчитан на 4-ядерный
# сервер, где ядра делятся с LiveKit (медиа важнее API).
UVICORN_WORKERS=2
# Пул соединений с БД НА КАЖДЫЙ воркер. Общий расход инстанса —
# UVICORN_WORKERS × (DB_POOL_SIZE + DB_MAX_OVERFLOW), плюс соединения Celery
# и alembic. Держите сумму заметно ниже max_connections у Postgres (по
# умолчанию 100), иначе вместо понятной ошибки приложения получите отказ БД.
DB_POOL_SIZE=10
DB_MAX_OVERFLOW=10
DB_POOL_TIMEOUT=10
# --- Email (рассылка саммари + .ics-приглашения) --- # --- Email (рассылка саммари + .ics-приглашения) ---
# `console` — дефолт для dev (письмо только логируется, ссылка подтверждения # `console` — дефолт для dev (письмо только логируется, ссылка подтверждения
# email берётся из логов); `smtp` — реальная отправка через aiosmtplib. # email берётся из логов); `smtp` — реальная отправка через aiosmtplib.
@@ -98,7 +112,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) --- # --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой # install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно. # установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.2 VIDCONF_VERSION=0.0.12
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного # --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг, # `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,

4
.gitignore vendored
View File

@@ -50,3 +50,7 @@ backend/media/
# Рабочая папка Claude Code (промпты, агенты, заметки) — не в репозиторий # Рабочая папка Claude Code (промпты, агенты, заметки) — не в репозиторий
.forcc/ .forcc/
# Локальные конфиги инструментов сессий (launch.json dev-сервера и т.п.) —
# привязаны к конкретной машине, в репозиторий не идут.
.claude/

View File

@@ -3,6 +3,264 @@
Формат основан на [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.12] — 2026-07-28
Разблокировка backend под нагрузкой: вход в конференцию перестаёт отваливаться,
когда участники включают микрофоны.
### Исправлено
- Обработчик webhook `track_published` больше не запускает Track Egress внутри
своей транзакции. Раньше каждый опубликованный микрофон уходил в сетевой
вызов, а на инстансе без профиля `transcribe` (egress-сервиса в деплое нет)
этот вызов висел 2025 секунд, всё это время удерживая соединение с БД. На
нагрузочном тесте с 19 участниками пул соединений выгребался за секунды, и
вход в конференцию начинал отвечать 500. Теперь запуск уходит в фоновую
задачу со своей сессией, а webhook отвечает сразу — LiveKit перестаёт копить
очередь доставки и терять события.
- Track Egress не запускается вовсе, если транскрибация выключена в настройках
инстанса — тот же guard, что уже был в обработчике `room_finished`.
- Запуск Track Egress ограничен таймаутом (по умолчанию 3 секунды) вместо
ожидания собственного таймаута LiveKit.
### Изменено
- Пул соединений с БД задаётся явно (`DB_POOL_SIZE`, `DB_MAX_OVERFLOW`,
`DB_POOL_TIMEOUT`) вместо дефолта SQLAlchemy 5 + 10. Считайте бюджет на весь
инстанс: каждый воркер держит свой пул, и сумма должна оставаться заметно
ниже `max_connections` у Postgres.
- Backend запускается с несколькими процессами uvicorn (`UVICORN_WORKERS`,
по умолчанию 2). Одиночный процесс означал, что любой блокирующий вызов
останавливает и параллельные запросы, и WS-чат всех участников. Дефолт 2, а
не по числу ядер: на четырёхъядерном сервере ядра делятся с LiveKit.
## [0.0.11] — 2026-07-28
Выбор режима показа участников в конференции, скрытие остальных и круглая
иконка участника при выключенной камере.
### Добавлено
- Выбор режима показа участников на сцене вместо автоматической раскладки:
«Стандарт» (крупная плитка плюс карусель остальных сбоку, как было),
«Плитки» (все участники равными плитками) и «Живые плитки» (плитками —
только те, у кого включена камера, остальные в карусели). Кто именно
показан крупно в «Стандарте», по-прежнему решает прежняя логика фокуса
(новая демонстрация → закреплённый → говорящий с видео → говорящий).
Выбор сохраняется между заходами в комнату.
- Скрытие остальных участников — на сцене остаётся только основная плитка.
Скрыть можно из меню «Вид», из шторки настроек на мобильном и кнопкой над
колонкой миниатюр; вернуть — кнопкой «Показать остальных (N)», которая
видна всё время, пока кто-то скрыт. В режиме «Плитки» скрывать нечего,
переключатель там заблокирован.
- Переключатель вида: кнопка «Вид» с поповером в тулбаре на широком экране;
на мобильном — секция «Вид» в шторке настроек, чтобы не добавлять шестую
кнопку в уже ужатый тулбар.
### Изменено
- Демонстрация экрана временно перебивает выбранный режим: пока идёт шэр,
сцена ведёт себя как «Стандарт», а по его завершении возвращается к
выбранному виду.
- Равномерная сетка плиток стала пригодна для телефона: 2 колонки от 360px
ширины и портретная раскладка 2×3 вместо двух плиток на страницу, которые
давал набор раскладок библиотеки. Лишние участники уходят на следующую
страницу — листается свайпом и стрелками.
### Исправлено
- Иконка участника при выключенной камере больше не превращается в эллипс:
размер считается от узкой стороны плитки (container-запросы), а не как
проценты от разных сторон. В крупной плитке иконка занимает примерно её
половину, в миниатюрах карусели — всю узкую сторону с отступом 5px; на
большой плитке фокуса иконка больше не упирается в прежний потолок 96px.
- Мини-плеер в Chrome открывался на самом пользователе вместо того, что он
видел крупно: сцена в PiP-окне — отдельный экземпляр компонента и выбирала
фокус с нуля, доходя до фолбэка «показать себя». Теперь ключ фокуса
переживает переезд в мини-окно и обратно. В Safari (video-фолбэк вместо
Document PiP) поведение не менялось.
## [0.0.10] — 2026-07-28
Разбивка метрик по контейнерам в Grafana и плашка с характеристиками сервера.
### Добавлено
- Метрики по контейнерам (CPU/память/сеть каждого отдельно) — замена
`cAdvisor`, несовместимого с containerd-снапшоттером Docker Engine на
сервере `1gb` (убран в `0.0.9`). Свой минимальный сервис
`container-exporter` (`deploy/monitoring/container-exporter/`) опрашивает
Docker Engine API параллельно в фоновой задаче, независимо от
storage-driver'а. Доступ к докер-сокету изолирован через
`docker-socket-proxy` — экспортеру разрешены только `GET`-запросы по
контейнерам, любые изменяющие запросы блокируются на уровне прокси.
Панели «Топ контейнеров по CPU/памяти» вернулись в дашборд
«Хост и контейнеры».
- Плашка «Характеристики сервера» в том же дашборде: живые «Ядра CPU» /
«Объём RAM» / «Диск (/)» из `node-exporter`, GPU — из новой info-метрики
backend `vidconf_host_info` (данные `install.sh`, `HW_*` в `.env`).
## [0.0.9] — 2026-07-28
Метрики CPU/RAM/диска хоста в Grafana; дефолтные подвкладки конференций и
пользователей в админке.
### Добавлено
- Мониторинг железа: `node-exporter` (CPU/RAM/диск/сеть/load average хоста)
— профиль compose `monitoring`, без публикации портов наружу. Новый
дашборд Grafana «Хост и контейнеры». Три новых алерта Prometheus:
`HostMemoryLow`, `HostDiskLow`, `HostCpuHigh` (пороги — под сервер `1gb`:
8 ГБ RAM, 4 CPU, 50 ГБ диска). `cAdvisor` (разбивка по контейнерам)
сознательно не включён — несовместим с containerd-снапшоттером Docker
Engine на `1gb`, см. `docs/deploy/monitoring.md`.
### Изменено
- Админка, вкладка «Конференции»: вкладка «Все» перенесена в конец списка
фильтров (после «Завершённые»), по умолчанию открываются «Запланированные».
- Админка, вкладка «Пользователи»: по умолчанию открываются «Активные»
(было «Все»).
## [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
Управление фокусом основного окна конференции: участника можно закрепить, а
без закрепления окно само следует за говорящим. Плюс общий футер на всех
страницах сервиса.
### Добавлено
- Закрепление участника в основном окне — кнопка-булавка на плитке.
Закреплённый участник держит фокус, даже когда говорят другие; повторное
нажатие снимает закрепление, как и выход закреплённого из комнаты.
Закреплённая плитка помечена рамкой и подсвеченной булавкой; на
тач-устройствах булавка видна без наведения.
- Общий футер на всех страницах, кроме комнаты конференции: версия инстанса
и копирайт со ссылкой на автора.
### Изменения
- Основное окно конференции следует за активным говорящим, а не удерживает
выбранного один раз участника. Фокус не переключается на короткие реплики
(удержание 1.2 с) и не уводится с идущей демонстрации экрана. Среди
нескольких одновременно говорящих крупно показывается тот, у кого включена
камера. Мини-плеер (Document PiP) работает как раньше.
- Демонстрация экрана по-прежнему главнее всего: она перебивает и
закрепление, а после её завершения фокус возвращается на закреплённого
участника.
- Футер админки заменён общим компонентом; в брендовой панели экранов входа
строка копирайта с захардкоженным годом убрана — год теперь берётся
текущий.
### Исправлено
- Календарь: «г.» после года убрана и из подписей дней в самой сетке (видны
как всплывающая подсказка и в accessibility-дереве) — в недельном и
месячном виде. В 0.0.4 это было исправлено только для подписи периода.
## [0.0.5] — 2026-07-27
Контактный адрес инстанса: настраиваемый адрес для ответов на письма
(уходят от `no-reply@`) и кнопка проверки почтовой конфигурации из
админки.
### Добавлено
- Настройка «Контактный адрес» в админке (вкладка «Настройки») — email
подставляется в заголовок `Reply-To` писем подтверждения регистрации,
приглашений на конференции и саммари; при выключенной настройке
`Reply-To` не проставляется, поведение как раньше.
- Кнопка «Отправить тестовое письмо» — синхронная проверка почтовой
конфигурации без регистрации фиктивного пользователя. Получатель по
умолчанию — email текущего администратора, можно указать другой.
Результат виден сразу: успех либо текст ошибки транспорта (сервер
недоступен/получатель отклонён), без логина и пароля SMTP в ответе.
## [0.0.4] — 2026-07-26
Календарь конференций: перестроен в раскладку «календарь во всю ширину плюс
модальные окна» — карточки событий разгружены, подробности вынесены во
всплывающее окно.
### Изменения
- Форма создания и редактирования конференции больше не занимает постоянную
колонку справа, а открывается в модальном окне — по кнопке «Новая
конференция», по карандашику на карточке события или из информационного
окна. Добавлен общий хук `useModalDismiss` (закрытие по Escape и возврат
фокуса на элемент, из которого окно открыли).
- Блок календаря занимает всю ширину страницы.
- Карточка события в сетке содержит только название (обрезается многоточием и
больше не вылезает за границы ячейки) и — у организатора — кнопку
редактирования. Время, номер и статус переехали в информационное окно.
- Клик по карточке события всегда открывает информационное окно, в том числе
для своих конференций: редактирование стало отдельным действием.
- Информационное окно вхождения дополнено статус-бейджем и кнопкой
«Редактировать» для организатора.
### Исправления
- Подпись периода в недельном и месячном виде больше не содержит «г.»
(форматирование через `Intl.DateTimeFormat` вместо локали FullCalendar).
## [0.0.3] — 2026-07-26
Мобильная версия: адаптивная вёрстка для узких экранов на ключевых экранах
сервиса, найденная и поправленная по итогам ручного прохода по мобильному
интерфейсу.
### Исправления
- Вход/регистрация (`AuthLayout`): заголовок «Ваша инфраструктура» вылезал за
край брендовой панели на узких экранах — кегль теперь адаптивно уменьшается
(`clamp()`) вместо обрезки.
- Комната конференции: топбар и тулбар не помещались на мобильной ширине
(тулбар с 8 кнопками физически шире экрана, крайние кнопки были обрезаны без
возможности до них докрутить). На мобильном тулбар сокращён до
микрофона/камеры/устройств/чата/выхода (демонстрация экрана,
полноэкранный режим и мини-окно скрыты — редко нужны с телефона), в топбаре
скрыты название конференции и строка статуса, а чип «№ … / Пригласить»
оставлен — с телефона он нужнее.
- Админка: заголовок и 4 вкладки раздела не помещались в ширину (вкладки
переносятся в 2 ряда, поясняющий текст под заголовком скрыт на мобильном);
таблицы «Конференции» и «Пользователи» обрезались вбок — на мобильном в них
остаются только ключевые колонки, название конференции стало кнопкой,
открывающей то же окно редактирования, что и раньше (туда же добавлена
кнопка удаления — на мобильном столбец действий скрыт).
- Подключение к конференции (`/join`): кнопка «Ввести другую ссылку или
номер» на экране завершённой конференции вылезала за карточку на узких
экранах (единственная кнопка на этом флоу без формы-обёртки, задающей ей
ширину, а `.btn` держит `white-space: nowrap`).
## [0.0.2] — 2026-07-26 ## [0.0.2] — 2026-07-26
Релиз воспроизводимого боевого развёртывания: конфигурация деплоя перенесена в Релиз воспроизводимого боевого развёртывания: конфигурация деплоя перенесена в
@@ -59,5 +317,13 @@
Первоначальная версия VidConf. Первоначальная версия VidConf.
[0.0.10]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.10
[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
[0.0.3]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.3
[0.0.2]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.2 [0.0.2]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.2
[0.0.1]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.1 [0.0.1]: https://github.com/MaxRonzhin/vidconf_rel/releases/tag/v0.0.1

View File

@@ -1 +1 @@
0.0.2 0.0.12

View File

@@ -37,4 +37,13 @@ EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=5s --retries=10 --start-period=15s \ HEALTHCHECK --interval=10s --timeout=5s --retries=10 --start-period=15s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1 CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1
CMD ["uv", "run", "uvicorn", "main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"] # Число воркеров — из окружения (`UVICORN_WORKERS`, см. docker-compose.yml).
# Один процесс означает, что любой блокирующий вызов в обработчике
# останавливает весь event loop: параллельные запросы и WS-чат всех
# участников встают в очередь. Дефолт 2, а не «по числу ядер»: медиа
# важнее API, и на 4-ядерном сервере LiveKit в пике забирает 1.6 ядра.
#
# `sh -c` нужен ради подстановки переменной (exec-форма её не делает),
# `exec` — чтобы uvicorn получил PID 1 и корректно принимал SIGTERM.
CMD ["sh", "-c", \
"exec uv run uvicorn main:create_app --factory --host 0.0.0.0 --port 8000 --workers ${UVICORN_WORKERS:-2}"]

View File

@@ -13,7 +13,7 @@
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated, Literal
import anyio import anyio
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
@@ -45,6 +45,8 @@ from schemas.admin import (
TeamListOut, TeamListOut,
TeamOut, TeamOut,
TeamUpdateIn, TeamUpdateIn,
TestEmailIn,
TestEmailOut,
) )
from schemas.conferences import ConferenceUpdateIn from schemas.conferences import ConferenceUpdateIn
from services.ai_levels import detect_ai_levels from services.ai_levels import detect_ai_levels
@@ -56,9 +58,11 @@ from services.conferences import (
InvalidConferenceStateError, InvalidConferenceStateError,
NotConferenceOwnerError, NotConferenceOwnerError,
) )
from services.email import EmailSendError, create_email_backend
from services.instance_settings import ( from services.instance_settings import (
InstanceSettingsService, InstanceSettingsService,
InvalidAiLevelError, InvalidAiLevelError,
InvalidContactEmailError,
InvalidEmailDomainError, InvalidEmailDomainError,
InvalidTimezoneError, InvalidTimezoneError,
SettingsUpdateIn, SettingsUpdateIn,
@@ -170,12 +174,16 @@ async def send_conference_invitations(
async def list_users( async def list_users(
admin: Annotated[User, Depends(require_admin)], admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)], session: Annotated[AsyncSession, Depends(get_session)],
status: Annotated[Literal["active", "blocked"] | None, Query()] = None,
q: Annotated[str | None, Query()] = None, q: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query(gt=0, le=MAX_LIMIT)] = DEFAULT_LIMIT, limit: Annotated[int, Query(gt=0, le=MAX_LIMIT)] = DEFAULT_LIMIT,
offset: Annotated[int, Query(ge=0)] = 0, offset: Annotated[int, Query(ge=0)] = 0,
) -> AdminUserListOut: ) -> AdminUserListOut:
"""Список всех пользователей инстанса с текстовым поиском по email/имени.""" """Список пользователей инстанса — фильтр по статусу (`active`/`blocked`, без
rows, total = await AdminUserRepository(session).list_paginated(q=q, limit=limit, offset=offset) параметра — все) и текстовый поиск по email/имени/названию команды."""
rows, total = await AdminUserRepository(session).list_paginated(
status=status, q=q, limit=limit, offset=offset
)
media_root = _media_root() media_root = _media_root()
items = [ items = [
_to_admin_user_out(user, team_name=team_name, media_root=media_root) _to_admin_user_out(user, team_name=team_name, media_root=media_root)
@@ -391,12 +399,59 @@ async def update_settings(
service = InstanceSettingsService(session) service = InstanceSettingsService(session)
try: try:
cfg = await service.update(data) cfg = await service.update(data)
except (InvalidAiLevelError, InvalidTimezoneError, InvalidEmailDomainError) as exc: except (
InvalidAiLevelError,
InvalidTimezoneError,
InvalidEmailDomainError,
InvalidContactEmailError,
) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
queue_served = await anyio.to_thread.run_sync(transcription_queue_served) queue_served = await anyio.to_thread.run_sync(transcription_queue_served)
return _to_settings_out(cfg, transcription_queue_served=queue_served) return _to_settings_out(cfg, transcription_queue_served=queue_served)
@router.post("/settings/test-email", response_model=TestEmailOut)
async def send_test_email(
data: TestEmailIn,
admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> TestEmailOut:
"""Отправить тестовое письмо синхронно — проверка почтовой конфигурации без
регистрации фиктивного пользователя.
Получатель по умолчанию — email текущего администратора. Отправка идёт
напрямую из эндпоинта (не через Celery), чтобы результат был виден сразу.
Секреты SMTP (логин/пароль) в ответе не участвуют — только хост/порт,
и то лишь при `EMAIL_BACKEND=smtp` (см. `.env.example`).
"""
app_settings = get_app_settings()
recipient = data.to or admin.email
cfg = await InstanceSettingsService(session).get()
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
is_smtp = app_settings.email_backend == "smtp"
smtp_host = app_settings.smtp_host if is_smtp else None
smtp_port = app_settings.smtp_port if is_smtp else None
backend = create_email_backend(app_settings)
try:
await backend.send(
to=recipient,
subject="Тестовое письмо VidConf",
body="Это тестовое письмо для проверки почтовой конфигурации инстанса VidConf.",
reply_to=reply_to,
)
except EmailSendError as exc:
return TestEmailOut(
success=False, message=str(exc), smtp_host=smtp_host, smtp_port=smtp_port
)
return TestEmailOut(
success=True,
message=f"Письмо успешно отправлено на {recipient}",
smtp_host=smtp_host,
smtp_port=smtp_port,
)
def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -> SettingsOut: def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -> SettingsOut:
"""Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI.""" """Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI."""
return SettingsOut( return SettingsOut(
@@ -409,7 +464,9 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
display_timezone=cfg.display_timezone, display_timezone=cfg.display_timezone,
registration_team_choice=cfg.registration_team_choice, registration_team_choice=cfg.registration_team_choice,
registration_email_domain_enabled=cfg.registration_email_domain_enabled, 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` — иначе пустой массив (справочник команд не `registration_team_choice` — иначе пустой массив (справочник команд не
раскрывается, пока выбор выключен). `email_domain` — эталонный домен при раскрывается, пока выбор выключен). `email_domains` — эталонные домены при
включённой настройке `registration_email_domain`, иначе `None`. включённой настройке `registration_email_domain`, иначе пустой список.
""" """
cfg = await InstanceSettingsService(session).get() cfg = await InstanceSettingsService(session).get()
teams: list[RegistrationTeamOptionOut] = [] teams: list[RegistrationTeamOptionOut] = []
if cfg.registration_team_choice: if cfg.registration_team_choice:
items, _ = await TeamRepository(session).list_all() items, _ = await TeamRepository(session).list_all()
teams = [RegistrationTeamOptionOut(id=team.id, name=team.name) for team in items] 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( 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

@@ -3,7 +3,7 @@
import logging import logging
from typing import Annotated, Any, cast from typing import Annotated, Any, cast
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request, status
from livekit import api from livekit import api
from sqlalchemy import CursorResult from sqlalchemy import CursorResult
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -22,6 +22,7 @@ router = APIRouter(prefix="/api/v1/livekit", tags=["livekit"])
@router.post("/webhook") @router.post("/webhook")
async def receive_webhook( async def receive_webhook(
request: Request, request: Request,
background: BackgroundTasks,
session: Annotated[AsyncSession, Depends(get_session)], session: Annotated[AsyncSession, Depends(get_session)],
authorization: Annotated[str | None, Header()] = None, authorization: Annotated[str | None, Header()] = None,
) -> dict[str, str]: ) -> dict[str, str]:
@@ -30,6 +31,11 @@ async def receive_webhook(
Дедупликация по `event.id`: `INSERT ... ON CONFLICT DO NOTHING` в Дедупликация по `event.id`: `INSERT ... ON CONFLICT DO NOTHING` в
`livekit_webhook_events` в одной транзакции с эффектами обработчика — `livekit_webhook_events` в одной транзакции с эффектами обработчика —
при конфликте (дубль) эффекты пропускаются, но ответ всё равно 200. при конфликте (дубль) эффекты пропускаются, но ответ всё равно 200.
Всё, что требует сети (запуск Track Egress), уходит в `background` и
выполняется уже после ответа: обработчик держит соединение с БД и
открытую транзакцию, а LiveKit при медленном ответе копит очередь
доставки и в итоге дропает события (см. `services.egress.run_track_egress`).
""" """
settings = get_settings() settings = get_settings()
raw_body = await request.body() raw_body = await request.body()
@@ -57,7 +63,7 @@ async def receive_webhook(
await session.commit() await session.commit()
return {"status": "duplicate"} return {"status": "duplicate"}
dispatcher = WebhookDispatcher(session) dispatcher = WebhookDispatcher(session, schedule=background.add_task)
await dispatcher.dispatch(event) await dispatcher.dispatch(event)
await session.commit() await session.commit()
return {"status": "ok"} return {"status": "ok"}

View File

@@ -1,15 +1,17 @@
"""Метрики Prometheus: латентность HTTP + gauge'и пайплайна и очередей. """Метрики Prometheus: латентность HTTP + gauge'и пайплайна, очередей и железа.
`GET /metrics` — без авторизации (снаружи закрывается на уровне nginx, вне `GET /metrics` — без авторизации (снаружи закрывается на уровне nginx, вне
периметра backend, см. `docs/deploy/scaling.md`/monitoring-часть devops): периметра backend, см. `docs/deploy/scaling.md`/monitoring-часть devops):
Prometheus-серверы традиционно ходят напрямую в контейнер по внутренней Prometheus-серверы традиционно ходят напрямую в контейнер по внутренней
сети, а не через публичный `/api/`-гейтвей. сети, а не через публичный `/api/`-гейтвей.
Gauge'и `vidconf_pipeline_sessions`/`vidconf_celery_queue_depth` намеренно Gauge'и `vidconf_pipeline_sessions`/`vidconf_celery_queue_depth`/
НЕ обновляются фоновой задачей — значения пересчитываются прямо в обработчике `vidconf_host_info` намеренно НЕ обновляются фоновой задачей — значения
запроса при каждом scrape (см. докстринг `metrics_endpoint`), поэтому их пересчитываются прямо в обработчике запроса при каждом scrape (см.
асинхронные источники (БД, Redis) можно опросить обычным `await` вместо докстринг `metrics_endpoint`), поэтому их асинхронные источники (БД,
реализации синхронного `prometheus_client.registry.Collector`. Redis) можно опросить обычным `await` вместо реализации синхронного
`prometheus_client.registry.Collector` (у `vidconf_host_info` источник
и вовсе синхронный — настройки уже в памяти процесса).
""" """
import time import time
@@ -20,6 +22,7 @@ from prometheus_client import CONTENT_TYPE_LATEST, Gauge, Histogram, generate_la
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from starlette.routing import Match from starlette.routing import Match
from core.config import get_settings
from core.db import get_session from core.db import get_session
from core.redis import redis_client from core.redis import redis_client
from models.session import PIPELINE_STATUSES from models.session import PIPELINE_STATUSES
@@ -110,6 +113,42 @@ async def _refresh_celery_queue_depth_gauge() -> None:
CELERY_QUEUE_DEPTH.labels(queue=queue).set(depth) CELERY_QUEUE_DEPTH.labels(queue=queue).set(depth)
# --- Info-метрика обнаруженного железа (install.sh, ADR-004) ---------------
HOST_INFO = Gauge(
"vidconf_host_info",
"Обнаруженное установщиком железо (info-метрика, значение всегда 1, данные в лейблах)",
labelnames=("cpus", "ram_mb", "gpu_name", "vram_mb"),
)
def _hw_label(value: int | None) -> str:
"""`None` (install.sh не запускался либо GPU не обнаружен) → «—», не пустая строка.
Пустой лейбл Grafana отрисовала бы как пустой текст на панели —
прочерк однозначно читается как «не определено».
"""
return str(value) if value is not None else ""
def _refresh_host_info_gauge() -> None:
"""Пересчитать `vidconf_host_info` по текущим `HW_*` настройкам (`core/config.py`).
Источник — `install.sh`, который пишет `HW_CPUS`/`HW_RAM_MB`/
`HW_GPU_NAME`/`HW_VRAM_MB` в `.env` при установке (ADR-004). Живые
CPU/RAM/диск хоста в дашборде «Хост и контейнеры» берутся из
node-exporter напрямую — здесь только то, чего node-exporter не
знает (GPU), плюс дублирование CPU/RAM install.sh для сверки.
"""
settings = get_settings()
HOST_INFO.labels(
cpus=_hw_label(settings.hw_cpus),
ram_mb=_hw_label(settings.hw_ram_mb),
gpu_name=settings.hw_gpu_name or "",
vram_mb=_hw_label(settings.hw_vram_mb),
).set(1)
@router.get("/metrics") @router.get("/metrics")
async def metrics_endpoint(session: AsyncSession = Depends(get_session)) -> Response: async def metrics_endpoint(session: AsyncSession = Depends(get_session)) -> Response:
"""Отдать метрики Prometheus в формате text exposition. """Отдать метрики Prometheus в формате text exposition.
@@ -122,4 +161,5 @@ async def metrics_endpoint(session: AsyncSession = Depends(get_session)) -> Resp
""" """
await _refresh_pipeline_sessions_gauge(session) await _refresh_pipeline_sessions_gauge(session)
await _refresh_celery_queue_depth_gauge() await _refresh_celery_queue_depth_gauge()
_refresh_host_info_gauge()
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST) return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)

View File

@@ -20,6 +20,24 @@ class Settings(BaseSettings):
redis_url: str = "redis://localhost:6379/0" redis_url: str = "redis://localhost:6379/0"
plugins_config_path: str = "../config/plugins.yaml" plugins_config_path: str = "../config/plugins.yaml"
# --- Пул соединений с БД ---
# Дефолт SQLAlchemy (5 + 10) на нагрузочном тесте 28.07.2026 выгребался
# за секунды: 226 ошибок `QueuePool limit of size 5 overflow 10 reached`
# и 37 ответов 500 на путях входа в конференцию.
#
# ⚠️ Бюджет соединений считается на ВЕСЬ инстанс, а не на процесс: каждый
# воркер uvicorn (`UVICORN_WORKERS`) держит собственный пул, плюс
# соединения нужны Celery-воркерам и alembic при миграциях. При
# `max_connections=100` у Postgres и двух воркерах 2 × (10 + 10) = 40
# оставляет запас. Поднимая значения на более крупном сервере, поднимайте
# и `max_connections` — иначе вместо понятной ошибки приложения получите
# отказ Postgres, который диагностируется куда хуже.
db_pool_size: int = 10
db_max_overflow: int = 10
# 10 секунд вместо дефолтных 30 — сознательно: пусть запрос падает быстро
# и показывает проблему, а не висит полминуты, делая вид, что всё живо.
db_pool_timeout: int = 10
# --- Версия инстанса (релиз v0.0.1) --- # --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из файла `VERSION` (корень репозитория) в # install.sh копирует значение из файла `VERSION` (корень репозитория) в
# `.env` при каждой установке/обновлении — здесь только чтение готового # `.env` при каждой установке/обновлении — здесь только чтение готового
@@ -59,6 +77,13 @@ class Settings(BaseSettings):
# (см. `deploy/docker-compose.yml`); в тестах переопределяется на `tmp_path`. # (см. `deploy/docker-compose.yml`); в тестах переопределяется на `tmp_path`.
recordings_dir: str = "/recordings" recordings_dir: str = "/recordings"
# Таймаут запуска Track Egress. Когда egress-сервиса в деплое нет (профиль
# `transcribe` не поднят), LiveKit ждёт ответа воркера через Redis до
# собственного таймаута psrpc — на тесте 28.07.2026 это давало по 2025
# секунд на каждый вызов. Ждать столько бессмысленно: если egress жив, он
# отвечает за доли секунды.
egress_start_timeout_s: float = 3.0
# --- Email (SMTP-бэкенд) --- # --- Email (SMTP-бэкенд) ---
# `console` — дефолт для dev (письмо только логируется); `smtp` — реальная # `console` — дефолт для dev (письмо только логируется); `smtp` — реальная
# отправка через aiosmtplib. Секреты SMTP — только в `.env` (инвариант №6), # отправка через aiosmtplib. Секреты SMTP — только в `.env` (инвариант №6),

View File

@@ -13,7 +13,16 @@ from core.config import get_settings
settings = get_settings() settings = get_settings()
engine: AsyncEngine = create_async_engine(settings.database_url, pool_pre_ping=True) engine: AsyncEngine = create_async_engine(
settings.database_url,
pool_pre_ping=True,
# Параметры пула — в настройках (`core/config.py`, там же расчёт бюджета
# соединений на инстанс). Дефолт SQLAlchemy 5 + 10 под нагрузкой
# выгребался за секунды.
pool_size=settings.db_pool_size,
max_overflow=settings.db_max_overflow,
pool_timeout=settings.db_pool_timeout,
)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False) async_session_maker = async_sessionmaker(engine, expire_on_commit=False)

View File

@@ -77,8 +77,13 @@ class InstanceConfig(BaseModel):
# — см. `services/instance_settings.py`. # — см. `services/instance_settings.py`.
registration_team_choice: bool = False registration_team_choice: bool = False
# Верификация регистрирующихся по домену email: при включении # Верификация регистрирующихся по домену email: при включении
# `POST /auth/register` принимает только # `POST /auth/register` принимает только email с доменом из
# email с доменом `registration_email_domain` — см. # `registration_email_domains` (любым из списка) — см.
# `services/instance_settings.py`. # `services/instance_settings.py`.
registration_email_domain_enabled: bool = False 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`.
contact_email_enabled: bool = False
contact_email: str | None = None

View File

@@ -2,7 +2,7 @@
import uuid import uuid
from sqlalchemy import func, or_, select from sqlalchemy import ColumnElement, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from models.conference import Conference from models.conference import Conference
@@ -56,25 +56,37 @@ class AdminConferenceRepository:
class AdminUserRepository: class AdminUserRepository:
"""Постраничный список пользователей с текстовым поиском по email/имени.""" """Постраничный список пользователей с фильтром по статусу и текстовым поиском."""
def __init__(self, session: AsyncSession) -> None: def __init__(self, session: AsyncSession) -> None:
self._session = session self._session = session
async def list_paginated( 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]: ) -> tuple[list[tuple[User, str | None]], int]:
"""Вернуть страницу пользователей (+ имя команды) и общее число совпадений. """Вернуть страницу пользователей (+ имя команды) и общее число совпадений.
`LEFT JOIN` на `teams` — имя команды нужно карточке профиля/таблице `status` — `"active"`/`"blocked"` (фильтр по `is_blocked`), `None` —
админки, у пользователя без команды — `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: if q:
like = f"%{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 = ( items_stmt = (
select(User, Team.name) select(User, Team.name)
.outerjoin(Team, User.team_id == Team.id) .outerjoin(Team, User.team_id == Team.id)

View File

@@ -155,4 +155,29 @@ class SettingsOut(BaseModel):
display_timezone: str display_timezone: str
registration_team_choice: bool registration_team_choice: bool
registration_email_domain_enabled: 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
class TestEmailIn(BaseModel):
"""Тело запроса тестовой отправки (`POST /admin/settings/test-email`).
`to` не задан — получатель по умолчанию: email текущего администратора
(см. `api/admin.py::send_test_email`).
"""
to: EmailStr | None = None
class TestEmailOut(BaseModel):
"""Результат тестовой отправки — понятный админу успех/ошибка транспорта.
`smtp_host`/`smtp_port` — только хост и порт (без логина/пароля, они не
покидают `.env`), заполняются лишь при `EMAIL_BACKEND=smtp`.
"""
success: bool
message: str
smtp_host: str | None = None
smtp_port: int | None = None

View File

@@ -103,11 +103,11 @@ class RegistrationOptionsOut(BaseModel):
`teams` отдаётся только при `team_choice_enabled=True` — иначе пустой `teams` отдаётся только при `team_choice_enabled=True` — иначе пустой
список (справочник команд не раскрывается, пока выбор выключен). список (справочник команд не раскрывается, пока выбор выключен).
`email_domain` — эталонный домен при включённой верификации регистрации `email_domains` — эталонные домены при включённой верификации регистрации
по домену email (настройка инстанса `registration_email_domain`), иначе по домену email (настройка инстанса `registration_email_domain`), иначе
`None`. пустой список.
""" """
team_choice_enabled: bool team_choice_enabled: bool
teams: list[RegistrationTeamOptionOut] 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` (публичный существует — иначе `InvalidTeamSelectionError` (публичный
эндпоинт, деталей не раскрываем). Если включена верификация домена эндпоинт, деталей не раскрываем). Если включена верификация домена
email (`registration_email_domain_enabled`), домен `email` (часть email (`registration_email_domain_enabled`), домен `email` (часть
после `@`, без учёта регистра) должен совпадать с эталонным — после `@`, без учёта регистра) должен совпадать с одним из
иначе `InvalidEmailDomainError`. Обе проверки — до создания эталонных доменов (`registration_email_domains`) — иначе
пользователя. `InvalidEmailDomainError`. Обе проверки — до создания пользователя.
""" """
existing = await self._users.get_by_email(email) existing = await self._users.get_by_email(email)
if existing is not None: if existing is not None:
@@ -118,7 +118,7 @@ class AuthService:
if cfg.registration_email_domain_enabled: if cfg.registration_email_domain_enabled:
email_domain = email.rsplit("@", 1)[-1].lower() 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) raise InvalidEmailDomainError(email)
if team_id is not None: if team_id is not None:
@@ -134,7 +134,8 @@ class AuthService:
password_hash=hash_password(password), password_hash=hash_password(password),
team_id=team_id, team_id=team_id,
) )
await self._issue_verification_email(user) reply_to = cfg.contact_email if cfg.contact_email_enabled else None
await self._issue_verification_email(user, reply_to=reply_to)
await self._session.commit() await self._session.commit()
return user return user
@@ -216,7 +217,7 @@ class AuthService:
await self._redis.set(f"{REFRESH_KEY_PREFIX}{jti}", str(user_id), ex=ttl_seconds) await self._redis.set(f"{REFRESH_KEY_PREFIX}{jti}", str(user_id), ex=ttl_seconds)
return TokenPair(access_token=access_token, refresh_token=refresh_token) return TokenPair(access_token=access_token, refresh_token=refresh_token)
async def _issue_verification_email(self, user: User) -> None: async def _issue_verification_email(self, user: User, *, reply_to: str | None = None) -> None:
token = secrets.token_urlsafe(32) # 256 бит случайности token = secrets.token_urlsafe(32) # 256 бит случайности
expires_at = datetime.now(UTC) + timedelta( expires_at = datetime.now(UTC) + timedelta(
hours=self._settings.email_verification_ttl_hours hours=self._settings.email_verification_ttl_hours
@@ -231,6 +232,7 @@ class AuthService:
to=user.email, to=user.email,
subject="Подтверждение регистрации VidConf", subject="Подтверждение регистрации VidConf",
body=f"Для подтверждения email перейдите по ссылке: {link}", body=f"Для подтверждения email перейдите по ссылке: {link}",
reply_to=reply_to,
) )

View File

@@ -5,14 +5,24 @@
транскодирования в `.ogg` на общий volume `recordings_dir`. Финализация транскодирования в `.ogg` на общий volume `recordings_dir`. Финализация
результата (итоговый `location`/ошибка) приходит асинхронно через webhook результата (итоговый `location`/ошибка) приходит асинхронно через webhook
`egress_ended` — здесь только сам запуск и `egress_id`/`started_at` из ответа. `egress_ended` — здесь только сам запуск и `egress_id`/`started_at` из ответа.
Запуск вынесен из тела webhook-обработчика в фоновую задачу
(`run_track_egress`) — см. докстринг этой функции.
""" """
import asyncio
import logging
import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from livekit import api from livekit import api
from core.config import get_settings from core.config import get_settings
from core.db import async_session_maker
from repositories.conferences import AudioTrackRepository
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -37,6 +47,10 @@ async def start_track_egress(room_name: str, track_sid: str, filepath: str) -> E
api_secret=settings.livekit_api_secret, api_secret=settings.livekit_api_secret,
) )
try: try:
# Без таймаута вызов висит до собственного таймаута psrpc LiveKit
# (2025 с, когда egress-воркера в деплое нет). `TimeoutError`
# ловит вызывающая сторона наравне с прочими ошибками запуска.
async with asyncio.timeout(settings.egress_start_timeout_s):
info = await lkapi.egress.start_track_egress( info = await lkapi.egress.start_track_egress(
api.TrackEgressRequest( api.TrackEgressRequest(
room_name=room_name, room_name=room_name,
@@ -56,3 +70,65 @@ async def start_track_egress(room_name: str, track_sid: str, filepath: str) -> E
else datetime.now(UTC) else datetime.now(UTC)
) )
return EgressStartResult(egress_id=info.egress_id, started_at=started_at) return EgressStartResult(egress_id=info.egress_id, started_at=started_at)
async def run_track_egress(
*,
room_name: str,
track_sid: str,
filepath: str,
session_id: uuid.UUID,
participant_id: uuid.UUID,
) -> None:
"""Запустить Track Egress и записать строку трека — ФОНОВАЯ задача вебхука.
Почему не внутри обработчика. `POST /api/v1/livekit/webhook` держит
соединение с БД и открытую транзакцию всё время своей работы (INSERT в
`livekit_webhook_events` сделан, commit — после обработчика). Пока здесь
жил сетевой вызов к egress, каждое событие `track_published` занимало
соединение на 2025 секунд, и на нагрузочном тесте 28.07.2026 пул
выгребался за секунды: 226 ошибок `QueuePool limit`, 37 ответов 500 на
путях входа в конференцию, 33 `dropped webhook` со стороны LiveKit
(очередь доставки росла до 56 секунд).
Поэтому обработчик отвечает 200 сразу, а сюда попадает только то, что
требует сети. Своя сессия БД (`async_session_maker`) обязательна: сессия
запроса к этому моменту уже закрыта вместе с ответом.
Идемпотентность сохраняется: `AudioTrackRepository.create` — это
`INSERT ... ON CONFLICT DO NOTHING` по `uq_session_track`, а проверка
«трек уже пишется» осталась в обработчике.
"""
try:
result = await start_track_egress(room_name, track_sid, filepath)
except Exception as exc: # noqa: BLE001 — недоступность egress не должна ронять фон
# Деплой-профиль (блок D): egress — необязательный сервис профиля
# `transcribe`; без него запись просто не стартует для этого трека.
# Строку `session_audio_tracks` не создаём — у нас нет `egress_id`,
# по которому её мог бы финализировать `egress_ended`.
logger.warning(
"track_published: не удалось запустить egress для трека %s сеанса %s: %s",
track_sid,
session_id,
exc,
)
return
async with async_session_maker() as session:
await AudioTrackRepository(session).create(
session_id=session_id,
participant_id=participant_id,
track_sid=track_sid,
egress_id=result.egress_id,
file_path=filepath,
started_at=result.started_at,
)
await session.commit()
logger.info(
"track_published: сеанс=%s участник=%s трек=%s egress=%s",
session_id,
participant_id,
track_sid,
result.egress_id,
)

View File

@@ -56,8 +56,14 @@ class EmailBackend(Protocol):
body: str, body: str,
html_body: str | None = None, html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (), attachments: Sequence[EmailAttachment] = (),
reply_to: str | None = None,
) -> None: ) -> None:
"""Отправить письмо получателю `to` (plaintext body обязателен, HTML — альтернатива).""" """Отправить письмо получателю `to` (plaintext body обязателен, HTML — альтернатива).
`reply_to` — необязательный контактный адрес инстанса (см.
`services/instance_settings.py::InstanceConfig.contact_email`), проставляется
заголовком `Reply-To`, если задан.
"""
... ...
@@ -72,13 +78,15 @@ class ConsoleEmailBackend:
body: str, body: str,
html_body: str | None = None, html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (), attachments: Sequence[EmailAttachment] = (),
reply_to: str | None = None,
) -> None: ) -> None:
"""Залогировать письмо (вложения — только имена файлов, без содержимого).""" """Залогировать письмо (вложения — только имена файлов, без содержимого)."""
attachment_names = ", ".join(a.filename for a in attachments) or "нет" attachment_names = ", ".join(a.filename for a in attachments) or "нет"
logger.info( logger.info(
"EMAIL to=%s subject=%s attachments=[%s]\n%s", "EMAIL to=%s subject=%s reply_to=%s attachments=[%s]\n%s",
to, to,
subject, subject,
reply_to or "нет",
attachment_names, attachment_names,
body, body,
) )
@@ -116,6 +124,7 @@ class SmtpEmailBackend:
body: str, body: str,
html_body: str | None = None, html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (), attachments: Sequence[EmailAttachment] = (),
reply_to: str | None = None,
) -> None: ) -> None:
"""Отправить письмо; ошибки транспорта транслируются в `EmailSendError`.""" """Отправить письмо; ошибки транспорта транслируются в `EmailSendError`."""
message = _build_message( message = _build_message(
@@ -125,6 +134,7 @@ class SmtpEmailBackend:
body=body, body=body,
html_body=html_body, html_body=html_body,
attachments=attachments, attachments=attachments,
reply_to=reply_to,
) )
try: try:
await aiosmtplib.send( await aiosmtplib.send(
@@ -165,12 +175,15 @@ def _build_message(
body: str, body: str,
html_body: str | None, html_body: str | None,
attachments: Sequence[EmailAttachment], attachments: Sequence[EmailAttachment],
reply_to: str | None = None,
) -> EmailMessage: ) -> EmailMessage:
"""Собрать `EmailMessage`: plaintext (+ HTML-альтернатива) + вложения.""" """Собрать `EmailMessage`: plaintext (+ HTML-альтернатива) + вложения."""
message = EmailMessage() message = EmailMessage()
message["From"] = sender message["From"] = sender
message["To"] = to message["To"] = to
message["Subject"] = subject message["Subject"] = subject
if reply_to:
message["Reply-To"] = reply_to
message.set_content(body) message.set_content(body)
if html_body is not None: if html_body is not None:
message.add_alternative(html_body, subtype="html") message.add_alternative(html_body, subtype="html")

View File

@@ -2,8 +2,8 @@
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`, Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
`ai_level`, `summary_recipients`, `display_timezone`, `ai_level`, `summary_recipients`, `display_timezone`,
`registration_team_choice`, `registration_email_domain`) — новая настройка `registration_team_choice`, `registration_email_domain`, `contact_email`) —
не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`) новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO
NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов
(например, при рестарте backend) не перетирает уже сделанные администратором (например, при рестарте backend) не перетирает уже сделанные администратором
@@ -17,7 +17,8 @@ from pathlib import Path
from typing import Any from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel from pydantic import BaseModel, EmailStr, TypeAdapter
from pydantic import ValidationError as PydanticValidationError
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -45,6 +46,7 @@ _KEY_SUMMARY_RECIPIENTS = "summary_recipients"
_KEY_DISPLAY_TIMEZONE = "display_timezone" _KEY_DISPLAY_TIMEZONE = "display_timezone"
_KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice" _KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice"
_KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain" _KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain"
_KEY_CONTACT_EMAIL = "contact_email"
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = ( BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
_KEY_CHAT, _KEY_CHAT,
@@ -61,7 +63,13 @@ _DEFAULT_AI_LEVEL_VALUE = {"level": "min"}
_DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"} _DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"}
_DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"} _DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"}
_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE = {"enabled": False} _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}
# Простой паттерн доменного имени: минимум один символ, минимум одна точка, # Простой паттерн доменного имени: минимум один символ, минимум одна точка,
# метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса), # метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса),
@@ -70,6 +78,11 @@ _EMAIL_DOMAIN_PATTERN = re.compile(
r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$" r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$"
) )
# Валидация формата контактного email — переиспользует тот же валидатор,
# что и `EmailStr` в pydantic-схемах (`schemas/admin.py` и др.), без
# отдельного регэкспа под адрес целиком.
_CONTACT_EMAIL_ADAPTER: TypeAdapter[str] = TypeAdapter(EmailStr)
class SettingsUpdateIn(BaseModel): class SettingsUpdateIn(BaseModel):
"""Частичное обновление настроек инстанса — все поля опциональны (PUT-патч). """Частичное обновление настроек инстанса — все поля опциональны (PUT-патч).
@@ -87,7 +100,9 @@ class SettingsUpdateIn(BaseModel):
display_timezone: str | None = None display_timezone: str | None = None
registration_team_choice: bool | None = None registration_team_choice: bool | None = None
registration_email_domain_enabled: 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
class BootstrapOverrides(BaseModel): class BootstrapOverrides(BaseModel):
@@ -136,6 +151,7 @@ def build_bootstrap_defaults(
_KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE), _KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE),
_KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE), _KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE),
_KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE), _KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE),
_KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE),
} }
if overrides is None: if overrides is None:
return defaults return defaults
@@ -172,6 +188,15 @@ class InvalidEmailDomainError(ValueError):
""" """
class InvalidContactEmailError(ValueError):
"""Некорректная настройка контактного адреса инстанса.
Поднимается при попытке включить контактный адрес без email
(`enabled=true` и пустой/отсутствующий email) либо при email, не
проходящем валидацию формата (`EmailStr`) — см. `_normalize_contact_email`.
"""
class InstanceSettingsService: class InstanceSettingsService:
"""CRUD-доступ к настройкам инстанса поверх таблицы `instance_settings`.""" """CRUD-доступ к настройкам инстанса поверх таблицы `instance_settings`."""
@@ -217,7 +242,20 @@ class InstanceSettingsService:
rows = await self._load_rows() rows = await self._load_rows()
cfg = _build_config(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)} statuses = {status.level: status for status in detect_ai_levels(cfg)}
if not statuses[patch.ai_level].available: if not statuses[patch.ai_level].available:
raise InvalidAiLevelError( raise InvalidAiLevelError(
@@ -247,26 +285,50 @@ class InstanceSettingsService:
if ( if (
patch.registration_email_domain_enabled is not None 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 = ( enabled = (
patch.registration_email_domain_enabled patch.registration_email_domain_enabled
if patch.registration_email_domain_enabled is not None if patch.registration_email_domain_enabled is not None
else cfg.registration_email_domain_enabled else cfg.registration_email_domain_enabled
) )
raw_domain = ( raw_domains = (
patch.registration_email_domain patch.registration_email_domains
if patch.registration_email_domain is not None if patch.registration_email_domains is not None
else cfg.registration_email_domain else cfg.registration_email_domains
) )
domain = _normalize_email_domain(raw_domain) if raw_domain else None domains = _normalize_email_domains(raw_domains)
if enabled and domain is None: if enabled and not domains:
raise InvalidEmailDomainError( raise InvalidEmailDomainError(
"нельзя включить верификацию домена email без указания домена" "нельзя включить верификацию домена email без указания хотя бы одного домена"
) )
cfg.registration_email_domain_enabled = enabled cfg.registration_email_domain_enabled = enabled
cfg.registration_email_domain = domain cfg.registration_email_domains = domains
await self._set(_KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "domain": domain}) 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 = (
patch.contact_email_enabled
if patch.contact_email_enabled is not None
else cfg.contact_email_enabled
)
raw_contact_email = (
patch.contact_email if patch.contact_email is not None else cfg.contact_email
)
contact_email = (
_normalize_contact_email(raw_contact_email) if raw_contact_email else None
)
if contact_enabled and contact_email is None:
raise InvalidContactEmailError(
"нельзя включить контактный адрес без указания email"
)
cfg.contact_email_enabled = contact_enabled
cfg.contact_email = contact_email
await self._set(
_KEY_CONTACT_EMAIL, {"enabled": contact_enabled, "email": contact_email}
)
if patch.transcription_enabled is not None: if patch.transcription_enabled is not None:
cfg.transcriber = cfg.transcriber.model_copy( cfg.transcriber = cfg.transcriber.model_copy(
@@ -360,6 +422,42 @@ def _normalize_email_domain(domain: str) -> str:
return normalized 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()
try:
_CONTACT_EMAIL_ADAPTER.validate_python(normalized)
except PydanticValidationError as exc:
raise InvalidContactEmailError(f"некорректный email: {email!r}") from exc
return normalized
def _build_config(rows: dict[str, Any]) -> InstanceConfig: def _build_config(rows: dict[str, Any]) -> InstanceConfig:
"""Собрать `InstanceConfig` из строк `instance_settings` с фолбэком на дефолты моделей. """Собрать `InstanceConfig` из строк `instance_settings` с фолбэком на дефолты моделей.
@@ -384,7 +482,11 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
registration_email_domain_enabled=rows.get( registration_email_domain_enabled=rows.get(
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE _KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
).get("enabled", False), ).get("enabled", False),
registration_email_domain=rows.get( registration_email_domains=_extract_email_domains(
_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE rows.get(_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE)
).get("domain"), ),
contact_email_enabled=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get(
"enabled", False
),
contact_email=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get("email"),
) )

View File

@@ -13,6 +13,7 @@
import logging import logging
import uuid import uuid
from collections.abc import Callable
from datetime import UTC, datetime from datetime import UTC, datetime
from livekit.protocol.egress import EgressStatus from livekit.protocol.egress import EgressStatus
@@ -26,7 +27,7 @@ from repositories.conferences import (
ConferenceRepository, ConferenceRepository,
ConferenceSessionRepository, ConferenceSessionRepository,
) )
from services.egress import start_track_egress from services.egress import run_track_egress
from services.instance_settings import InstanceSettingsService from services.instance_settings import InstanceSettingsService
from services.pipeline_producer import enqueue_pipeline from services.pipeline_producer import enqueue_pipeline
@@ -53,13 +54,27 @@ def _egress_ns_to_datetime(nanoseconds: int) -> datetime | None:
class WebhookDispatcher: class WebhookDispatcher:
"""Диспатчит `WebhookEvent` на обработчик по типу события.""" """Диспатчит `WebhookEvent` на обработчик по типу события.
def __init__(self, session: AsyncSession) -> None: `schedule` — планировщик фоновых задач: вызывается как
`schedule(coro_func, **kwargs)` и обязан вернуть управление немедленно,
не дожидаясь выполнения. В приложении это `BackgroundTasks.add_task`
FastAPI (задача стартует после отправки ответа), в тестах — вызовы
просто записываются. Через него уходит запуск Track Egress: сетевому
вызову не место внутри транзакции вебхука (см. `_on_track_published`).
"""
def __init__(
self,
session: AsyncSession,
*,
schedule: Callable[..., object],
) -> None:
self._conferences = ConferenceRepository(session) self._conferences = ConferenceRepository(session)
self._sessions = ConferenceSessionRepository(session) self._sessions = ConferenceSessionRepository(session)
self._audio_tracks = AudioTrackRepository(session) self._audio_tracks = AudioTrackRepository(session)
self._instance_settings = InstanceSettingsService(session) self._instance_settings = InstanceSettingsService(session)
self._schedule = schedule
async def dispatch(self, event: WebhookEvent) -> None: async def dispatch(self, event: WebhookEvent) -> None:
"""Обработать одно webhook-событие; неизвестный тип события — no-op.""" """Обработать одно webhook-событие; неизвестный тип события — no-op."""
@@ -161,15 +176,32 @@ class WebhookDispatcher:
) )
async def _on_track_published(self, event: WebhookEvent) -> None: async def _on_track_published(self, event: WebhookEvent) -> None:
"""Запустить Track Egress для опубликованного аудиотрека микрофона (ADR-002). """Запланировать Track Egress для опубликованного аудиотрека микрофона (ADR-002).
Видео/скриншеринг и т.п. — no-op (диаризация не нужна: транскрибируем Видео/скриншеринг и т.п. — no-op (диаризация не нужна: транскрибируем
только речь, трек = спикер). Идемпотентно: если строка трека уже только речь, трек = спикер). Идемпотентно: если строка трека уже
существует (гонка повторной доставки), egress повторно не запускается. существует (гонка повторной доставки), egress повторно не запускается.
Сам запуск уходит в фоновую задачу (`services.egress.run_track_egress`):
здесь остаются только быстрые проверки по БД, потому что обработчик
выполняется внутри открытой транзакции вебхука. Обоснование с цифрами —
в докстринге `run_track_egress`.
""" """
if event.track.type != TrackType.AUDIO or event.track.source != TrackSource.MICROPHONE: if event.track.type != TrackType.AUDIO or event.track.source != TrackSource.MICROPHONE:
return return
# Транскрибация выключена — записывать нечего. Тот же guard, что и в
# `_on_room_finished`: без него на инстансе без профиля `transcribe`
# (egress-контейнера в деплое нет) каждый микрофон превращался в
# заведомо безнадёжный сетевой вызов длиной в 2025 секунд.
cfg = await self._instance_settings.get()
if not cfg.transcriber.enabled:
logger.debug(
"livekit webhook track_published: транскрибация выключена — трек %s пропущен",
event.track.sid,
)
return
conference = await self._conferences.get_by_slug(event.room.name) conference = await self._conferences.get_by_slug(event.room.name)
if conference is None: if conference is None:
logger.warning( logger.warning(
@@ -216,37 +248,13 @@ class WebhookDispatcher:
filepath = ( filepath = (
f"{settings.recordings_dir}/{session_record.id}/{participant.id}_{event.track.sid}.ogg" f"{settings.recordings_dir}/{session_record.id}/{participant.id}_{event.track.sid}.ogg"
) )
try: self._schedule(
result = await start_track_egress(event.room.name, event.track.sid, filepath) run_track_egress,
except Exception as exc: # noqa: BLE001 — недоступность egress не должна ронять webhook room_name=event.room.name,
# Деплой-профиль (блок D): egress — необязательный сервис профиля track_sid=event.track.sid,
# `transcribe`; без него запись просто не стартует для этого трека filepath=filepath,
# (риск «Потерян webhook track_published»).
# Строку `session_audio_tracks` не создаём — у нас нет `egress_id`,
# по которому её мог бы финализировать `egress_ended`.
logger.warning(
"livekit webhook track_published: не удалось запустить egress для трека %s "
"сеанса %s: %s",
event.track.sid,
session_record.id,
exc,
)
return
await self._audio_tracks.create(
session_id=session_record.id, session_id=session_record.id,
participant_id=participant.id, participant_id=participant.id,
track_sid=event.track.sid,
egress_id=result.egress_id,
file_path=filepath,
started_at=result.started_at,
)
logger.info(
"livekit webhook track_published: сеанс=%s участник=%s трек=%s egress=%s",
session_record.id,
participant.id,
event.track.sid,
result.egress_id,
) )
async def _on_egress_ended(self, event: WebhookEvent) -> None: async def _on_egress_ended(self, event: WebhookEvent) -> None:

View File

@@ -24,6 +24,7 @@ from models.team import Team
from models.user import User from models.user import User
from services import ai_levels from services import ai_levels
from services.conference_ids import generate_number, generate_slug from services.conference_ids import generate_number, generate_slug
from services.email import EmailSendError
FUTURE = datetime.now(UTC) + timedelta(days=3) FUTURE = datetime.now(UTC) + timedelta(days=3)
@@ -85,6 +86,7 @@ async def test_all_admin_endpoints_forbidden_for_non_admin(
("PATCH", f"/api/v1/admin/users/{other_user.id}", {"role": "admin"}), ("PATCH", f"/api/v1/admin/users/{other_user.id}", {"role": "admin"}),
("GET", "/api/v1/admin/settings", None), ("GET", "/api/v1/admin/settings", None),
("PUT", "/api/v1/admin/settings", {}), ("PUT", "/api/v1/admin/settings", {}),
("POST", "/api/v1/admin/settings/test-email", {}),
] ]
for method, path, body in requests: for method, path, body in requests:
response = await client.request(method, path, json=body, headers=headers) response = await client.request(method, path, json=body, headers=headers)
@@ -379,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) 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( async def test_patch_user_role_and_block(
client: httpx.AsyncClient, db_session: AsyncSession client: httpx.AsyncClient, db_session: AsyncSession
) -> None: ) -> None:
@@ -606,3 +669,153 @@ async def test_put_settings_unavailable_ai_level_returns_400(
headers=_auth_headers(admin), headers=_auth_headers(admin),
) )
assert response.status_code == 400 assert response.status_code == 400
async def test_put_settings_contact_email_enable_and_persist(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False)
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.put(
"/api/v1/admin/settings",
json={"contact_email_enabled": True, "contact_email": "Contact@VidConf.RU"},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
body = response.json()
assert body["contact_email_enabled"] is True
assert body["contact_email"] == "contact@vidconf.ru"
reloaded = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
assert reloaded.json()["contact_email"] == "contact@vidconf.ru"
async def test_put_settings_contact_email_invalid_returns_400(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.put(
"/api/v1/admin/settings",
json={"contact_email_enabled": True, "contact_email": "not an email"},
headers=_auth_headers(admin),
)
assert response.status_code == 400
# --- Тестовое письмо ----------------------------------------------------------------
async def test_send_test_email_defaults_to_admin_email(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Без явного `to` тестовое письмо уходит на email текущего администратора."""
admin = await _make_user(db_session, role="admin")
await db_session.commit()
sent: dict[str, object] = {}
class _FakeBackend:
async def send(self, **kwargs: object) -> None:
sent.update(kwargs)
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend())
response = await client.post(
"/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin)
)
assert response.status_code == 200, response.text
body = response.json()
assert body["success"] is True
assert sent["to"] == admin.email
async def test_send_test_email_to_explicit_recipient(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
sent: dict[str, object] = {}
class _FakeBackend:
async def send(self, **kwargs: object) -> None:
sent.update(kwargs)
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend())
response = await client.post(
"/api/v1/admin/settings/test-email",
json={"to": "other@example.com"},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
assert sent["to"] == "other@example.com"
async def test_send_test_email_uses_contact_email_as_reply_to(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
await client.put(
"/api/v1/admin/settings",
json={"contact_email_enabled": True, "contact_email": "contact@vidconf.example"},
headers=_auth_headers(admin),
)
sent: dict[str, object] = {}
class _FakeBackend:
async def send(self, **kwargs: object) -> None:
sent.update(kwargs)
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend())
response = await client.post(
"/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin)
)
assert response.status_code == 200, response.text
assert sent["reply_to"] == "contact@vidconf.example"
async def test_send_test_email_reports_transport_failure_without_leaking_secrets(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Сбой транспорта — понятный текст ошибки; хост/порт видны, логин/пароль — нет."""
monkeypatch.setenv("EMAIL_BACKEND", "smtp")
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
monkeypatch.setenv("SMTP_PORT", "2525")
monkeypatch.setenv("SMTP_USERNAME", "secret-user")
monkeypatch.setenv("SMTP_PASSWORD", "super-secret-password")
get_settings.cache_clear()
class _FailingBackend:
async def send(self, **kwargs: object) -> None:
raise EmailSendError("временный сбой SMTP: сервер недоступен", retryable=True)
monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FailingBackend())
try:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.post(
"/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin)
)
assert response.status_code == 200, response.text
body = response.json()
assert body["success"] is False
assert "сбой SMTP" in body["message"]
assert body["smtp_host"] == "smtp.example.com"
assert body["smtp_port"] == 2525
assert "secret-user" not in response.text
assert "super-secret-password" not in response.text
finally:
get_settings.cache_clear()

View File

@@ -48,6 +48,7 @@ class _CapturingEmailBackend:
def __init__(self) -> None: def __init__(self) -> None:
self.sent: list[tuple[str, str, str]] = [] self.sent: list[tuple[str, str, str]] = []
self.reply_to: list[str | None] = []
async def send( async def send(
self, self,
@@ -57,8 +58,10 @@ class _CapturingEmailBackend:
body: str, body: str,
html_body: str | None = None, html_body: str | None = None,
attachments: Sequence[EmailAttachment] = (), attachments: Sequence[EmailAttachment] = (),
reply_to: str | None = None,
) -> None: ) -> None:
self.sent.append((to, subject, body)) self.sent.append((to, subject, body))
self.reply_to.append(reply_to)
def _extract_verification_token(email_body: str) -> str: def _extract_verification_token(email_body: str) -> str:
@@ -235,7 +238,7 @@ async def test_registration_options_disabled_by_default(
body = response.json() body = response.json()
assert body["team_choice_enabled"] is False assert body["team_choice_enabled"] is False
assert body["teams"] == [] assert body["teams"] == []
assert body["email_domain"] is None assert body["email_domains"] == []
async def test_registration_options_enabled_returns_teams_sorted_by_name( async def test_registration_options_enabled_returns_teams_sorted_by_name(
@@ -340,29 +343,30 @@ async def test_register_with_unknown_team_id_returns_400(
# --- Верификация регистрирующихся по домену email ------------------------------------- # --- Верификация регистрирующихся по домену 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 client: httpx.AsyncClient, db_session: AsyncSession
) -> None: ) -> None:
"""При включённой верификации домена `registration-options` отдаёт эталонный домен.""" """При включённой верификации домена `registration-options` отдаёт все эталонные домены."""
await InstanceSettingsService(db_session).update( await InstanceSettingsService(db_session).update(
SettingsUpdateIn( 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() await db_session.commit()
response = await client.get("/api/v1/auth/registration-options") response = await client.get("/api/v1/auth/registration-options")
assert response.status_code == 200, response.text 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 client: httpx.AsyncClient, db_session: AsyncSession
) -> None: ) -> None:
"""Выключенная верификация домена — `email_domain` всегда `null`, даже если домен сохранён.""" """Выключенная верификация домена — `email_domains` всегда пуст, даже если домены сохранены."""
await InstanceSettingsService(db_session).update( await InstanceSettingsService(db_session).update(
SettingsUpdateIn( 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( await InstanceSettingsService(db_session).update(
@@ -372,7 +376,7 @@ async def test_registration_options_email_domain_null_when_disabled(
response = await client.get("/api/v1/auth/registration-options") response = await client.get("/api/v1/auth/registration-options")
assert response.status_code == 200, response.text 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( async def test_register_with_foreign_domain_when_verification_enabled_returns_400(
@@ -380,7 +384,8 @@ async def test_register_with_foreign_domain_when_verification_enabled_returns_40
) -> None: ) -> None:
await InstanceSettingsService(db_session).update( await InstanceSettingsService(db_session).update(
SettingsUpdateIn( 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() await db_session.commit()
@@ -402,7 +407,7 @@ async def test_register_with_matching_domain_case_insensitive_succeeds(
) -> None: ) -> None:
await InstanceSettingsService(db_session).update( await InstanceSettingsService(db_session).update(
SettingsUpdateIn( 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() await db_session.commit()
@@ -418,6 +423,29 @@ async def test_register_with_matching_domain_case_insensitive_succeeds(
assert response.status_code == 201, response.text 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( async def test_register_any_domain_allowed_when_verification_disabled(
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
) -> None: ) -> None:
@@ -432,3 +460,34 @@ async def test_register_any_domain_allowed_when_verification_disabled(
}, },
) )
assert response.status_code == 201, response.text assert response.status_code == 201, response.text
async def test_register_sets_reply_to_when_contact_email_enabled(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Контактный адрес инстанса включён — письмо подтверждения регистрации несёт `Reply-To`."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(contact_email_enabled=True, contact_email="contact@vidconf.example")
)
await db_session.commit()
response = await client.post(
"/api/v1/auth/register",
json={"email": "dave@example.com", "name_user": "Dave", "password": "supersecret1"},
)
assert response.status_code == 201, response.text
assert email_backend.reply_to[-1] == "contact@vidconf.example"
async def test_register_no_reply_to_when_contact_email_disabled(
client: httpx.AsyncClient, email_backend: _CapturingEmailBackend
) -> None:
"""Контактный адрес выключен (дефолт) — `Reply-To` не проставляется."""
response = await client.post(
"/api/v1/auth/register",
json={"email": "erin@example.com", "name_user": "Erin", "password": "supersecret1"},
)
assert response.status_code == 201, response.text
assert email_backend.reply_to[-1] is None

View File

@@ -48,6 +48,7 @@ from services.instance_settings import (
BootstrapOverrides, BootstrapOverrides,
InstanceSettingsService, InstanceSettingsService,
InvalidAiLevelError, InvalidAiLevelError,
InvalidContactEmailError,
InvalidEmailDomainError, InvalidEmailDomainError,
InvalidTimezoneError, InvalidTimezoneError,
SettingsUpdateIn, SettingsUpdateIn,
@@ -69,6 +70,7 @@ _MANAGED_KEYS = (
"display_timezone", "display_timezone",
"registration_team_choice", "registration_team_choice",
"registration_email_domain", "registration_email_domain",
"contact_email",
) )
@@ -123,6 +125,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
"display_timezone", "display_timezone",
"registration_team_choice", "registration_team_choice",
"registration_email_domain", "registration_email_domain",
"contact_email",
} }
cfg = await service.get() cfg = await service.get()
assert cfg.transcriber.provider == "faster_whisper_cpu" assert cfg.transcriber.provider == "faster_whisper_cpu"
@@ -131,7 +134,9 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
assert cfg.display_timezone == "Europe/Moscow" assert cfg.display_timezone == "Europe/Moscow"
assert cfg.registration_team_choice is False assert cfg.registration_team_choice is False
assert cfg.registration_email_domain_enabled 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
async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits( async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
@@ -321,7 +326,7 @@ async def test_registration_team_choice_toggle(
async def test_registration_email_domain_enable_without_domain_rejected( async def test_registration_email_domain_enable_without_domain_rejected(
db_session: AsyncSession, clean_instance_settings: None db_session: AsyncSession, clean_instance_settings: None
) -> None: ) -> None:
"""Включение верификации без домена (ни в патче, ни ранее сохранённого) → 400.""" """Включение верификации без доменов (ни в патче, ни ранее сохранённых) → 400."""
service = InstanceSettingsService(db_session) service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML) await service.ensure_bootstrapped(PLUGINS_YAML)
@@ -330,7 +335,24 @@ async def test_registration_email_domain_enable_without_domain_rejected(
cfg = await service.get() cfg = await service.get()
assert cfg.registration_email_domain_enabled is False 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( async def test_registration_email_domain_rejects_invalid_pattern(
@@ -340,48 +362,168 @@ async def test_registration_email_domain_rejects_invalid_pattern(
await service.ensure_bootstrapped(PLUGINS_YAML) await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidEmailDomainError): 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() cfg = await service.get()
assert cfg.registration_email_domain is None assert cfg.registration_email_domains == []
async def test_registration_email_domain_normalizes_input( async def test_registration_email_domain_normalizes_input(
db_session: AsyncSession, clean_instance_settings: None db_session: AsyncSession, clean_instance_settings: None
) -> None: ) -> None:
"""`@Corp.RU ` нормализуется в `corp.ru` (strip, убрать «@», lower).""" """`@Corp.RU ` нормализуется в `corp.ru` (strip, убрать «@», lower); пустые строки
отбрасываются, дубликаты схлопываются."""
service = InstanceSettingsService(db_session) service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML) await service.ensure_bootstrapped(PLUGINS_YAML)
cfg = await service.update( cfg = await service.update(
SettingsUpdateIn( 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_enabled is True
assert cfg.registration_email_domain == "corp.ru" assert cfg.registration_email_domains == ["corp.ru", "acme.io"]
reloaded = await service.get() 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 db_session: AsyncSession, clean_instance_settings: None
) -> None: ) -> None:
"""Выключение верификации без передачи домена не требует домена и не роняет валидацию.""" """Выключение верификации без передачи доменов не требует их и не роняет валидацию."""
service = InstanceSettingsService(db_session) service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML) await service.ensure_bootstrapped(PLUGINS_YAML)
await service.update( await service.update(
SettingsUpdateIn( 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)) cfg = await service.update(SettingsUpdateIn(registration_email_domain_enabled=False))
assert cfg.registration_email_domain_enabled is 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(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Включение контактного адреса без email (ни в патче, ни ранее сохранённого) → 400."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidContactEmailError):
await service.update(SettingsUpdateIn(contact_email_enabled=True))
cfg = await service.get()
assert cfg.contact_email_enabled is False
assert cfg.contact_email is None
async def test_contact_email_rejects_invalid_format(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidContactEmailError):
await service.update(SettingsUpdateIn(contact_email="not an email"))
cfg = await service.get()
assert cfg.contact_email is None
async def test_contact_email_normalizes_input(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""` Contact@VidConf.RU ` нормализуется в `contact@vidconf.ru` (strip, lower)."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
cfg = await service.update(
SettingsUpdateIn(contact_email_enabled=True, contact_email=" Contact@VidConf.RU ")
)
assert cfg.contact_email_enabled is True
assert cfg.contact_email == "contact@vidconf.ru"
reloaded = await service.get()
assert reloaded.contact_email == "contact@vidconf.ru"
async def test_contact_email_can_be_disabled_keeping_stored_email(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Выключение контактного адреса без передачи email не требует его и не роняет валидацию."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
await service.update(
SettingsUpdateIn(contact_email_enabled=True, contact_email="contact@acme.io")
)
cfg = await service.update(SettingsUpdateIn(contact_email_enabled=False))
assert cfg.contact_email_enabled is False
assert cfg.contact_email == "contact@acme.io"
async def test_transcription_enabled_flag_toggles_both_transcriber_and_summarizer( async def test_transcription_enabled_flag_toggles_both_transcriber_and_summarizer(
@@ -411,6 +553,52 @@ async def test_update_rejects_unavailable_ai_level(
assert cfg.ai_level == "min" 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( async def test_update_rejects_invalid_timezone(
db_session: AsyncSession, clean_instance_settings: None db_session: AsyncSession, clean_instance_settings: None
) -> None: ) -> None:

View File

@@ -19,6 +19,7 @@ from celery.exceptions import MaxRetriesExceededError
from sqlalchemy import text from sqlalchemy import text
from core.db import engine from core.db import engine
from core.plugins.config import ChatConfig, InstanceConfig, SummarizerConfig, TranscriberConfig
from services.conference_ids import generate_number, generate_slug from services.conference_ids import generate_number, generate_slug
from services.email import EmailAttachment, EmailSendError from services.email import EmailAttachment, EmailSendError
from workers.tasks import invitations as invitations_module from workers.tasks import invitations as invitations_module
@@ -65,10 +66,13 @@ class _FakeEmailBackend:
body: str, body: str,
html_body: str | None = None, html_body: str | None = None,
attachments: tuple[EmailAttachment, ...] = (), attachments: tuple[EmailAttachment, ...] = (),
reply_to: str | None = None,
) -> None: ) -> None:
if to in self._fail_for: if to in self._fail_for:
raise EmailSendError(f"сбой отправки {to}", retryable=self._retryable) raise EmailSendError(f"сбой отправки {to}", retryable=self._retryable)
self.sent.append({"to": to, "subject": subject, "attachments": attachments}) self.sent.append(
{"to": to, "subject": subject, "attachments": attachments, "reply_to": reply_to}
)
class _Fixture: class _Fixture:
@@ -364,6 +368,48 @@ async def test_pinned_conference_sends_to_owner_and_past_participants_with_email
assert delivered == sent_to assert delivered == sent_to
def _cfg(
*, contact_email_enabled: bool = False, contact_email: str | None = None
) -> InstanceConfig:
return InstanceConfig(
transcriber=TranscriberConfig(),
summarizer=SummarizerConfig(),
chat=ChatConfig(),
contact_email_enabled=contact_email_enabled,
contact_email=contact_email,
)
async def test_invitations_set_reply_to_when_contact_email_enabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Включённый контактный адрес инстанса — приглашение несёт `Reply-To`."""
mock_backend = _FakeEmailBackend()
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
await send_invitations_async(
_FakeTask(),
fx.conference_id,
plugins_config=_cfg(contact_email_enabled=True, contact_email="contact@vidconf.example"),
)
assert mock_backend.sent
assert all(call["reply_to"] == "contact@vidconf.example" for call in mock_backend.sent)
async def test_invitations_no_reply_to_when_contact_email_disabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Контактный адрес выключен (дефолт) — `Reply-To` не проставляется."""
mock_backend = _FakeEmailBackend()
monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend)
await send_invitations_async(_FakeTask(), fx.conference_id, plugins_config=_cfg())
assert mock_backend.sent
assert all(call["reply_to"] is None for call in mock_backend.sent)
async def test_explicit_emails_override_default_recipients( async def test_explicit_emails_override_default_recipients(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:

View File

@@ -3,9 +3,13 @@
цикл закреплённой/незакреплённой конференции — на фикстурах payload'ов LiveKit. цикл закреплённой/незакреплённой конференции — на фикстурах payload'ов LiveKit.
""" """
import asyncio
import base64 import base64
import hashlib import hashlib
import json
import uuid import uuid
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
@@ -13,10 +17,13 @@ from unittest.mock import AsyncMock, Mock
import httpx import httpx
import jwt import jwt
import pytest import pytest
from google.protobuf.json_format import ParseDict
from livekit.protocol.webhook import WebhookEvent
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import services.egress as egress_module
import services.webhook_handlers as webhook_handlers_module import services.webhook_handlers as webhook_handlers_module
from core.config import get_settings from core.config import get_settings
from core.security import hash_password from core.security import hash_password
@@ -54,6 +61,52 @@ def _sign(body: bytes) -> str:
return jwt.encode(payload, settings.livekit_api_secret, algorithm="HS256") return jwt.encode(payload, settings.livekit_api_secret, algorithm="HS256")
def _parse_fixture_event(name: str, **placeholders: str) -> WebhookEvent:
"""Разобрать фикстуру в `WebhookEvent` — для тестов диспатчера без HTTP-слоя."""
return ParseDict(json.loads(_load_fixture(name, **placeholders)), WebhookEvent())
async def _set_transcriber_enabled(session: AsyncSession, *, enabled: bool) -> None:
"""Выставить `instance_settings.transcriber.enabled`.
Пишется тем же `db_session` (savepoint), что и обработчик webhook (подмена
`get_session` в фикстуре `app`) — видна обработчику без реального коммита
в dev-БД. Тесты, которым важен `track_published`, обязаны выставлять флаг
ЯВНО: значение в dev-БД непредсказуемо, а обработчик с версии 0.0.12
выходит на выключенной транскрибации раньше всех остальных проверок.
"""
value: dict[str, object] = {
"enabled": enabled,
"provider": "faster_whisper_cpu" if enabled else "null",
"model": "small" if enabled else None,
"language": "ru",
"options": {},
}
await session.execute(
pg_insert(InstanceSetting)
.values(key="transcriber", value=value)
.on_conflict_do_update(index_elements=["key"], set_={"value": value})
)
def _use_test_session_in_background(
monkeypatch: pytest.MonkeyPatch, db_session: AsyncSession
) -> None:
"""Заставить фоновую задачу egress работать с тестовой (savepoint) сессией.
`run_track_egress` намеренно берёт СВОЮ сессию (`async_session_maker`):
в бою сессия запроса к моменту фоновой задачи уже закрыта. В тестах такое
подключение шло бы мимо откатываемой транзакции и не увидело бы ни
конференции, ни участника — поэтому подменяем фабрику на тестовую сессию.
"""
@asynccontextmanager
async def _maker() -> AsyncGenerator[AsyncSession, None]:
yield db_session
monkeypatch.setattr(egress_module, "async_session_maker", _maker)
async def _post_webhook(client: httpx.AsyncClient, body: bytes) -> httpx.Response: async def _post_webhook(client: httpx.AsyncClient, body: bytes) -> httpx.Response:
return await client.post( return await client.post(
WEBHOOK_URL, WEBHOOK_URL,
@@ -319,8 +372,10 @@ async def test_track_published_by_guest_starts_egress_and_creates_track_row(
mock_start = AsyncMock( mock_start = AsyncMock(
return_value=EgressStartResult(egress_id="EG_guest_track", started_at=started_at) return_value=EgressStartResult(egress_id="EG_guest_track", started_at=started_at)
) )
monkeypatch.setattr(webhook_handlers_module, "start_track_egress", mock_start) monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
_use_test_session_in_background(monkeypatch, db_session)
await _set_transcriber_enabled(db_session, enabled=True)
conference = await _make_conference(db_session, generate_slug()) conference = await _make_conference(db_session, generate_slug())
guest = await _make_guest(db_session, conference) guest = await _make_guest(db_session, conference)
await db_session.commit() await db_session.commit()
@@ -382,8 +437,10 @@ async def test_track_published_survives_egress_unavailable(
) -> None: ) -> None:
"""Недоступность egress не должна ронять webhook (блок D): 200 + warning, без строки трека.""" """Недоступность egress не должна ронять webhook (блок D): 200 + warning, без строки трека."""
mock_start = AsyncMock(side_effect=RuntimeError("egress service unavailable")) mock_start = AsyncMock(side_effect=RuntimeError("egress service unavailable"))
monkeypatch.setattr(webhook_handlers_module, "start_track_egress", mock_start) monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
_use_test_session_in_background(monkeypatch, db_session)
await _set_transcriber_enabled(db_session, enabled=True)
conference = await _make_conference(db_session, generate_slug()) conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-track-egress-down@example.com") user = await _make_user(db_session, "webhook-track-egress-down@example.com")
await db_session.commit() await db_session.commit()
@@ -417,13 +474,169 @@ async def test_track_published_survives_egress_unavailable(
assert tracks == [] assert tracks == []
async def test_track_published_skips_egress_when_transcription_disabled(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Транскрибация выключена → egress не дёргается вовсе (релиз 0.0.12).
Симметрично guard'у в `_on_room_finished`. Без него на инстансе без профиля
`transcribe` (egress-контейнера в деплое нет) каждый микрофонный трек
превращался в заведомо безнадёжный вызов длиной в таймаут psrpc LiveKit —
2025 секунд внутри открытой транзакции вебхука. На нагрузочном тесте
28.07.2026 это выгребало пул соединений и роняло вход в конференцию в 500.
"""
mock_start = AsyncMock()
monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
_use_test_session_in_background(monkeypatch, db_session)
await _set_transcriber_enabled(db_session, enabled=False)
conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-track-transcriber-off@example.com")
await db_session.commit()
joined = _load_fixture(
"participant_joined.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
)
assert (await _post_webhook(client, joined)).status_code == 200
track_sid = "TR_transcriber_off"
published = _load_fixture(
"track_published.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
track_sid=track_sid,
)
assert (await _post_webhook(client, published)).status_code == 200
mock_start.assert_not_awaited()
tracks = (
await db_session.scalars(
select(SessionAudioTrack).where(SessionAudioTrack.track_sid == track_sid)
)
).all()
assert tracks == []
async def test_track_published_does_not_call_egress_inside_transaction(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Запуск egress уходит в фон, а не выполняется внутри обработчика (релиз 0.0.12).
Проверяется не время ответа (в тестах ASGI-транспорт дожидается фоновых
задач), а сама суть: пока открыта транзакция вебхука, сетевого вызова не
происходит — обработчик только планирует задачу. Именно это разгружает пул
соединений: до правки вызов жил внутри транзакции и держал соединение
2025 секунд, когда egress-сервиса в деплое нет.
"""
mock_start = AsyncMock(
return_value=EgressStartResult(egress_id="EG_bg", started_at=datetime.now(UTC))
)
monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
scheduled: list[tuple[object, dict[str, object]]] = []
def _schedule(func: object, **kwargs: object) -> None:
scheduled.append((func, kwargs))
await _set_transcriber_enabled(db_session, enabled=True)
conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-track-background@example.com")
await db_session.commit()
dispatcher = webhook_handlers_module.WebhookDispatcher(db_session, schedule=_schedule)
joined_event = _parse_fixture_event(
"participant_joined.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
)
await dispatcher.dispatch(joined_event)
track_sid = "TR_background"
published_event = _parse_fixture_event(
"track_published.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=str(user.id),
track_sid=track_sid,
)
await dispatcher.dispatch(published_event)
# Сеть не тронута: обработчик только запланировал задачу.
mock_start.assert_not_awaited()
assert len(scheduled) == 1
func, kwargs = scheduled[0]
assert func is egress_module.run_track_egress
assert kwargs["room_name"] == conference.slug
assert kwargs["track_sid"] == track_sid
session_record = await db_session.scalar(
select(ConferenceSession).where(ConferenceSession.conference_id == conference.id)
)
assert session_record is not None
assert kwargs["session_id"] == session_record.id
# А вот запущенная задача действительно ходит в egress и пишет строку.
_use_test_session_in_background(monkeypatch, db_session)
await egress_module.run_track_egress(**kwargs) # type: ignore[arg-type]
mock_start.assert_awaited_once()
track_row = await db_session.scalar(
select(SessionAudioTrack).where(SessionAudioTrack.track_sid == track_sid)
)
assert track_row is not None
assert track_row.egress_id == "EG_bg"
async def test_start_track_egress_gives_up_on_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
"""Запуск egress не ждёт дольше `egress_start_timeout_s` (релиз 0.0.12).
Когда egress-воркера нет, LiveKit держит вызов до собственного таймаута
psrpc — на тесте 28.07.2026 это было 2025 секунд на каждый микрофонный
трек. Живой egress отвечает за доли секунды, ждать столько незачем.
"""
settings = get_settings()
monkeypatch.setattr(settings, "egress_start_timeout_s", 0.05, raising=False)
closed = False
class _HangingEgress:
async def start_track_egress(self, _request: object) -> object:
await asyncio.sleep(5)
raise AssertionError("вызов должен был прерваться по таймауту")
class _HangingApi:
def __init__(self, *_args: object, **_kwargs: object) -> None:
self.egress = _HangingEgress()
async def aclose(self) -> None:
nonlocal closed
closed = True
# Строковая форма: `api` в `services.egress` — реэкспорт из livekit SDK,
# обращение к нему атрибутом mypy считает неявным экспортом.
monkeypatch.setattr("services.egress.api.LiveKitAPI", _HangingApi)
with pytest.raises(TimeoutError):
await egress_module.start_track_egress("room", "TR_hang", "/recordings/x.ogg")
# Клиент закрывается и на неуспешном пути — иначе утекали бы соединения.
assert closed is True
async def test_track_published_video_is_noop( async def test_track_published_video_is_noop(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
"""№9 плана (часть 1): video-трек — no-op, egress не запускается.""" """№9 плана (часть 1): video-трек — no-op, egress не запускается."""
mock_start = AsyncMock() mock_start = AsyncMock()
monkeypatch.setattr(webhook_handlers_module, "start_track_egress", mock_start) monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
_use_test_session_in_background(monkeypatch, db_session)
await _set_transcriber_enabled(db_session, enabled=True)
conference = await _make_conference(db_session, generate_slug()) conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-track-video@example.com") user = await _make_user(db_session, "webhook-track-video@example.com")
await db_session.commit() await db_session.commit()
@@ -462,8 +675,10 @@ async def test_track_published_repeated_webhook_creates_single_row(
mock_start = AsyncMock( mock_start = AsyncMock(
return_value=EgressStartResult(egress_id="EG_repeat", started_at=datetime.now(UTC)) return_value=EgressStartResult(egress_id="EG_repeat", started_at=datetime.now(UTC))
) )
monkeypatch.setattr(webhook_handlers_module, "start_track_egress", mock_start) monkeypatch.setattr(egress_module, "start_track_egress", mock_start)
_use_test_session_in_background(monkeypatch, db_session)
await _set_transcriber_enabled(db_session, enabled=True)
conference = await _make_conference(db_session, generate_slug()) conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-track-repeat@example.com") user = await _make_user(db_session, "webhook-track-repeat@example.com")
await db_session.commit() await db_session.commit()
@@ -508,6 +723,8 @@ async def test_egress_ended_finalizes_track_success_and_failure(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
"""№10 плана (часть 1): `egress_ended` — 'recorded' на успехе, 'failed' на ошибке.""" """№10 плана (часть 1): `egress_ended` — 'recorded' на успехе, 'failed' на ошибке."""
_use_test_session_in_background(monkeypatch, db_session)
await _set_transcriber_enabled(db_session, enabled=True)
conference = await _make_conference(db_session, generate_slug()) conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-egress-ended@example.com") user = await _make_user(db_session, "webhook-egress-ended@example.com")
await db_session.commit() await db_session.commit()
@@ -527,7 +744,7 @@ async def test_egress_ended_finalizes_track_success_and_failure(
# Успешная запись. # Успешная запись.
ok_started = datetime.now(UTC) ok_started = datetime.now(UTC)
monkeypatch.setattr( monkeypatch.setattr(
webhook_handlers_module, egress_module,
"start_track_egress", "start_track_egress",
AsyncMock(return_value=EgressStartResult(egress_id="EG_ok", started_at=ok_started)), AsyncMock(return_value=EgressStartResult(egress_id="EG_ok", started_at=ok_started)),
) )
@@ -559,7 +776,7 @@ async def test_egress_ended_finalizes_track_success_and_failure(
# Ошибка записи. # Ошибка записи.
monkeypatch.setattr( monkeypatch.setattr(
webhook_handlers_module, egress_module,
"start_track_egress", "start_track_egress",
AsyncMock( AsyncMock(
return_value=EgressStartResult(egress_id="EG_fail", started_at=datetime.now(UTC)) return_value=EgressStartResult(egress_id="EG_fail", started_at=datetime.now(UTC))
@@ -597,6 +814,10 @@ async def test_room_finished_enqueues_pipeline(
mock_enqueue = Mock() mock_enqueue = Mock()
monkeypatch.setattr(webhook_handlers_module, "enqueue_pipeline", mock_enqueue) monkeypatch.setattr(webhook_handlers_module, "enqueue_pipeline", mock_enqueue)
# Флаг выставляется явно: `_on_room_finished` ставит задачу в очередь только
# при включённой транскрибации, а состояние `instance_settings` в dev-БД
# непредсказуемо (тест падал, если в базе оставалось `enabled: false`).
await _set_transcriber_enabled(db_session, enabled=True)
conference = await _make_conference(db_session, generate_slug()) conference = await _make_conference(db_session, generate_slug())
await db_session.commit() await db_session.commit()

View File

@@ -20,6 +20,7 @@ from prometheus_client.samples import Sample
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from api import metrics as metrics_module from api import metrics as metrics_module
from core.config import get_settings
from core.redis import redis_client from core.redis import redis_client
from models.conference import Conference from models.conference import Conference
from models.session import ConferenceSession from models.session import ConferenceSession
@@ -57,6 +58,31 @@ async def test_metrics_endpoint_returns_prometheus_exposition_format(
assert "vidconf_http_request_duration_seconds" in families assert "vidconf_http_request_duration_seconds" in families
assert "vidconf_pipeline_sessions" in families assert "vidconf_pipeline_sessions" in families
assert "vidconf_celery_queue_depth" in families assert "vidconf_celery_queue_depth" in families
assert "vidconf_host_info" in families
async def test_metrics_host_info_gauge_reflects_settings(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""`vidconf_host_info` отдаёт `HW_*` из настроек как лейблы, значение всегда 1."""
settings = get_settings()
monkeypatch.setattr(settings, "hw_cpus", 4)
monkeypatch.setattr(settings, "hw_ram_mb", 7937)
monkeypatch.setattr(settings, "hw_gpu_name", None)
monkeypatch.setattr(settings, "hw_vram_mb", None)
response = await client.get("/metrics")
samples = _samples(response.text, "vidconf_host_info")
value = _sample_value(
samples,
suffix="vidconf_host_info",
cpus="4",
ram_mb="7937",
gpu_name="",
vram_mb="",
)
assert value == 1
async def test_metrics_records_http_latency_by_route(client: httpx.AsyncClient) -> None: async def test_metrics_records_http_latency_by_route(client: httpx.AsyncClient) -> None:

View File

@@ -60,6 +60,7 @@ class _FakeEmailBackend:
def __init__(self, *, fail_for: set[str] | None = None, retryable: bool = True) -> None: def __init__(self, *, fail_for: set[str] | None = None, retryable: bool = True) -> None:
self.sent: list[str] = [] self.sent: list[str] = []
self.reply_to: list[str | None] = []
self._fail_for = fail_for or set() self._fail_for = fail_for or set()
self._retryable = retryable self._retryable = retryable
@@ -71,10 +72,12 @@ class _FakeEmailBackend:
body: str, body: str,
html_body: str | None = None, html_body: str | None = None,
attachments: Any = (), attachments: Any = (),
reply_to: str | None = None,
) -> None: ) -> None:
if to in self._fail_for: if to in self._fail_for:
raise EmailSendError(f"сбой отправки для {to}", retryable=self._retryable) raise EmailSendError(f"сбой отправки для {to}", retryable=self._retryable)
self.sent.append(to) self.sent.append(to)
self.reply_to.append(reply_to)
class _Fixture: class _Fixture:
@@ -221,12 +224,19 @@ async def fx() -> AsyncGenerator[_Fixture, None]:
await conn.commit() await conn.commit()
def _cfg(*, summary_recipients: str = "all") -> InstanceConfig: def _cfg(
*,
summary_recipients: str = "all",
contact_email_enabled: bool = False,
contact_email: str | None = None,
) -> InstanceConfig:
return InstanceConfig( return InstanceConfig(
transcriber=TranscriberConfig(), transcriber=TranscriberConfig(),
summarizer=SummarizerConfig(), summarizer=SummarizerConfig(),
chat=ChatConfig(), chat=ChatConfig(),
summary_recipients=cast("Any", summary_recipients), summary_recipients=cast("Any", summary_recipients),
contact_email_enabled=contact_email_enabled,
contact_email=contact_email,
) )
@@ -291,6 +301,40 @@ async def test_notify_session_mode_owner_sends_only_to_owner(
assert await _fetch_session_status(fx.session_id) == "notified" assert await _fetch_session_status(fx.session_id) == "notified"
async def test_notify_session_sets_reply_to_when_contact_email_enabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Включённый контактный адрес инстанса — саммари-письмо несёт `Reply-To`."""
backend = _FakeEmailBackend()
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: backend)
await notify_session_async(
_FakeTask(),
fx.session_id,
plugins_config=_cfg(
summary_recipients="owner",
contact_email_enabled=True,
contact_email="contact@vidconf.example",
),
)
assert backend.reply_to == ["contact@vidconf.example"]
async def test_notify_session_no_reply_to_when_contact_email_disabled(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Контактный адрес выключен (дефолт) — `Reply-To` не проставляется."""
backend = _FakeEmailBackend()
monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: backend)
await notify_session_async(
_FakeTask(), fx.session_id, plugins_config=_cfg(summary_recipients="owner")
)
assert backend.reply_to == [None]
async def test_notify_session_conference_override_wins_over_instance_default( async def test_notify_session_conference_override_wins_over_instance_default(
fx: _Fixture, monkeypatch: pytest.MonkeyPatch fx: _Fixture, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:

View File

@@ -7,6 +7,8 @@
""" """
import logging import logging
from email.message import EmailMessage
from typing import cast
import aiosmtplib import aiosmtplib
import pytest import pytest
@@ -90,6 +92,33 @@ def test_build_message_includes_html_alternative_and_ics_attachment() -> None:
assert attachments[0].get_payload(decode=True) == b"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n" assert attachments[0].get_payload(decode=True) == b"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
def test_build_message_sets_reply_to_when_given() -> None:
message = _build_message(
sender="VidConf <no-reply@vidconf.example>",
to="user@example.com",
subject="Тема",
body="Тело",
html_body=None,
attachments=(),
reply_to="contact@vidconf.example",
)
assert message["Reply-To"] == "contact@vidconf.example"
def test_build_message_omits_reply_to_when_not_given() -> None:
message = _build_message(
sender="VidConf <no-reply@vidconf.example>",
to="user@example.com",
subject="Тема",
body="Тело",
html_body=None,
attachments=(),
)
assert message["Reply-To"] is None
def _backend() -> SmtpEmailBackend: def _backend() -> SmtpEmailBackend:
return SmtpEmailBackend( return SmtpEmailBackend(
hostname="smtp.example.com", hostname="smtp.example.com",
@@ -168,3 +197,23 @@ async def test_smtp_backend_send_success_calls_aiosmtplib(monkeypatch: pytest.Mo
"use_tls": False, "use_tls": False,
"timeout": 30, "timeout": 30,
} }
async def test_smtp_backend_send_passes_reply_to_into_message(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
async def _fake_send(message: object, **kwargs: object) -> tuple[dict[str, object], str]:
captured["message"] = message
return {}, "OK"
monkeypatch.setattr(aiosmtplib, "send", _fake_send)
backend = _backend()
await backend.send(
to="user@example.com", subject="Тема", body="Тело", reply_to="contact@vidconf.example"
)
message = cast(EmailMessage, captured["message"])
assert message["Reply-To"] == "contact@vidconf.example"

View File

@@ -82,7 +82,12 @@ 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.2} VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.12}
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
# (DB_POOL_SIZE + DB_MAX_OVERFLOW), а у Postgres есть max_connections.
UVICORN_WORKERS: ${UVICORN_WORKERS:-2}
# config/ лежит в корне репозитория и не попадает в образ (контекст сборки — # config/ лежит в корне репозитория и не попадает в образ (контекст сборки —
# только backend/), поэтому plugins.yaml монтируется отдельно. # только backend/), поэтому plugins.yaml монтируется отдельно.
volumes: volumes:
@@ -704,6 +709,91 @@ services:
profiles: ["monitoring"] profiles: ["monitoring"]
logging: *default-logging 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
# Метрики по контейнерам (CPU/память/сеть каждого отдельно) — замена
# cAdvisor, который на сервере `1gb` несовместим с Docker Engine
# (containerd-снапшоттер вместо classic overlay2 — подробности и
# перепробованные варианты см. `.forcc/JOURNAL.md`, раздел релиза 0.0.9).
# Вместо интроспекции graph-driver'а — Docker Engine API напрямую
# (`GET /containers/json` + `/stats`), он не зависит от storage-driver.
#
# Доступ к докер-сокету равносилен root на хосте (`:ro` при монтировании
# ограничивает права на файл-ноду, а не на протокол — через сокет можно
# поднять привилегированный контейнер и получить хост). Поэтому сокет
# монтируется СЮДА, в доверенный прокси-образ, а НЕ в наш собственный
# `container-exporter` ниже — прокси разрешает ему только
# `GET /containers/json` и `GET /containers/*/stats` (`CONTAINERS=1`),
# любые изменяющие запросы заблокированы (`POST=0`). Полная компрометация
# `container-exporter` не даёт управлять Docker. Обоснование и разбор —
# `docs/deploy/monitoring.md`.
docker-socket-proxy:
image: tecnativa/docker-socket-proxy:v0.5.0
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
CONTAINERS: 1
# POST=0 — дефолт образа; выписан явно, потому что от него зависит
# безопасность всей схемы (полагаться на невыписанный дефолт для
# критичного флага не стоит).
POST: 0
EVENTS: 0
# Не публикуем порт наружу вообще — container-exporter достаёт API
# по имени сервиса во внутренней сети compose, тот же принцип, что и
# у node-exporter/postgres-exporter выше.
healthcheck:
test: ["CMD", "wget", "-q", "-O-", "http://127.0.0.1:2375/_ping"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
profiles: ["monitoring"]
logging: *default-logging
container-exporter:
build:
context: ./monitoring/container-exporter
dockerfile: Dockerfile
restart: unless-stopped
environment:
DOCKER_API_BASE: "http://docker-socket-proxy:2375"
depends_on:
docker-socket-proxy:
condition: service_healthy
# Без ports — тот же принцип, что и у прокси выше; healthcheck
# прописан в самом Dockerfile экспортера (как у backend/worker).
profiles: ["monitoring"]
logging: *default-logging
grafana: grafana:
image: grafana/grafana:13.1.0 image: grafana/grafana:13.1.0
restart: unless-stopped restart: unless-stopped

View File

@@ -60,3 +60,59 @@ groups:
# суммаризация встанет (задачи будут копиться в очереди summarize, # суммаризация встанет (задачи будут копиться в очереди summarize,
# см. также алерт QueueGrowing). Проверить # см. также алерт QueueGrowing). Проверить
# `docker compose ps llm` / `llm-gpu` и `docker compose logs llm`. # `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 ГБ). Смотреть, какой контейнер ест
память — дашборд «Хост и контейнеры», топ по памяти
(container-exporter, см. deploy/monitoring/container-exporter/).
# 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
(дашборд «Хост и контейнеры») и латентность API — возможно,
не хватает уровня AI/ресурсов под нагрузку.

View File

@@ -0,0 +1,32 @@
FROM python:3.12-slim AS base
# uv binary (тот же приём, что и backend/Dockerfile).
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
PYTHONUNBUFFERED=1
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
COPY exporter.py ./
# Непривилегированный пользователь — сокет Docker сервис не видит вообще
# (доступ только к socket-proxy по сети compose), но принцип наименьших
# привилегий соблюдаем и здесь.
RUN useradd --no-create-home --shell /usr/sbin/nologin exporter
USER exporter
EXPOSE 9419
HEALTHCHECK --interval=10s --timeout=5s --retries=10 --start-period=15s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:9419/health')" || exit 1
# Не `uv run` — тому нужен писабельный кэш в $HOME, а у непривилегированного
# пользователя без домашней директории (--no-create-home выше) его нет;
# зависимости уже установлены в .venv на шаге сборки, `uv run` в рантайме
# не добавляет ничего, кроме этой проблемы.
CMD [".venv/bin/python", "exporter.py"]

View File

@@ -0,0 +1,234 @@
"""Экспортер метрик Prometheus по контейнерам поверх Docker Engine API.
Отдельный минимальный сервис (не часть backend — backend не должен иметь
доступ к докер-сокету, см. `docs/deploy/monitoring.md`). Опрашивает Docker
Engine API ЧЕРЕЗ `docker-socket-proxy` (`deploy/docker-compose.yml`) —
самому экспортеру `/var/run/docker.sock` не примонтирован, только доступ
по внутренней сети compose к прокси, который разрешает исключительно
`GET /containers/json` и `GET /containers/{id}/stats` (только чтение, без
прав управлять Docker — см. обоснование в `docs/deploy/monitoring.md`).
Стек — `aiohttp` (не `httpx`/FastAPI, как у backend): это отдельный
процесс вне зависимостей backend, а `aiohttp` даёт async HTTP-клиент и
сервер в одной небольшой библиотеке, без лишних зависимостей ради пяти
строк веб-сервера.
Метрики (КОНТРАКТ с `deploy/monitoring/prometheus.yml` и
`deploy/monitoring/grafana/dashboards/host.json` — при переименовании
поправить оба файла), лейбл везде — `name` (имя контейнера):
- `vidconf_container_cpu_percent` — доля CPU, использованная контейнером
за последний интервал опроса, тот же расчёт, что у `docker stats`
(0-100 на одно ядро, суммарно может быть больше 100 при использовании
нескольких ядер).
- `vidconf_container_memory_usage_bytes` / `_limit_bytes` — из
`stats.memory_stats`, `usage` очищен от переиспользуемого page cache
(`inactive_file`) тем же приёмом, что cAdvisor использовал для
`container_memory_working_set_bytes` — именно это число cgroup
учитывает при OOM. Без явного лимита памяти у сервиса в
docker-compose `limit` равен общему объёму RAM хоста — это не баг.
- `vidconf_container_network_rx_bytes` / `_tx_bytes` — суммарно по всем
сетевым интерфейсам контейнера, счётчик со старта контейнера
(обнуляется при пересоздании) — для скорости передачи взять `rate()`.
Опрос контейнеров идёт ПАРАЛЛЕЛЬНО (`asyncio.gather`) в фоновой задаче со
своим интервалом (`POLL_INTERVAL_S`), а не синхронно на каждый scrape
Prometheus: `GET /containers/{id}/stats?stream=false` не мгновенен (Docker
считает CPU-дельту по двум внутренним замерам с разницей ~1с — проверено
на реальном стенде), и линейный опрос дюжины контейнеров легко вышел бы за
scrape-интервал Prometheus (15с). Параллельный опрос укладывается в ~1-2с
независимо от числа контейнеров (проверено на 3 контейнерах, 4 запроса на
каждый параллельно — 2с суммарно). Фоновая задача пишет значения прямо в
метрики `prometheus_client` (они и есть кэш), обработчик `/metrics` не
трогает Docker API вообще — стабильная латентность ответа независимо от
состояния Docker API.
"""
import asyncio
import logging
import os
import time
from typing import Any
import aiohttp
from aiohttp import web
from prometheus_client import CONTENT_TYPE_LATEST, Gauge, generate_latest
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("container_exporter")
DOCKER_API_BASE = os.environ.get("DOCKER_API_BASE", "http://docker-socket-proxy:2375")
POLL_INTERVAL_S = float(os.environ.get("POLL_INTERVAL_S", "15"))
REQUEST_TIMEOUT_S = float(os.environ.get("REQUEST_TIMEOUT_S", "10"))
LISTEN_PORT = int(os.environ.get("PORT", "9419"))
CPU_PERCENT = Gauge(
"vidconf_container_cpu_percent",
"Использование CPU контейнером, % от одного ядра (как `docker stats`)",
labelnames=("name",),
)
MEMORY_USAGE_BYTES = Gauge(
"vidconf_container_memory_usage_bytes",
"Память контейнера без переиспользуемого page cache (working set)",
labelnames=("name",),
)
MEMORY_LIMIT_BYTES = Gauge(
"vidconf_container_memory_limit_bytes",
"Лимит памяти контейнера (= RAM хоста, если лимит не задан явно)",
labelnames=("name",),
)
NETWORK_RX_BYTES = Gauge(
"vidconf_container_network_rx_bytes",
"Байт получено контейнером по сети, суммарно по интерфейсам, с момента старта контейнера",
labelnames=("name",),
)
NETWORK_TX_BYTES = Gauge(
"vidconf_container_network_tx_bytes",
"Байт отправлено контейнером по сети, суммарно по интерфейсам, с момента старта контейнера",
labelnames=("name",),
)
class PollState:
"""Флаг успешности последнего опроса — источник `/health` для compose."""
last_poll_ok: bool = False
def _container_name(container: dict[str, Any]) -> str:
"""Имя контейнера без ведущего `/` (Docker API отдаёт список имён с ним)."""
names = container.get("Names") or []
if names:
return str(names[0]).lstrip("/")
return str(container["Id"])[:12]
def _cpu_percent(stats: dict[str, Any]) -> float | None:
"""Доля CPU за интервал между `precpu_stats` и `cpu_stats` — расчёт `docker stats` (CLI).
Оба поля Docker кладёт в один ответ `stats?stream=false` сам (снимает
дважды с разницей ~1с внутри демона) — считать дельту вручную между
двумя нашими запросами не нужно.
"""
cpu = stats.get("cpu_stats") or {}
precpu = stats.get("precpu_stats") or {}
cpu_total: int | None = (cpu.get("cpu_usage") or {}).get("total_usage")
precpu_total: int | None = (precpu.get("cpu_usage") or {}).get("total_usage")
system: int | None = cpu.get("system_cpu_usage")
presystem: int | None = precpu.get("system_cpu_usage")
# Первый снимок контейнера (только что создан) — precpu_stats пуст.
if cpu_total is None or precpu_total is None or system is None or presystem is None:
return None
cpu_delta = cpu_total - precpu_total
system_delta = system - presystem
if system_delta <= 0 or cpu_delta < 0:
return None
percpu_usage = (cpu.get("cpu_usage") or {}).get("percpu_usage") or []
online_cpus = cpu.get("online_cpus") or len(percpu_usage) or 1
return float((cpu_delta / system_delta) * online_cpus * 100.0)
def _apply_stats(name: str, stats: dict[str, Any]) -> None:
cpu_percent = _cpu_percent(stats)
if cpu_percent is not None:
CPU_PERCENT.labels(name=name).set(cpu_percent)
memory = stats.get("memory_stats") or {}
usage = memory.get("usage")
limit = memory.get("limit")
cache = (memory.get("stats") or {}).get("inactive_file", 0)
if usage is not None:
MEMORY_USAGE_BYTES.labels(name=name).set(max(usage - cache, 0))
if limit is not None:
MEMORY_LIMIT_BYTES.labels(name=name).set(limit)
networks: dict[str, Any] = stats.get("networks") or {}
if networks:
rx = sum(net.get("rx_bytes", 0) for net in networks.values())
tx = sum(net.get("tx_bytes", 0) for net in networks.values())
NETWORK_RX_BYTES.labels(name=name).set(rx)
NETWORK_TX_BYTES.labels(name=name).set(tx)
async def _fetch_and_apply(session: aiohttp.ClientSession, container: dict[str, Any]) -> None:
name = _container_name(container)
container_id = container["Id"]
timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT_S)
try:
async with session.get(
f"{DOCKER_API_BASE}/containers/{container_id}/stats",
params={"stream": "false"},
timeout=timeout,
) as response:
response.raise_for_status()
stats = await response.json(content_type=None)
except Exception:
logger.warning("не удалось получить stats контейнера %s", name, exc_info=True)
return
_apply_stats(name, stats)
async def _poll_once(session: aiohttp.ClientSession) -> None:
timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT_S)
async with session.get(f"{DOCKER_API_BASE}/containers/json", timeout=timeout) as response:
response.raise_for_status()
containers = await response.json(content_type=None)
# Параллельно — см. докстринг модуля про латентность stats-запроса.
await asyncio.gather(*(_fetch_and_apply(session, c) for c in containers))
async def _poll_loop(state: PollState) -> None:
async with aiohttp.ClientSession() as session:
while True:
start = time.monotonic()
try:
await _poll_once(session)
state.last_poll_ok = True
except Exception:
logger.exception("опрос Docker API завершился ошибкой")
state.last_poll_ok = False
elapsed = time.monotonic() - start
await asyncio.sleep(max(POLL_INTERVAL_S - elapsed, 1.0))
# `aiohttp.web.Response(content_type=...)` не принимает `charset` внутри
# строки типа (падает `ValueError`), а `CONTENT_TYPE_LATEST` из
# `prometheus_client` — "text/plain; version=0.0.4; charset=utf-8" целиком;
# разбираем один раз на media type (без `charset=...`) и сам charset.
_METRICS_MEDIA_TYPE = "; ".join(
part for part in CONTENT_TYPE_LATEST.split("; ") if not part.startswith("charset=")
)
async def metrics_handler(_request: web.Request) -> web.Response:
return web.Response(body=generate_latest(), content_type=_METRICS_MEDIA_TYPE, charset="utf-8")
def _make_health_handler(state: PollState) -> Any:
async def health_handler(_request: web.Request) -> web.Response:
if state.last_poll_ok:
return web.Response(text="ok")
return web.Response(text="опрос Docker API ещё не выполнялся успешно", status=503)
return health_handler
def create_app() -> web.Application:
app = web.Application()
state = PollState()
app.router.add_get("/metrics", metrics_handler)
app.router.add_get("/health", _make_health_handler(state))
async def on_startup(_app: web.Application) -> None:
app["poll_task"] = asyncio.create_task(_poll_loop(state))
async def on_cleanup(_app: web.Application) -> None:
app["poll_task"].cancel()
app.on_startup.append(on_startup)
app.on_cleanup.append(on_cleanup)
return app
if __name__ == "__main__":
web.run_app(create_app(), host="0.0.0.0", port=LISTEN_PORT)

View File

@@ -0,0 +1,26 @@
[project]
name = "vidconf-container-exporter"
version = "0.1.0"
description = "Метрики Prometheus по контейнерам поверх Docker Engine API (профиль monitoring, deploy/docker-compose.yml)."
requires-python = ">=3.12"
dependencies = [
"aiohttp>=3.10.0",
"prometheus-client>=0.25.0",
]
[dependency-groups]
dev = [
"mypy>=2.3.0",
"ruff>=0.15.21",
]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "ASYNC"]
[tool.mypy]
python_version = "3.12"
strict = true

View File

@@ -0,0 +1,747 @@
version = 1
revision = 3
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.15'",
"python_full_version < '3.15'",
]
[[package]]
name = "aiohappyeyeballs"
version = "2.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" },
]
[[package]]
name = "aiohttp"
version = "3.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
{ name = "aiosignal" },
{ name = "attrs" },
{ name = "frozenlist" },
{ name = "multidict" },
{ name = "propcache" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" },
{ url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" },
{ url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" },
{ url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" },
{ url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" },
{ url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" },
{ url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" },
{ url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" },
{ url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" },
{ url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" },
{ url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" },
{ url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" },
{ url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" },
{ url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" },
{ url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" },
{ url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" },
{ url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" },
{ url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" },
{ url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" },
{ url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" },
{ url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" },
{ url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" },
{ url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" },
{ url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" },
{ url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" },
{ url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" },
{ url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" },
{ url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" },
{ url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" },
{ url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" },
{ url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" },
{ url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" },
{ url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" },
{ url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" },
{ url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" },
{ url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" },
{ url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" },
{ url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" },
{ url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" },
{ url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" },
{ url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" },
{ url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" },
{ url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" },
{ url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" },
{ url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" },
{ url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" },
{ url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" },
{ url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" },
{ url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" },
{ url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" },
{ url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" },
{ url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" },
{ url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" },
{ url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" },
{ url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" },
{ url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" },
{ url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" },
{ url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" },
{ url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" },
{ url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" },
{ url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" },
{ url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" },
{ url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" },
{ url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" },
{ url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" },
{ url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" },
{ url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" },
{ url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" },
{ url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" },
{ url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" },
{ url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" },
{ url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" },
{ url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" },
{ url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" },
{ url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" },
{ url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" },
{ url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" },
]
[[package]]
name = "aiosignal"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "frozenlist" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
[[package]]
name = "ast-serialize"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" },
{ url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" },
{ url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" },
{ url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" },
{ url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" },
{ url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" },
{ url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" },
{ url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" },
{ url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" },
{ url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" },
{ url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" },
{ url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" },
{ url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" },
{ url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" },
{ url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" },
{ url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" },
{ url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" },
{ url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" },
{ url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" },
{ url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" },
{ url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" },
{ url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" },
{ url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" },
{ url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" },
{ url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" },
{ url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" },
{ url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" },
{ url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" },
{ url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" },
{ url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" },
{ url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" },
{ url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" },
]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "frozenlist"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" },
{ url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" },
{ url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" },
{ url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" },
{ url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" },
{ url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" },
{ url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" },
{ url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" },
{ url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" },
{ url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" },
{ url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" },
{ url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" },
{ url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" },
{ url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" },
{ url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" },
{ url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },
{ url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },
{ url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },
{ url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" },
{ url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" },
{ url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" },
{ url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" },
{ url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" },
{ url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" },
{ url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" },
{ url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" },
{ url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },
{ url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },
{ url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },
{ url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" },
{ url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" },
{ url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" },
{ url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" },
{ url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" },
{ url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" },
{ url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" },
{ url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" },
{ url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" },
{ url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" },
{ url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },
{ url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },
{ url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },
{ url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
{ url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
{ url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
{ url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
{ url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
{ url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
{ url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
{ url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
{ url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
{ url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
{ url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
{ url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
{ url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
{ url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
{ url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
{ url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
{ url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
{ url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
{ url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
{ url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
{ url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
{ url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
{ url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
{ url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
{ url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
{ url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "librt"
version = "0.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" },
{ url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" },
{ url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" },
{ url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" },
{ url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" },
{ url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" },
{ url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" },
{ url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" },
{ url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" },
{ url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" },
{ url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" },
{ url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" },
{ url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" },
{ url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" },
{ url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" },
{ url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" },
{ url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" },
{ url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" },
{ url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" },
{ url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" },
{ url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" },
{ url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" },
{ url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" },
{ url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" },
{ url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" },
{ url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" },
{ url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" },
{ url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" },
{ url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" },
{ url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" },
{ url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" },
{ url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" },
{ url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" },
{ url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" },
{ url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" },
{ url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" },
{ url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" },
{ url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" },
{ url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" },
{ url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" },
{ url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" },
{ url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" },
{ url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" },
{ url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" },
{ url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" },
{ url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" },
{ url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" },
{ url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" },
{ url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" },
{ url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" },
{ url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" },
]
[[package]]
name = "multidict"
version = "6.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" },
{ url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" },
{ url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" },
{ url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" },
{ url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" },
{ url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" },
{ url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" },
{ url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" },
{ url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" },
{ url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" },
{ url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" },
{ url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" },
{ url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" },
{ url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" },
{ url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" },
{ url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" },
{ url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" },
{ url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" },
{ url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" },
{ url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" },
{ url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" },
{ url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" },
{ url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" },
{ url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" },
{ url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" },
{ url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" },
{ url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" },
{ url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" },
{ url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" },
{ url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" },
{ url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" },
{ url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" },
{ url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" },
{ url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" },
{ url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" },
{ url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" },
{ url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" },
{ url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" },
{ url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" },
{ url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" },
{ url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" },
{ url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" },
{ url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" },
{ url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" },
{ url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" },
{ url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" },
{ url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" },
{ url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" },
{ url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" },
{ url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" },
{ url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" },
{ url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" },
{ url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" },
{ url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" },
{ url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" },
{ url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" },
{ url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" },
{ url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" },
{ url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" },
{ url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" },
{ url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" },
{ url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" },
{ url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" },
{ url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" },
{ url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },
{ url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },
{ url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },
{ url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" },
{ url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" },
{ url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" },
{ url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" },
{ url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" },
{ url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" },
{ url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" },
{ url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" },
{ url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" },
{ url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" },
{ url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" },
{ url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" },
{ url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" },
{ url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" },
{ url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },
{ url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
]
[[package]]
name = "mypy"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ast-serialize" },
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" },
{ url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" },
{ url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" },
{ url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" },
{ url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" },
{ url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" },
{ url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" },
{ url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" },
{ url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" },
{ url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" },
{ url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" },
{ url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" },
{ url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" },
{ url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" },
{ url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" },
{ url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" },
{ url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" },
{ url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" },
{ url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" },
{ url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" },
{ url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" },
{ url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" },
{ url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" },
{ url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" },
{ url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" },
{ url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" },
{ url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" },
{ url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "pathspec"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
[[package]]
name = "prometheus-client"
version = "0.26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" },
]
[[package]]
name = "propcache"
version = "0.5.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" },
{ url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" },
{ url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" },
{ url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" },
{ url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" },
{ url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" },
{ url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" },
{ url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" },
{ url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" },
{ url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" },
{ url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" },
{ url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" },
{ url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" },
{ url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" },
{ url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" },
{ url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" },
{ url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" },
{ url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" },
{ url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" },
{ url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" },
{ url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" },
{ url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" },
{ url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" },
{ url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" },
{ url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" },
{ url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" },
{ url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" },
{ url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" },
{ url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" },
{ url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" },
{ url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" },
{ url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" },
{ url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" },
{ url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" },
{ url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" },
{ url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" },
{ url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" },
{ url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" },
{ url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" },
{ url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" },
{ url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" },
{ url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" },
{ url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" },
{ url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" },
{ url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" },
{ url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" },
{ url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" },
{ url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" },
{ url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" },
{ url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" },
{ url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" },
{ url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" },
{ url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" },
{ url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" },
{ url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" },
{ url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" },
{ url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" },
{ url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" },
{ url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" },
{ url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" },
{ url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" },
{ url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" },
{ url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" },
{ url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" },
{ url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" },
{ url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" },
{ url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" },
{ url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" },
{ url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" },
{ url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" },
{ url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" },
{ url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" },
{ url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" },
{ url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" },
{ url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" },
{ url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" },
]
[[package]]
name = "ruff"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" },
{ url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" },
{ url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" },
{ url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" },
{ url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" },
{ url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" },
{ url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" },
{ url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" },
{ url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" },
{ url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" },
{ url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" },
{ url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" },
{ url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" },
{ url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" },
{ url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "vidconf-container-exporter"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "aiohttp" },
{ name = "prometheus-client" },
]
[package.dev-dependencies]
dev = [
{ name = "mypy" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = ">=3.10.0" },
{ name = "prometheus-client", specifier = ">=0.25.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "mypy", specifier = ">=2.3.0" },
{ name = "ruff", specifier = ">=0.15.21" },
]
[[package]]
name = "yarl"
version = "1.24.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "multidict" },
{ name = "propcache" },
]
sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" },
{ url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" },
{ url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" },
{ url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" },
{ url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" },
{ url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" },
{ url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" },
{ url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" },
{ url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" },
{ url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" },
{ url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" },
{ url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" },
{ url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" },
{ url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" },
{ url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" },
{ url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" },
{ url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" },
{ url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" },
{ url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" },
{ url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" },
{ url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" },
{ url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" },
{ url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" },
{ url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" },
{ url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" },
{ url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" },
{ url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" },
{ url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" },
{ url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" },
{ url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" },
{ url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" },
{ url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" },
{ url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" },
{ url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" },
{ url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" },
{ url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" },
{ url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" },
{ url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" },
{ url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" },
{ url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" },
{ url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" },
{ url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" },
{ url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" },
{ url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" },
{ url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" },
{ url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" },
{ url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" },
{ url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" },
{ url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" },
{ url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" },
{ url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" },
{ url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" },
{ url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" },
{ url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" },
{ url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" },
{ url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" },
{ url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" },
{ url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" },
{ url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" },
{ url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" },
{ url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" },
{ url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" },
{ url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" },
{ url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" },
{ url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" },
]

View File

@@ -0,0 +1,354 @@
{
"title": "Хост и контейнеры",
"description": "CPU/RAM/диск/сеть хоста (node-exporter), топ контейнеров по CPU/памяти (container-exporter поверх Docker Engine API — замена cAdvisor, несовместимого с containerd-снапшоттером сервера 1gb, см. .forcc/JOURNAL.md) и характеристики сервера — профиль 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": "Источник — container-exporter (job container-exporter, свой сервис поверх Docker Engine API, см. deploy/monitoring/container-exporter/). Отвечает на вопрос «кто из контейнеров грузит CPU» при срабатывании HostCpuHigh. % от одного ядра — как `docker stats`, суммарно может быть больше 100 при нескольких ядрах.",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 20 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "unit": "percent", "custom": { "drawStyle": "line", "fillOpacity": 10, "stacking": { "mode": "normal" } } },
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "topk(5, vidconf_container_cpu_percent)",
"legendFormat": "{{name}}",
"refId": "A"
}
]
},
{
"id": 11,
"title": "Топ контейнеров по памяти",
"description": "vidconf_container_memory_usage_bytes — usage без переиспользуемого page cache (working set), источник — container-exporter.",
"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, vidconf_container_memory_usage_bytes)",
"legendFormat": "{{name}}",
"refId": "A"
}
]
},
{
"id": 12,
"title": "Ядра CPU",
"description": "Число ядер хоста (node-exporter, живое значение — работает на любой инсталляции, не только на 1gb).",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 36 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "none",
"decimals": 0,
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "count(count by (cpu) (node_cpu_seconds_total{mode=\"idle\"}))",
"refId": "A"
}
]
},
{
"id": 13,
"title": "Объём RAM",
"description": "Полный объём памяти хоста (node-exporter, живое значение).",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 36 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "decbytes",
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "node_memory_MemTotal_bytes",
"refId": "A"
}
]
},
{
"id": 14,
"title": "Диск (/)",
"description": "Полный размер корневого раздела (node-exporter, живое значение) — не путать с панелью «Свободно на диске» выше (там остаток).",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 36 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "decbytes",
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "node_filesystem_size_bytes{mountpoint=\"/\",fstype!=\"tmpfs\"}",
"refId": "A"
}
]
},
{
"id": 15,
"title": "GPU",
"description": "Обнаруженная install.sh видеокарта (backend, gauge vidconf_host_info — HW_GPU_NAME из .env, ADR-004). «—» — видеокарты нет либо install.sh не запускался (dev-окружение). Таблица, а не stat-панель: у stat-панели в режиме отображения имени серии свой алгоритм автоподбора размера шрифта, который для однознаковых значений вроде «—» рисует нечитаемо мелкий текст.",
"type": "table",
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 36 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "custom": { "align": "center" } },
"overrides": []
},
"options": {
"showHeader": true,
"cellHeight": "lg"
},
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"Value": true,
"__name__": true,
"cpus": true,
"ram_mb": true,
"instance": true,
"job": true
},
"renameByName": { "gpu_name": "Модель", "vram_mb": "VRAM, МБ" }
}
}
],
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "vidconf_host_info",
"format": "table",
"instant": true,
"refId": "A"
}
]
}
]
}

View File

@@ -1,6 +1,7 @@
# Конфигурация Prometheus (devops) — сбор метрик backend, # Конфигурация Prometheus (devops) — сбор метрик backend,
# PostgreSQL, Redis и локального LLM-сервера. Поднимается compose-профилем # PostgreSQL, Redis, железа хоста, контейнеров и локального LLM-сервера.
# `monitoring` (deploy/docker-compose.yml, сервис `prometheus`). # Поднимается compose-профилем `monitoring` (deploy/docker-compose.yml,
# сервис `prometheus`).
# #
# Имена метрик backend (`vidconf_http_request_duration_seconds`, # Имена метрик backend (`vidconf_http_request_duration_seconds`,
# `vidconf_pipeline_sessions`, `vidconf_celery_queue_depth`) — КОНТРАКТ с # `vidconf_pipeline_sessions`, `vidconf_celery_queue_depth`) — КОНТРАКТ с
@@ -33,6 +34,25 @@ scrape_configs:
static_configs: static_configs:
- targets: ["redis-exporter:9121"] - 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, сервис
# `container-exporter`). Отвечает на вопрос «какой из контейнеров ест
# ресурсы», в отличие от job `node` (только хост целиком). Замена
# cAdvisor (несовместим с containerd-снапшоттером сервера `1gb`, см.
# .forcc/JOURNAL.md) — свой экспортер поверх Docker Engine API,
# метрики `vidconf_container_*`, см. deploy/monitoring/container-exporter/.
- job_name: container-exporter
static_configs:
- targets: ["container-exporter:9419"]
# Локальный 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

@@ -10,11 +10,68 @@
| `prometheus` | `prom/prometheus:v3.13.1` | `9090` | сбор и хранение метрик, оценка правил алертинга | | `prometheus` | `prom/prometheus:v3.13.1` | `9090` | сбор и хранение метрик, оценка правил алертинга |
| `postgres-exporter` | `quay.io/prometheuscommunity/postgres-exporter:v0.20.1` | — (внутренний) | метрики PostgreSQL | | `postgres-exporter` | `quay.io/prometheuscommunity/postgres-exporter:v0.20.1` | — (внутренний) | метрики PostgreSQL |
| `redis-exporter` | `oliver006/redis_exporter:v1.87.0-alpine` | — (внутренний) | метрики Redis | | `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 |
| `docker-socket-proxy` | `tecnativa/docker-socket-proxy:v0.5.0` | — (внутренний) | read-only прокси к Docker Engine API для `container-exporter` |
| `container-exporter` | сборка из `deploy/monitoring/container-exporter/` | — (внутренний) | метрики CPU/память/сеть в разбивке по контейнерам |
| `grafana` | `grafana/grafana:13.1.0` | `3001` (внутри контейнера `3000`) | дашборды «Пайплайны пост-обработки» и «Хост и контейнеры» |
`node-exporter`/`docker-socket-proxy`/`container-exporter` не публикуют
порт на хост вообще (не только 127.0.0.1) — Prometheus и сам экспортер
ходят друг к другу по имени сервиса во внутренней сети compose,
публикация на хост для этого не нужна.
### 1.1. Метрики по контейнерам — свой экспортер вместо cAdvisor
**cAdvisor не используется**: Docker Engine на сервере `1gb` использует
containerd-снапшоттер (`docker info``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 с containerd image store, флагами не
чинится — см. `.forcc/JOURNAL.md`.
Вместо этого — свой минимальный экспортер (`container-exporter`,
`deploy/monitoring/container-exporter/exporter.py`, Python/aiohttp/
`prometheus_client`) поверх Docker Engine API: `GET /containers/json` +
`GET /containers/{id}/stats?stream=false`. Эти эндпойнты не зависят от
storage-driver'а — работают и с containerd-снапшоттером, и с overlay2.
Опрос контейнеров идёт параллельно в фоновой задаче (свой интервал,
не завязан на scrape Prometheus) — `stats?stream=false` не мгновенен
(Docker считает CPU-дельту по двум внутренним замерам с разницей ~1с),
линейный опрос дюжины контейнеров легко вышел бы за scrape-интервал.
Метрики (лейбл `name` — имя контейнера):
- `vidconf_container_cpu_percent` — % от одного ядра, как `docker stats`.
- `vidconf_container_memory_usage_bytes` / `_limit_bytes` — working set
(без переиспользуемого page cache) и лимит памяти.
- `vidconf_container_network_rx_bytes` / `_tx_bytes` — суммарно по
интерфейсам, счётчик со старта контейнера.
**Безопасность: доступ к `/var/run/docker.sock` равносилен root на
хосте.** Флаг `:ro` при монтировании сокета не спасает — он ограничивает
права на файл-ноду, а не на протокол: через сокет можно поднять
привилегированный контейнер и получить хост. Поэтому сокет НЕ
монтируется напрямую в `container-exporter` — между ними стоит
`docker-socket-proxy` (`tecnativa/docker-socket-proxy`), которому
разрешены только `GET`-запросы по контейнерам (`CONTAINERS=1`, `POST=0`
— дефолт образа, выписан явно). Даже полная компрометация
`container-exporter` (например, через уязвимость в его зависимостях) не
даёт управлять Docker — прокси физически не пропустит `POST`. Ни один
из двух сервисов не публикует портов наружу.
Если на другом сервере используется классический overlay2 и хочется
именно cAdvisor — можно добавить его по тому же образцу, что и
`node-exporter` (свой job в `prometheus.yml`, без публикации портов),
но тогда придётся решать ту же проблему монтирования сокета отдельно
(cAdvisor тоже требует доступ к Docker API).
Файлы: `deploy/monitoring/prometheus.yml`, `deploy/monitoring/alerts.yml`, Файлы: `deploy/monitoring/prometheus.yml`, `deploy/monitoring/alerts.yml`,
`deploy/monitoring/grafana/provisioning/` (datasource + провайдер `deploy/monitoring/grafana/provisioning/` (datasource + провайдер
дашбордов), `deploy/monitoring/grafana/dashboards/pipelines.json`. дашбордов), `deploy/monitoring/grafana/dashboards/pipelines.json`,
`deploy/monitoring/grafana/dashboards/host.json`,
`deploy/monitoring/container-exporter/` (код экспортера).
## 2. Запуск ## 2. Запуск
@@ -37,9 +94,9 @@ ssh -L 9090:127.0.0.1:9090 -L 3001:127.0.0.1:3001 <user>@<host>
``` ```
и открывайте `http://localhost:9090` / `http://localhost:3001` у себя. и открывайте `http://localhost:9090` / `http://localhost:3001` у себя.
Дашборд «Пайплайны пост-обработки» (папка VidConf в Grafana) появляется Дашборды «Пайплайны пост-обработки» и «Хост и контейнеры» (папка VidConf
сразу — источник данных и дашборд провижинятся из файлов, без ручной в Grafana) появляются сразу — источник данных и дашборды провижинятся из
настройки. файлов, без ручной настройки.
## 3. Метрики backend ## 3. Метрики backend
@@ -56,6 +113,13 @@ ssh -L 9090:127.0.0.1:9090 -L 3001:127.0.0.1:3001 <user>@<host>
- `vidconf_celery_queue_depth` (gauge, `queue`) — глубина очередей Celery - `vidconf_celery_queue_depth` (gauge, `queue`) — глубина очередей Celery
(`transcription`/`summarize`/`notify`/`celery`, redis `LLEN`), карта (`transcription`/`summarize`/`notify`/`celery`, redis `LLEN`), карта
очередей — `docs/deploy/scaling.md`. очередей — `docs/deploy/scaling.md`.
- `vidconf_host_info` (gauge, всегда `1`, лейблы `cpus`/`ram_mb`/`gpu_name`/
`vram_mb`) — info-метрика обнаруженного `install.sh` железа (`HW_*` в
`.env`, ADR-004). Источник плашки с характеристиками сервера в дашборде
«Хост и контейнеры» — живые CPU/RAM/диск там же берутся из
node-exporter, а GPU node-exporter не знает вообще, поэтому только эта
метрика. `«—»` в лейбле — `install.sh` не запускался (dev) либо
видеокарта не обнаружена.
Job `llm` в `prometheus.yml` скрейпит `llm:8080/metrics` Job `llm` в `prometheus.yml` скрейпит `llm:8080/metrics`
(`LLAMA_ARG_ENDPOINT_METRICS=1`) — этот адрес резолвится ЛИБО сервисом (`LLAMA_ARG_ENDPOINT_METRICS=1`) — этот адрес резолвится ЛИБО сервисом
@@ -78,6 +142,13 @@ Job `llm` в `prometheus.yml` скрейпит `llm:8080/metrics`
| `PipelineFailed` | рост числа сеансов в статусе `failed` за 15 минут | critical | | `PipelineFailed` | рост числа сеансов в статусе `failed` за 15 минут | critical |
| `QueueGrowing` | глубина очереди растёт 15 минут подряд и превышает 10 задач | warning | | `QueueGrowing` | глубина очереди растёт 15 минут подряд и превышает 10 задач | warning |
| `LlmDown` | `up{job="llm"} == 0` дольше 2 минут | critical (закомментирован по умолчанию) | | `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` `LlmDown` актуален только на инсталляциях с профилем `llm`/`llm-gpu`
(пресеты 35) — по умолчанию (профили `media,monitoring`, без AI) правило (пресеты 35) — по умолчанию (профили `media,monitoring`, без AI) правило

View File

@@ -32,8 +32,13 @@ export interface SettingsOut {
registration_team_choice: boolean registration_team_choice: boolean
/** Включена ли верификация регистрации по домену корпоративной почты. */ /** Включена ли верификация регистрации по домену корпоративной почты. */
registration_email_domain_enabled: boolean registration_email_domain_enabled: boolean
/** Эталонный домен для верификации (напр. `company.ru`) — `null`, если верификация выключена. */ /** Эталонные домены для верификации (напр. `['company.ru']`) — email подходит, если
registration_email_domain: string | null * совпадает с любым из списка; пуст, если верификация выключена. */
registration_email_domains: string[]
/** Включён ли контактный адрес инстанса (подставляется в `Reply-To` исходящих писем). */
contact_email_enabled: boolean
/** Контактный адрес — `null`, если не задан/выключен. */
contact_email: string | null
} }
/** Тело частичного обновления настроек инстанса — все поля опциональны. */ /** Тело частичного обновления настроек инстанса — все поля опциональны. */
@@ -45,9 +50,27 @@ export interface SettingsUpdateIn {
summary_recipients?: SummaryRecipientsMode summary_recipients?: SummaryRecipientsMode
display_timezone?: string display_timezone?: string
registration_team_choice?: boolean registration_team_choice?: boolean
/** Включение без домена или невалидный домен — backend отвечает 400. */ /** Включение с пустым списком или невалидным доменом — backend отвечает 400. */
registration_email_domain_enabled?: boolean 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
}
/** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */
export interface TestEmailIn {
/** Не задан — backend отправит на email текущего администратора. */
to?: string | null
}
/** Результат тестовой отправки — успех или текст ошибки транспорта. */
export interface TestEmailOut {
success: boolean
message: string
/** Хост/порт SMTP — только при `EMAIL_BACKEND=smtp`, без логина/пароля. */
smtp_host: string | null
smtp_port: number | null
} }
/** Конференция в ответе админ-списка — `ConferenceOut` + сведения о владельце. */ /** Конференция в ответе админ-списка — `ConferenceOut` + сведения о владельце. */
@@ -114,6 +137,8 @@ export type AdminUserDetailOut = AdminUserOut
/** Параметры выборки списка пользователей. */ /** Параметры выборки списка пользователей. */
export interface AdminUserQuery { export interface AdminUserQuery {
/** Фильтр по блокировке — без параметра отдаются все пользователи. */
status?: 'active' | 'blocked'
q?: string q?: string
limit?: number limit?: number
offset?: number offset?: number
@@ -198,7 +223,7 @@ export async function sendConferenceInvitations(id: string, emails?: string[]):
/** Список пользователей для админки — с поиском и пагинацией. */ /** Список пользователей для админки — с поиском и пагинацией. */
export async function listAdminUsers(query: AdminUserQuery = {}): Promise<PagedResult<AdminUserOut>> { 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}`) return apiRequest<PagedResult<AdminUserOut>>(`/admin/users${qs}`)
} }
@@ -238,6 +263,14 @@ export async function updateAdminSettings(payload: SettingsUpdateIn): Promise<Se
return apiRequest<SettingsOut>('/admin/settings', { method: 'PUT', body: payload }) return apiRequest<SettingsOut>('/admin/settings', { method: 'PUT', body: payload })
} }
/**
* Отправить тестовое письмо синхронно — проверка почтовой конфигурации.
* Результат приходит в теле ответа (`success`/`message`), не через HTTP-статус ошибки.
*/
export async function sendTestEmail(payload: TestEmailIn = {}): Promise<TestEmailOut> {
return apiRequest<TestEmailOut>('/admin/settings/test-email', { method: 'POST', body: payload })
}
/** Список команд для админки — с пагинацией. */ /** Список команд для админки — с пагинацией. */
export async function listAdminTeams(query: AdminTeamQuery = {}): Promise<PagedResult<TeamOut>> { export async function listAdminTeams(query: AdminTeamQuery = {}): Promise<PagedResult<TeamOut>> {
const qs = toQueryString({ limit: query.limit, offset: query.offset }) const qs = toQueryString({ limit: query.limit, offset: query.offset })

View File

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

View File

@@ -18,10 +18,10 @@ import { formatRecurrenceSummary } from '@/lib/recurrenceFormat'
const PAGE_SIZE = 10 const PAGE_SIZE = 10
const STATUS_FILTERS: { value: ConferenceStatus | 'all'; label: string }[] = [ const STATUS_FILTERS: { value: ConferenceStatus | 'all'; label: string }[] = [
{ value: 'all', label: 'Все' },
{ value: 'scheduled', label: 'Запланированные' }, { value: 'scheduled', label: 'Запланированные' },
{ value: 'active', label: 'Активные' }, { value: 'active', label: 'Активные' },
{ value: 'ended', label: 'Завершённые' }, { value: 'ended', label: 'Завершённые' },
{ value: 'all', label: 'Все' },
] ]
function StatusBadge({ conference }: { conference: AdminConferenceOut }) { function StatusBadge({ conference }: { conference: AdminConferenceOut }) {
@@ -50,7 +50,18 @@ function StatusBadge({ conference }: { conference: AdminConferenceOut }) {
} }
/** Форма быстрого редактирования конференции из админки (title/время/пароль/рассылка). */ /** Форма быстрого редактирования конференции из админки (title/время/пароль/рассылка). */
function EditConferenceModal({ conference, onClose }: { conference: AdminConferenceOut; onClose: () => void }) { function EditConferenceModal({
conference,
onClose,
onDelete,
deleting,
}: {
conference: AdminConferenceOut
onClose: () => void
/** Удаление конференции — та же мутация, что и у корзины в строке таблицы (см. AdminConferencesTab). */
onDelete: () => void
deleting: boolean
}) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const toast = useToast() const toast = useToast()
const initialSlot = conference.scheduled_at ? toLocalDateTimeParts(conference.scheduled_at) : { date: '', time: '' } const initialSlot = conference.scheduled_at ? toLocalDateTimeParts(conference.scheduled_at) : { date: '', time: '' }
@@ -64,6 +75,7 @@ function EditConferenceModal({ conference, onClose }: { conference: AdminConfere
const [summaryRecipients, setSummaryRecipients] = useState<'' | SummaryRecipientsMode>( const [summaryRecipients, setSummaryRecipients] = useState<'' | SummaryRecipientsMode>(
conference.summary_recipients ?? '', conference.summary_recipients ?? '',
) )
const [confirmDelete, setConfirmDelete] = useState(false)
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (payload: AdminConferenceUpdatePayload) => updateAdminConference(conference.id, payload), mutationFn: (payload: AdminConferenceUpdatePayload) => updateAdminConference(conference.id, payload),
@@ -179,7 +191,24 @@ function EditConferenceModal({ conference, onClose }: { conference: AdminConfere
</select> </select>
</div> </div>
<div className="modal-actions"> <div className="modal-actions modal-actions--split">
{confirmDelete ? (
<div className="row-actions">
<span className="field-hint">Удалить без возможности восстановления?</span>
<button type="button" className="btn btn-secondary" onClick={() => setConfirmDelete(false)}>
Нет
</button>
<button type="button" className="btn btn-danger" disabled={deleting} onClick={onDelete}>
{deleting ? 'Удаляем…' : 'Да, удалить'}
</button>
</div>
) : (
<button type="button" className="btn btn-danger" onClick={() => setConfirmDelete(true)}>
<Trash2 style={{ width: 16, height: 16 }} aria-hidden="true" />
Удалить
</button>
)}
<div className="modal-actions-group">
<button type="button" className="btn btn-secondary" onClick={onClose}> <button type="button" className="btn btn-secondary" onClick={onClose}>
Отмена Отмена
</button> </button>
@@ -187,6 +216,7 @@ function EditConferenceModal({ conference, onClose }: { conference: AdminConfere
{mutation.isPending ? 'Сохраняем…' : 'Сохранить'} {mutation.isPending ? 'Сохраняем…' : 'Сохранить'}
</button> </button>
</div> </div>
</div>
</form> </form>
</div> </div>
</div> </div>
@@ -257,7 +287,7 @@ function InviteModal({ conference, onClose }: { conference: AdminConferenceOut;
* блок «table-card», адаптированный под сущность «конференция»). * блок «table-card», адаптированный под сущность «конференция»).
*/ */
export function AdminConferencesTab() { export function AdminConferencesTab() {
const [statusFilter, setStatusFilter] = useState<ConferenceStatus | 'all'>('all') const [statusFilter, setStatusFilter] = useState<ConferenceStatus | 'all'>('scheduled')
const [searchInput, setSearchInput] = useState('') const [searchInput, setSearchInput] = useState('')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [offset, setOffset] = useState(0) const [offset, setOffset] = useState(0)
@@ -293,6 +323,9 @@ export function AdminConferencesTab() {
queryClient.invalidateQueries({ queryKey: ['admin', 'conferences'] }) queryClient.invalidateQueries({ queryKey: ['admin', 'conferences'] })
toast.show('Конференция удалена', 'success') toast.show('Конференция удалена', 'success')
setConfirmingDeleteId(null) setConfirmingDeleteId(null)
// Удаление могло прийти и из модалки редактирования (кнопка «Удалить»
// там дёргает эту же мутацию) — закрываем её тоже, если открыта.
setEditingConference(null)
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409) { if (err instanceof ApiError && err.status === 409) {
@@ -340,7 +373,7 @@ export function AdminConferencesTab() {
</span> </span>
</div> </div>
<div className="table-card"> <div className="table-card conferences-table">
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -363,11 +396,16 @@ export function AdminConferencesTab() {
{items.map((conf) => ( {items.map((conf) => (
<tr key={conf.id}> <tr key={conf.id}>
<td> <td>
{/* На мобильном это единственный вход в редактирование строки (см. .conferences-table
в admin.css — остальные колонки там скрыты), на десктопе — то же самое, что кнопка
«Редактировать» правее, просто более крупная область клика. */}
<button type="button" className="conf-cell-name" onClick={() => setEditingConference(conf)}>
{conf.is_closed && <Lock style={{ width: 14, height: 14, marginRight: 6, verticalAlign: '-2px' }} aria-hidden="true" />} {conf.is_closed && <Lock style={{ width: 14, height: 14, marginRight: 6, verticalAlign: '-2px' }} aria-hidden="true" />}
{conf.title ?? 'Без названия'} {conf.title ?? 'Без названия'}
<span style={{ display: 'block', font: 'var(--text-caption)', color: 'var(--color-ink-500)' }}> <span style={{ display: 'block', font: 'var(--text-caption)', color: 'var(--color-ink-500)' }}>
{conf.number} {conf.number}
</span> </span>
</button>
</td> </td>
<td> <td>
{conf.owner_name ?? '—'} {conf.owner_name ?? '—'}
@@ -443,7 +481,14 @@ export function AdminConferencesTab() {
</div> </div>
)} )}
{editingConference && <EditConferenceModal conference={editingConference} onClose={() => setEditingConference(null)} />} {editingConference && (
<EditConferenceModal
conference={editingConference}
onClose={() => setEditingConference(null)}
onDelete={() => deleteMutation.mutate(editingConference.id)}
deleting={deleteMutation.isPending}
/>
)}
{invitingConference && <InviteModal conference={invitingConference} onClose={() => setInvitingConference(null)} />} {invitingConference && <InviteModal conference={invitingConference} onClose={() => setInvitingConference(null)} />}
</> </>
) )

View File

@@ -1,9 +1,18 @@
import { useState } from 'react' import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle } from 'lucide-react' import { AlertTriangle, X } from 'lucide-react'
import { getAdminSettings, updateAdminSettings, type AiLevel, type SettingsOut, type SettingsUpdateIn } from '@/api/admin' import {
getAdminSettings,
sendTestEmail,
updateAdminSettings,
type AiLevel,
type SettingsOut,
type SettingsUpdateIn,
type TestEmailOut,
} from '@/api/admin'
import type { SummaryRecipientsMode } from '@/api/conferences' import type { SummaryRecipientsMode } from '@/api/conferences'
import { ApiError, errorDetail } from '@/api/client' import { ApiError, errorDetail } from '@/api/client'
import { useAuth } from '@/auth/useAuth'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { useToast } from '@/components/ui/ToastProvider' import { useToast } from '@/components/ui/ToastProvider'
@@ -42,6 +51,7 @@ export function AdminSettingsTab() {
function AdminSettingsForm({ data }: { data: SettingsOut }) { function AdminSettingsForm({ data }: { data: SettingsOut }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const toast = useToast() const toast = useToast()
const { user } = useAuth()
const [chatEnabled, setChatEnabled] = useState(data.chat_enabled) const [chatEnabled, setChatEnabled] = useState(data.chat_enabled)
const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled) const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled)
@@ -50,7 +60,12 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
const [timezone, setTimezone] = useState(data.display_timezone) const [timezone, setTimezone] = useState(data.display_timezone)
const [teamChoiceEnabled, setTeamChoiceEnabled] = useState(data.registration_team_choice) const [teamChoiceEnabled, setTeamChoiceEnabled] = useState(data.registration_team_choice)
const [domainVerificationEnabled, setDomainVerificationEnabled] = useState(data.registration_email_domain_enabled) 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('')
const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null)
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (payload: SettingsUpdateIn) => updateAdminSettings(payload), mutationFn: (payload: SettingsUpdateIn) => updateAdminSettings(payload),
@@ -67,17 +82,61 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
}, },
}) })
function handleSave() { const testEmailMutation = useMutation({
mutation.mutate({ mutationFn: () => sendTestEmail(testEmailTo.trim() ? { to: testEmailTo.trim() } : {}),
chat_enabled: chatEnabled, onSuccess: (result) => setTestEmailResult(result),
transcription_enabled: aiEnabled, onError: (err: unknown) => {
ai_level: aiLevel, setTestEmailResult({
summary_recipients: recipients, success: false,
display_timezone: timezone.trim(), message: (err instanceof ApiError && errorDetail(err)) || 'Не удалось отправить тестовое письмо',
registration_team_choice: teamChoiceEnabled, smtp_host: null,
registration_email_domain_enabled: domainVerificationEnabled, smtp_port: null,
registration_email_domain: emailDomain.trim() || null,
}) })
},
})
function addDomain() {
const domain = newDomainInput.trim().toLowerCase().replace(/^@/, '')
if (!domain || emailDomains.includes(domain)) {
setNewDomainInput('')
return
}
setEmailDomains([...emailDomains, domain])
setNewDomainInput('')
}
function removeDomain(domain: string) {
setEmailDomains(emailDomains.filter((d) => d !== domain))
}
function handleSave() {
// Отправляем только реально изменённые поля (`SettingsUpdateIn` — набор
// опциональных полей именно для этого): иначе, например, ai_level
// уходил бы в PUT нетронутым при каждом сохранении и на слабом железе
// валился бы в 400, блокируя правку вообще любой другой настройки.
const payload: SettingsUpdateIn = {}
if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled
if (aiEnabled !== data.transcription_enabled) payload.transcription_enabled = aiEnabled
if (aiLevel !== data.ai_level) payload.ai_level = aiLevel
if (recipients !== data.summary_recipients) payload.summary_recipients = recipients
if (timezone.trim() !== data.display_timezone) payload.display_timezone = timezone.trim()
if (teamChoiceEnabled !== data.registration_team_choice) {
payload.registration_team_choice = teamChoiceEnabled
}
if (domainVerificationEnabled !== data.registration_email_domain_enabled) {
payload.registration_email_domain_enabled = domainVerificationEnabled
}
if (JSON.stringify(emailDomains) !== JSON.stringify(data.registration_email_domains)) {
payload.registration_email_domains = emailDomains
}
if (contactEmailEnabled !== data.contact_email_enabled) {
payload.contact_email_enabled = contactEmailEnabled
}
const trimmedContactEmail = contactEmail.trim() || null
if (trimmedContactEmail !== (data.contact_email ?? null)) {
payload.contact_email = trimmedContactEmail
}
mutation.mutate(payload)
} }
return ( return (
@@ -200,19 +259,53 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
<section className="settings-card"> <section className="settings-card">
<h2>Эталон mail-домена</h2> <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 }}> <div className="field" style={{ marginBottom: 0 }}>
<label htmlFor="settings-email-domain">Домен корпоративной почты</label> <label htmlFor="settings-email-domain">Домен корпоративной почты</label>
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
<input <input
id="settings-email-domain" id="settings-email-domain"
type="text" type="text"
placeholder="company.ru" placeholder="company.ru"
value={emailDomain} value={newDomainInput}
disabled={!domainVerificationEnabled} disabled={!domainVerificationEnabled}
onChange={(e) => setEmailDomain(e.target.value)} 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>
</div> </div>
</section> </section>
@@ -268,6 +361,80 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
</div> </div>
</section> </section>
<section className="settings-card">
<h2>Контактный адрес</h2>
<p className="desc">
Адрес для ответов на письма от инстанса (уходят от no-reply этот адрес подставляется
в заголовок «Reply-To»).
</p>
<div className="settings-card-body">
<div className="toggle-row" style={{ borderTop: 'none', paddingTop: 0 }}>
<div className="toggle-copy">
<strong>Контактный адрес включён</strong>
<span>Подставляется в Reply-To подтверждения регистрации, приглашений и саммари</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={contactEmailEnabled}
onChange={(e) => setContactEmailEnabled(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
<div className="field" style={{ marginBottom: 0, marginTop: 'var(--space-4)' }}>
<label htmlFor="settings-contact-email">Email</label>
<input
id="settings-contact-email"
type="email"
placeholder="contact@vidconf.ru"
value={contactEmail}
disabled={!contactEmailEnabled}
onChange={(e) => setContactEmail(e.target.value)}
/>
</div>
</div>
</section>
<section className="settings-card">
<h2>Тестовое письмо</h2>
<p className="desc">Отправить проверочное письмо синхронно, чтобы сразу увидеть результат почтовой конфигурации.</p>
<div className="settings-card-body">
<div className="field">
<label htmlFor="settings-test-email-to">Получатель</label>
<input
id="settings-test-email-to"
type="email"
placeholder={user?.email ?? 'you@example.com'}
value={testEmailTo}
onChange={(e) => setTestEmailTo(e.target.value)}
/>
</div>
<button
type="button"
className="btn btn-secondary"
disabled={testEmailMutation.isPending}
onClick={() => testEmailMutation.mutate()}
>
{testEmailMutation.isPending ? 'Отправляем…' : 'Отправить тестовое письмо'}
</button>
{testEmailResult && (
<p
className="field-hint"
style={{ color: testEmailResult.success ? 'var(--color-success)' : 'var(--color-danger)' }}
>
{testEmailResult.message}
{testEmailResult.smtp_host && ` (${testEmailResult.smtp_host}:${testEmailResult.smtp_port})`}
</p>
)}
</div>
</section>
<div className="settings-actions"> <div className="settings-actions">
<button type="button" className="btn btn-primary" disabled={mutation.isPending} onClick={handleSave}> <button type="button" className="btn btn-primary" disabled={mutation.isPending} onClick={handleSave}>
{mutation.isPending ? 'Сохраняем…' : 'Сохранить настройки'} {mutation.isPending ? 'Сохраняем…' : 'Сохранить настройки'}

View File

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

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { AppFooter } from '@/components/layout/AppFooter'
import { LogoMark } from '@/components/ui/LogoMark' import { LogoMark } from '@/components/ui/LogoMark'
import { ThemeToggle } from '@/components/ui/ThemeToggle' import { ThemeToggle } from '@/components/ui/ThemeToggle'
import '@/styles/auth.css' import '@/styles/auth.css'
@@ -40,12 +41,16 @@ export function AuthLayout({ children }: { children: ReactNode }) {
</div> </div>
</div> </div>
</div> </div>
<p className="hint-msg" style={{ position: 'relative', zIndex: 1 }}>
© 2026 VidConf · self-hosted
</p>
</aside> </aside>
<main className="form-panel">{children}</main> {/* Копирайт с годом и версией инстанса — общий `AppFooter` (он же на всех
остальных страницах). Строку «© 2026 VidConf · self-hosted» из
брендовой панели при этом убрали совсем (решение оператора): год в ней
был захардкожен, а копирайт теперь живёт в футере. */}
<main className="form-panel">
{children}
<AppFooter />
</main>
</div> </div>
) )
} }

View File

@@ -5,8 +5,8 @@ import type { EventClickArg, EventHoveringArg } from '@fullcalendar/core'
import dayGridPlugin from '@fullcalendar/daygrid' import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction' import interactionPlugin from '@fullcalendar/interaction'
import ruLocale from '@fullcalendar/core/locales/ru' import ruLocale from '@fullcalendar/core/locales/ru'
import { CheckCircle2, ChevronLeft, ChevronRight, Clock, Lock, Play, Repeat } from 'lucide-react' import { ChevronLeft, ChevronRight, Lock, Pencil } from 'lucide-react'
import type { OccurrenceOut } from '@/api/conferences' import type { ConferenceOut, OccurrenceOut } from '@/api/conferences'
import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard' import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard'
/* /*
@@ -35,6 +35,16 @@ const HIDDEN_DAYS_NONE: number[] = []
*/ */
const eventFocusCleanups = new WeakMap<HTMLElement, () => void>() const eventFocusCleanups = new WeakMap<HTMLElement, () => void>()
/**
* Актуальный колбэк редактирования из последнего рендера
* `ConferenceCalendar`. `renderEventContent` обязан оставаться одной и той же
* функцией на всё время жизни модуля (см. комментарий выше про
* resetOptions/бесконечный цикл), поэтому не может напрямую замкнуть пропс
* компонента — вместо этого читает его отсюда, а компонент синхронизирует
* переменную эффектом при каждом изменении пропса.
*/
let latestOnEditClick: (conference: ConferenceOut) => void = () => {}
export type CalendarViewMode = 'dayGridWeek' | 'dayGridMonth' export type CalendarViewMode = 'dayGridWeek' | 'dayGridMonth'
interface ConferenceCalendarProps { interface ConferenceCalendarProps {
@@ -42,73 +52,166 @@ interface ConferenceCalendarProps {
onViewChange: (view: CalendarViewMode) => void onViewChange: (view: CalendarViewMode) => void
occurrences: OccurrenceOut[] occurrences: OccurrenceOut[]
isLoading: boolean isLoading: boolean
/** Владельческие конференции текущего пользователя (`/conferences/my`, только свои — `is_owner`) — определяет, показывать ли карандашик на чипе. */
myConferenceById: Map<string, ConferenceOut>
/** Сообщает видимый диапазон сетки (UTC ISO) — используется для запроса вхождений. */ /** Сообщает видимый диапазон сетки (UTC ISO) — используется для запроса вхождений. */
onRangeChange: (fromIso: string, toIso: string) => void onRangeChange: (fromIso: string, toIso: string) => void
onOccurrenceClick: (occurrence: OccurrenceOut) => void onOccurrenceClick: (occurrence: OccurrenceOut) => void
onEditClick: (conference: ConferenceOut) => void
} }
type BadgeKind = 'scheduled' | 'pinned' | 'live' | 'ended' /**
* Не зависит от пропсов/состояния компонента — модульная константа-функция
function occurrenceBadge(occ: OccurrenceOut, start: Date | null, end: Date | null): { kind: BadgeKind; label: string } { * (см. комментарий выше про стабильность identity). В чипе — только название
const now = new Date() * (обрезается многоточием по цепочке min-width:0/overflow:hidden ниже по
if (start && end && now >= start && now <= end) return { kind: 'live', label: 'Идёт сейчас' } * дереву, см. calendar.css) и, у владельца, карандашик редактирования; время/
if (end && now > end) return { kind: 'ended', label: 'Завершена' } * статус/номер — только в информационном окне (`ConferenceOccurrenceDialog`).
if (occ.is_pinned) return { kind: 'pinned', label: 'Закреплена' } */
return { kind: 'scheduled', label: 'Запланирована' }
}
const BADGE_ICON: Record<BadgeKind, typeof Clock> = {
scheduled: Clock,
pinned: Repeat,
live: Play,
ended: CheckCircle2,
}
/** Не зависит от пропсов/состояния компонента — модульная константа-функция (см. комментарий выше про стабильность identity). */
function renderEventContent(arg: EventContentArg) { function renderEventContent(arg: EventContentArg) {
const occ = arg.event.extendedProps.occurrence as OccurrenceOut const occ = arg.event.extendedProps.occurrence as OccurrenceOut
const start = arg.event.start const ownedConference = arg.event.extendedProps.ownedConference as ConferenceOut | undefined
const end = arg.event.end
const badge = occurrenceBadge(occ, start, end)
const Icon = BADGE_ICON[badge.kind]
const timeLabel =
start && end
? `${start.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}${end.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`
: ''
return ( return (
<div> <div className="chip-row">
<span className={`conf-badge ${badge.kind}`}>
<Icon style={{ width: 11, height: 11 }} aria-hidden="true" />
{badge.label}
</span>
<span className="chip-time">{timeLabel}</span>
<span className="chip-title"> <span className="chip-title">
{occ.is_closed && <Lock className="chip-lock" aria-hidden="true" />} {occ.is_closed && <Lock className="chip-lock" aria-hidden="true" />}
{arg.event.title} <span className="chip-title-text">{arg.event.title}</span>
</span> </span>
{ownedConference && (
<button
type="button"
className="chip-edit"
aria-label="Редактировать конференцию"
onClick={(e) => {
// Иначе клик долетит и до обработчика чипа (eventClick) — тот
// открыл бы информационное окно вместо формы редактирования.
e.stopPropagation()
latestOnEditClick(ownedConference)
}}
>
<Pencil aria-hidden="true" />
</button>
)}
</div> </div>
) )
} }
/** «г.» — сноска Intl.DateTimeFormat('ru-RU', ...) после года при формате year+month(+day); в подписи периода не нужна. */
const YEAR_SUFFIX_RE = /\s*г\.(?=\s|$)/g
/**
* Подписи дней в самой сетке, из которых надо убрать «г.» (см.
* `useStripYearSuffix`): месячный вид — номер дня в ячейке, недельный —
* подпись колонки дня.
*/
const DAY_LABEL_SELECTOR = '.fc-daygrid-day-number, .fc-col-header-cell-cushion'
/** Убирает «г.» из `aria-label`/`title` элемента; ничего не пишет, если менять нечего. */
function stripYearSuffixAttrs(el: Element) {
for (const attr of ['aria-label', 'title']) {
const value = el.getAttribute(attr)
if (!value) continue
const cleaned = value.replace(YEAR_SUFFIX_RE, '')
if (cleaned !== value) el.setAttribute(attr, cleaned)
}
}
/**
* Убирает «г.» из служебных подписей дней внутри сетки FullCalendar.
*
* Кроме подписи периода (её формат мы задаём сами, см. `buildTitleFormatter`),
* дату дня FullCalendar подставляет ещё и в подпись ячейки — она видна в
* accessibility-дереве и как всплывающая подсказка. Формат этой подписи
* библиотека наружу НЕ отдаёт: `buildNavLinkAttrs` (@fullcalendar/core)
* форматирует дату жёстко зашитым `{year,month:'long',day:'numeric'}` и кладёт
* результат в `aria-label` (в `title` — когда включены navLinks), а с локалью
* `ru` Intl всегда даёт «20 июля 2026 г.». Настройкой это не убирается,
* поэтому правим уже отрендеренный атрибут.
*
* Почему MutationObserver, а не хуки `dayCellDidMount`/`dayHeaderDidMount`:
* при переключении недельного вида на месячный FullCalendar ПЕРЕИСПОЛЬЗУЕТ
* уже смонтированные ячейки — `didMount` для них повторно не зовётся, а
* подпись внутри перерисовывается заново, и «г.» возвращается (поймали на
* живой проверке: после переключения вида ровно строка переиспользованных
* ячеек оставалась с «г.»). Наблюдатель же ловит любую перерисовку.
* Зацикливания нет: `stripYearSuffixAttrs` пишет атрибут, только если он
* реально меняется, поэтому вызванная нашей же правкой итерация ничего не
* пишет и цепочка обрывается.
*/
function useStripYearSuffix(rootRef: React.RefObject<HTMLElement | null>) {
useEffect(() => {
const root = rootRef.current
if (!root) return
const clean = () => root.querySelectorAll(DAY_LABEL_SELECTOR).forEach(stripYearSuffixAttrs)
clean()
const observer = new MutationObserver(clean)
observer.observe(root, { subtree: true, childList: true, attributeFilter: ['aria-label', 'title'] })
return () => observer.disconnect()
}, [rootRef])
}
/**
* Форма аргумента, который FullCalendar передаёт функции-`titleFormat`
* (`VerboseFormattingArg` из @fullcalendar/core/internal-common — не
* реэкспортируется из публичного `@fullcalendar/core`, поэтому типизируем
* структурно сами, только нужные поля).
*/
interface TitleFormatArg {
start: { marker: Date }
end?: { marker: Date }
}
/**
* Строит titleFormat-функцию FullCalendar на Intl.DateTimeFormat вместо
* ruLocale-зависимого форматтера — тот всегда добавляет «г.» после года.
* FullCalendar передаёт границы диапазона уже с учётом exclusivity (конец
* периода минус 1мс, см. `dateEnv.formatRange` в @fullcalendar/core) — этого
* достаточно, чтобы Intl.formatRange сам корректно схлопнул одинаковые
* месяц/год и красиво отформатировал переход через месяц/год.
*/
function buildTitleFormatter(options: Intl.DateTimeFormatOptions) {
const formatter = new Intl.DateTimeFormat('ru-RU', { ...options, timeZone: 'UTC' })
return (arg: TitleFormatArg): string => {
const raw = arg.end ? formatter.formatRange(arg.start.marker, arg.end.marker) : formatter.format(arg.start.marker)
return raw.replace(YEAR_SUFFIX_RE, '')
}
}
const CALENDAR_VIEWS = {
dayGridWeek: { titleFormat: buildTitleFormatter({ year: 'numeric', month: 'long', day: 'numeric' }) },
dayGridMonth: { titleFormat: buildTitleFormatter({ year: 'numeric', month: 'long' }) },
}
/** /**
* Обёртка над FullCalendar (см. design/mockups/calendar.html): недельная * Обёртка над FullCalendar (см. design/mockups/calendar.html): недельная
* (только будни, 5 колонок) / месячная сетка вхождений конференций, свой * (только будни, 5 колонок) / месячная сетка вхождений конференций, свой
* тулбар (вид, навигация по датам) вместо встроенного headerToolbar. * тулбар (вид, навигация по датам) вместо встроенного headerToolbar.
* События рендерятся как «чипы» (`.conf-chip`) со статус-бейджем §4.6 * События рендерятся как «чипы» (`.conf-chip`) — только название (+карандашик
* DESIGN_SYSTEM.md — вместо позиционирования по времени (timeGrid), т.к. * у владельца), статус/время/номер — в информационном окне по клику
* менеджер конференций показывает список вхождений по дням, не занятость * (`ConferenceOccurrenceDialog`) — вместо позиционирования по времени
* переговорной по часам (переговорных комнат в модели нет). * (timeGrid), т.к. менеджер конференций показывает список вхождений по дням,
* не занятость переговорной по часам (переговорных комнат в модели нет).
*/ */
export function ConferenceCalendar({ export function ConferenceCalendar({
view, view,
onViewChange, onViewChange,
occurrences, occurrences,
isLoading, isLoading,
myConferenceById,
onRangeChange, onRangeChange,
onOccurrenceClick, onOccurrenceClick,
onEditClick,
}: ConferenceCalendarProps) { }: ConferenceCalendarProps) {
const calendarRef = useRef<FullCalendar | null>(null) const calendarRef = useRef<FullCalendar | null>(null)
const titleRef = useRef<HTMLSpanElement | null>(null) const titleRef = useRef<HTMLSpanElement | null>(null)
const gridRef = useRef<HTMLDivElement | null>(null)
useStripYearSuffix(gridRef)
// См. комментарий у `latestOnEditClick` в начале файла — синхронизируем
// переменную вместо того, чтобы замыкать пропс напрямую в renderEventContent.
useEffect(() => {
latestOnEditClick = onEditClick
}, [onEditClick])
// Ховер-карточка конференции — состояние держим внутри // Ховер-карточка конференции — состояние держим внутри
// компонента (не поднимаем в CalendarPage), чтобы колбэки FullCalendar ниже // компонента (не поднимаем в CalendarPage), чтобы колбэки FullCalendar ниже
@@ -159,20 +262,24 @@ export function ConferenceCalendar({
calendarRef.current?.getApi().today() calendarRef.current?.getApi().today()
} }
// Мемоизировано по `occurrences` — иначе на каждый рендер создавался бы // Мемоизировано по `occurrences`/`myConferenceById` — иначе на каждый рендер
// новый массив-`events` той же длины/содержимого, и FullCalendar считал бы // создавался бы новый массив-`events` той же длины/содержимого, и
// его «изменившейся опцией» (см. комментарий у модульных констант выше). // FullCalendar считал бы его «изменившейся опцией» (см. комментарий у
// модульных констант выше).
const events: EventInput[] = useMemo( const events: EventInput[] = useMemo(
() => () =>
occurrences.map((occ) => ({ occurrences.map((occ) => {
const owned = myConferenceById.get(occ.conference_id)
return {
id: `${occ.conference_id}:${occ.starts_at}`, id: `${occ.conference_id}:${occ.starts_at}`,
title: occ.title ?? 'Конференция без названия', title: occ.title ?? 'Конференция без названия',
start: occ.starts_at, start: occ.starts_at,
end: occ.ends_at, end: occ.ends_at,
classNames: ['conf-chip'], classNames: ['conf-chip'],
extendedProps: { occurrence: occ }, extendedProps: { occurrence: occ, ownedConference: owned?.is_owner ? owned : undefined },
})), }
[occurrences], }),
[occurrences, myConferenceById],
) )
const handleEventClick = useCallback( const handleEventClick = useCallback(
@@ -216,7 +323,7 @@ export function ConferenceCalendar({
</div> </div>
</div> </div>
<div className="vc-calendar" aria-busy={isLoading}> <div className="vc-calendar" aria-busy={isLoading} ref={gridRef}>
<FullCalendar <FullCalendar
ref={calendarRef} ref={calendarRef}
plugins={CALENDAR_PLUGINS} plugins={CALENDAR_PLUGINS}
@@ -228,6 +335,7 @@ export function ConferenceCalendar({
hiddenDays={view === 'dayGridWeek' ? HIDDEN_DAYS_BUSINESS_WEEK : HIDDEN_DAYS_NONE} hiddenDays={view === 'dayGridWeek' ? HIDDEN_DAYS_BUSINESS_WEEK : HIDDEN_DAYS_NONE}
height="auto" height="auto"
dayMaxEvents={false} dayMaxEvents={false}
views={CALENDAR_VIEWS}
events={events} events={events}
eventContent={renderEventContent} eventContent={renderEventContent}
eventClick={handleEventClick} eventClick={handleEventClick}

View File

@@ -1,10 +1,15 @@
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Calendar, Lock, Play, Repeat, X } from 'lucide-react' import { Calendar, Hash, Lock, Pencil, Play, Repeat, X } from 'lucide-react'
import type { OccurrenceOut } from '@/api/conferences' import type { ConferenceOut, OccurrenceOut } from '@/api/conferences'
import { occurrenceBadge, OCCURRENCE_BADGE_ICON } from '@/lib/occurrenceStatus'
import { useModalDismiss } from '@/hooks/useModalDismiss'
interface ConferenceOccurrenceDialogProps { interface ConferenceOccurrenceDialogProps {
occurrence: OccurrenceOut occurrence: OccurrenceOut
/** Присутствует только когда текущий пользователь — организатор этого вхождения (`is_owner`) — показывает кнопку «Редактировать». */
ownedConference?: ConferenceOut
onClose: () => void onClose: () => void
onEdit?: (conference: ConferenceOut) => void
} }
function formatRange(startsAt: string, endsAt: string): string { function formatRange(startsAt: string, endsAt: string): string {
@@ -18,14 +23,19 @@ function formatRange(startsAt: string, endsAt: string): string {
/** /**
* Быстрый просмотр вхождения конференции по клику на чип в календаре — для * Быстрый просмотр вхождения конференции по клику на чип в календаре — для
* конференций, которые пользователь не организует (не владелец — * всех конференций, включая свои: редактирование теперь отдельное действие
* `is_owner`): свои конференции CalendarPage открывает сразу в форме * (карандашик на чипе или кнопка «Редактировать» здесь, у владельца), сам
* редактирования (`ConferenceFormCard`). Здесь — минимум: когда, статус, вход * клик по чипу всегда открывает это окно. Показывает статус, время, номер и,
* и ссылка на управление; подробности состава участников/организатора — в * для владельца, вход в редактирование; подробности состава
* ховер-карточке при наведении/фокусе на чип (`ConferenceHoverCard`). * участников/организатора — в ховер-карточке при наведении/фокусе на чип
* (`ConferenceHoverCard`).
*/ */
export function ConferenceOccurrenceDialog({ occurrence, onClose }: ConferenceOccurrenceDialogProps) { export function ConferenceOccurrenceDialog({ occurrence, ownedConference, onClose, onEdit }: ConferenceOccurrenceDialogProps) {
const navigate = useNavigate() const navigate = useNavigate()
useModalDismiss(onClose)
const badge = occurrenceBadge(occurrence, new Date(occurrence.starts_at), new Date(occurrence.ends_at))
const BadgeIcon = OCCURRENCE_BADGE_ICON[badge.kind]
return ( return (
<div className="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="occurrence-dialog-title" onClick={onClose}> <div className="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="occurrence-dialog-title" onClick={onClose}>
@@ -40,13 +50,18 @@ export function ConferenceOccurrenceDialog({ occurrence, onClose }: ConferenceOc
</button> </button>
</div> </div>
<span className={`conf-badge ${badge.kind}`} style={{ marginBottom: 'var(--space-4)' }}>
<BadgeIcon style={{ width: 11, height: 11 }} aria-hidden="true" />
{badge.label}
</span>
<div className="detail-row"> <div className="detail-row">
<Calendar style={{ width: 18, height: 18 }} aria-hidden="true" /> <Calendar style={{ width: 18, height: 18 }} aria-hidden="true" />
<span className="label">Когда</span> <span className="label">Когда</span>
<span>{formatRange(occurrence.starts_at, occurrence.ends_at)}</span> <span>{formatRange(occurrence.starts_at, occurrence.ends_at)}</span>
</div> </div>
<div className="detail-row"> <div className="detail-row">
<Repeat style={{ width: 18, height: 18 }} aria-hidden="true" /> <Hash style={{ width: 18, height: 18 }} aria-hidden="true" />
<span className="label">Номер</span> <span className="label">Номер</span>
<span>{occurrence.number}</span> <span>{occurrence.number}</span>
</div> </div>
@@ -63,10 +78,18 @@ export function ConferenceOccurrenceDialog({ occurrence, onClose }: ConferenceOc
<Play style={{ width: 16, height: 16 }} aria-hidden="true" /> <Play style={{ width: 16, height: 16 }} aria-hidden="true" />
Войти Войти
</button> </button>
{ownedConference && onEdit && (
<button type="button" className="btn btn-secondary" onClick={() => onEdit(ownedConference)}>
<Pencil style={{ width: 16, height: 16 }} aria-hidden="true" />
Редактировать
</button>
)}
</div> </div>
{!ownedConference && (
<p className="field-hint" style={{ textAlign: 'center', marginTop: 'var(--space-3)' }}> <p className="field-hint" style={{ textAlign: 'center', marginTop: 'var(--space-3)' }}>
Управление и редактирование в <Link to="/my-conferences">«Моих конференциях»</Link> Управление и редактирование в <Link to="/my-conferences">«Моих конференциях»</Link>
</p> </p>
)}
</div> </div>
</div> </div>
) )

View File

@@ -0,0 +1,34 @@
import { useQuery } from '@tanstack/react-query'
import { getHealth } from '@/api/health'
import '@/styles/footer.css'
/**
* Общий футер приложения: версия инстанса и копирайт со ссылкой на автора.
*
* Ставится на всех страницах, КРОМЕ комнаты конференции (`RoomPage`) — там
* полноэкранное видео и тулбар, футеру места нет. Раскладок в приложении
* несколько (`page-shell`, `AuthLayout`, `join-shell`, `lobby-shell`), поэтому
* компонент рендерит только собственный `<footer>`, а привязку к конкретной
* раскладке делает CSS (см. `styles/footer.css`).
*
* Версия — из публичного `GET /api/health` (авторизации не требует, годится и
* для экранов входа). Поле `version` опционально: если backend его не отдал,
* версию просто не показываем (см. `HealthOut.version`). Ключ запроса тот же
* `['health']`, что и у админки, — react-query отдаст общий закэшированный
* результат, лишнего запроса не будет.
*
* Год — текущий, из `Date` (не захардкожен): страница живёт в браузере
* пользователя и не должна устаревать 1 января.
*/
export function AppFooter() {
const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, retry: false })
return (
<footer className="app-footer">
VidConf{health?.version ? ` · v${health.version}` : ''} · © {new Date().getFullYear()} · by{' '}
<a href="https://github.com/MaxRonzhin" target="_blank" rel="noopener">
MaxRonzhin
</a>
</footer>
)
}

View File

@@ -1,34 +1,68 @@
import { useRef, useState } from 'react'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useMediaDeviceSelect, usePersistentUserChoices } from '@livekit/components-react' import { useMediaDeviceSelect, usePersistentUserChoices } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider' import { useToast } from '@/components/ui/ToastProvider'
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
import { useModalDismiss } from '@/hooks/useModalDismiss'
import { StageViewOptions, type StageViewProps } from '@/components/room/StageViewOptions'
import { isAudioOutputSelectable, saveAudioOutputDeviceId } from '@/lib/audioOutputDevice'
interface DeviceSettingsDialogProps { interface DeviceSettingsDialogProps extends StageViewProps {
onClose: () => void onClose: () => void
} }
/** Свайп ручки шторки вниз дальше этого порога (px) закрывает панель, меньше — она возвращается на место. */
const SHEET_DISMISS_THRESHOLD_PX = 80
/** Человекочитаемая подпись пункта списка устройств — `label` пуст, пока нет разрешения на медиа. */ /** Человекочитаемая подпись пункта списка устройств — `label` пуст, пока нет разрешения на медиа. */
function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string): string { function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string): string {
return device.label || `${fallback} ${index + 1}` return device.label || `${fallback} ${index + 1}`
} }
/** /**
* Диалог «Настройки устройств» — два селекта * Панель «Настройки устройств» — три селекта на хуках `@livekit/components-react`:
* на хуках `@livekit/components-react`: список устройств и переключение — * список устройств и переключение целиком в `useMediaDeviceSelect` (сама
* целиком в `useMediaDeviceSelect` (сама подписана на * подписана на `RoomEvent.MediaDevicesChanged`), персист выбора микрофона/камеры —
* `RoomEvent.MediaDevicesChanged`), персист выбора — в `usePersistentUserChoices` * в `usePersistentUserChoices` (localStorage, читается заново при следующем
* (localStorage, читается заново при следующем входе в комнату — см. * входе в комнату — см. `RoomPage.tsx`, `options` пропс `LiveKitRoom`).
* `RoomPage.tsx`, `options` пропс `LiveKitRoom`). * Аудиовыход персистится отдельно (`lib/audioOutputDevice.ts`) — `LocalUserChoices`
* LiveKit про него не знает, а `RoomOptions.audioOutput` читает сохранённый
* `deviceId` при следующем подключении.
*
* На мобильной ширине (`useIsCompactViewport`) рендерится шторкой снизу вместо
* модалки — по клику вне, Escape (`useModalDismiss`) и свайпу вниз за ручку.
* Десктоп не меняется.
*
* Там же, и только там, первой секцией идёт «Вид» (режим показа участников и
* скрытие остальных): на мобильном тулбар ужат до пяти кнопок, отдельной
* кнопки «Вид» там нет — см. `RoomToolbar`. Поэтому и заголовок панели на
* мобильном шире по смыслу («Настройки», а не «Настройки устройств»).
* *
* ДОЛЖЕН рендериться внутри `<LiveKitRoom>`: `useMediaDeviceSelect` без явно * ДОЛЖЕН рендериться внутри `<LiveKitRoom>`: `useMediaDeviceSelect` без явно
* переданного `room` берёт активную комнату из `RoomContext` — вне контекста * переданного `room` берёт активную комнату из `RoomContext` — вне контекста
* он создал бы отдельный, ни с чем не связанный `Room()` и переключал бы * он создал бы отдельный, ни с чем не связанный `Room()` и переключал бы
* устройство «в никуда». * устройство «в никуда».
*/ */
export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) { export function DeviceSettingsDialog({
onClose,
layoutMode,
onLayoutModeChange,
hideOthers,
onHideOthersChange,
}: DeviceSettingsDialogProps) {
const toast = useToast() const toast = useToast()
const { saveAudioInputDeviceId, saveVideoInputDeviceId } = usePersistentUserChoices() const { saveAudioInputDeviceId, saveVideoInputDeviceId } = usePersistentUserChoices()
const mic = useMediaDeviceSelect({ kind: 'audioinput' }) const mic = useMediaDeviceSelect({ kind: 'audioinput' })
const camera = useMediaDeviceSelect({ kind: 'videoinput' }) 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) { async function handleMicChange(deviceId: string) {
try { try {
@@ -49,22 +83,83 @@ 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 ( return (
<div <div
className="room-modal-overlay" className={isCompact ? 'room-sheet-overlay' : 'room-modal-overlay'}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="device-settings-title" aria-labelledby="device-settings-title"
onClick={onClose} 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"> <div className="room-modal-head">
<h2 id="device-settings-title">Настройки устройств</h2> <h2 id="device-settings-title">{isCompact ? 'Настройки' : 'Настройки устройств'}</h2>
<button type="button" className="room-modal-close" aria-label="Закрыть" onClick={onClose}> <button type="button" className="room-modal-close" aria-label="Закрыть" onClick={onClose}>
<X className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" /> <X className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
</button> </button>
</div> </div>
{/* Обёртка НЕ `.room-field`: там `label { display: block }` со
специфичностью выше, чем у `.stage-view-mode` — пункты режимов
рассыпались бы в столбик (кружок отдельно, текст под ним). */}
{isCompact && (
<div className="stage-view-section">
<StageViewOptions
layoutMode={layoutMode}
onLayoutModeChange={onLayoutModeChange}
hideOthers={hideOthers}
onHideOthersChange={onHideOthersChange}
/>
</div>
)}
<div className="room-field"> <div className="room-field">
<label htmlFor="device-settings-mic">Микрофон</label> <label htmlFor="device-settings-mic">Микрофон</label>
<select <select
@@ -104,6 +199,34 @@ export function DeviceSettingsDialog({ onClose }: DeviceSettingsDialogProps) {
))} ))}
</select> </select>
</div> </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>
</div> </div>
) )

View File

@@ -1,4 +1,4 @@
import { ScreenShare } from 'lucide-react' import { Pin, PinOff, ScreenShare } from 'lucide-react'
import { Track } from 'livekit-client' import { Track } from 'livekit-client'
import { import {
AudioTrack, AudioTrack,
@@ -19,6 +19,7 @@ import {
type TrackReferenceOrPlaceholder, type TrackReferenceOrPlaceholder,
} from '@livekit/components-react' } from '@livekit/components-react'
import { Avatar } from '@/components/ui/Avatar' import { Avatar } from '@/components/ui/Avatar'
import { stageTrackKey } from '@/components/room/stageFocus'
/** Метаданные участника из LiveKit access-токена (см. `AccessToken.with_metadata` на backend) — JSON `{"avatar_url": "..."}`; у гостей отсутствуют. */ /** Метаданные участника из LiveKit access-токена (см. `AccessToken.with_metadata` на backend) — JSON `{"avatar_url": "..."}`; у гостей отсутствуют. */
interface ParticipantMetadata { interface ParticipantMetadata {
@@ -43,7 +44,7 @@ function parseAvatarUrl(metadata: string | undefined): string | null {
* разметке (см. `node_modules/@livekit/components-react/src/components/participant/ParticipantTile.tsx`, * разметке (см. `node_modules/@livekit/components-react/src/components/participant/ParticipantTile.tsx`,
* версия 2.9.23 — источник этой копии). * версия 2.9.23 — источник этой копии).
*/ */
function TileBody({ onStopSharing }: { onStopSharing?: () => void }) { function TileBody({ onStopSharing, pinnedKey, onTogglePin }: TileControlsProps) {
const trackReference = useEnsureTrackRef() const trackReference = useEnsureTrackRef()
const isEncrypted = useIsEncrypted(trackReference.participant) const isEncrypted = useIsEncrypted(trackReference.participant)
const autoManageSubscription = useFeatureContext()?.autoSubscription const autoManageSubscription = useFeatureContext()?.autoSubscription
@@ -60,6 +61,12 @@ function TileBody({ onStopSharing }: { onStopSharing?: () => void }) {
const showSharingChip = Boolean( const showSharingChip = Boolean(
onStopSharing && trackReference.source === Track.Source.ScreenShare && trackReference.participant.isLocal, onStopSharing && trackReference.source === Track.Source.ScreenShare && trackReference.participant.isLocal,
) )
// Кнопка закрепления — только там, где сцена умеет закрепление (основное
// окно передаёт `onTogglePin`; в мини-плеере плитка одна, закреплять нечего).
// Ключ плитки берём из её собственного трека: в карусели/гриде плитки
// рендерятся шаблоном без пропсов, снаружи «какая это плитка» не передать.
const tileKey = stageTrackKey(trackReference)
const isPinned = pinnedKey === tileKey
return ( return (
<> <>
@@ -106,6 +113,25 @@ function TileBody({ onStopSharing }: { onStopSharing?: () => void }) {
<ConnectionQualityIndicator className="lk-participant-metadata-item" /> <ConnectionQualityIndicator className="lk-participant-metadata-item" />
</div> </div>
<FocusToggle trackRef={trackReference} /> <FocusToggle trackRef={trackReference} />
{onTogglePin && (
<button
type="button"
className={`room-pin-toggle${isPinned ? ' is-pinned' : ''}`}
aria-pressed={isPinned}
title={isPinned ? 'Открепить' : 'Закрепить в основном окне'}
aria-label={
isPinned ? `Открепить: ${displayName}` : `Закрепить в основном окне: ${displayName}`
}
onClick={(e) => {
// Иначе клик долетит до самой плитки (`onParticipantClick`
// у `ParticipantTile`) — булавка не должна означать «клик по плитке».
e.stopPropagation()
onTogglePin(tileKey)
}}
>
{isPinned ? <PinOff aria-hidden="true" /> : <Pin aria-hidden="true" />}
</button>
)}
{showSharingChip && ( {showSharingChip && (
<div className="stage-sharing-chip"> <div className="stage-sharing-chip">
<ScreenShare className="lucide" aria-hidden="true" /> <ScreenShare className="lucide" aria-hidden="true" />
@@ -119,10 +145,8 @@ function TileBody({ onStopSharing }: { onStopSharing?: () => void }) {
) )
} }
interface RoomParticipantTileProps { /** Управляющие элементы поверх плитки — общие для обёртки и её `TileBody`. */
trackRef?: TrackReferenceOrPlaceholder interface TileControlsProps {
disableSpeakingIndicator?: boolean
onParticipantClick?: (event: ParticipantClickEvent) => void
/** /**
* Остановить демонстрацию экрана — если передано, при рендере СВОЕЙ активной * Остановить демонстрацию экрана — если передано, при рендере СВОЕЙ активной
* демонстрации (Track.Source.ScreenShare + `participant.isLocal`) поверх * демонстрации (Track.Source.ScreenShare + `participant.isLocal`) поверх
@@ -131,6 +155,23 @@ interface RoomParticipantTileProps {
* в этом приложении не появляется (см. `RoomStage.tsx`). * в этом приложении не появляется (см. `RoomStage.tsx`).
*/ */
onStopSharing?: () => void onStopSharing?: () => void
/**
* Ключ закреплённой сейчас плитки (`identity:source`, см. `stageTrackKey`) —
* плитка сравнивает его со своим и подсвечивает булавку/рамку.
*/
pinnedKey?: string | null
/**
* Закрепить/открепить эту плитку в основном окне. Передаёт свой ключ
* (плитки в карусели/гриде рендерятся шаблоном, снаружи их не различить).
* Не передан — кнопки-булавки на плитке нет (мини-плеер: плитка одна).
*/
onTogglePin?: (key: string) => void
}
interface RoomParticipantTileProps extends TileControlsProps {
trackRef?: TrackReferenceOrPlaceholder
disableSpeakingIndicator?: boolean
onParticipantClick?: (event: ParticipantClickEvent) => void
} }
/** /**
@@ -142,15 +183,20 @@ interface RoomParticipantTileProps {
* собственного токена), и для удалённых. * собственного токена), и для удалённых.
* *
* Пин-логика оригинала (`handleSubscribe`/сброс пина при отписке от трека) * Пин-логика оригинала (`handleSubscribe`/сброс пина при отписке от трека)
* сознательно опущена — приложение пока нигде не создаёт `LayoutContext` * сознательно опущена — приложение нигде не создаёт `LayoutContext`, поэтому
* (пиннинг плиток не реализован), поэтому в оригинале эта ветка и так была * в оригинале эта ветка и так была мёртвым кодом без провайдера контекста (по
* мёртвым кодом без провайдера контекста. * той же причине ничего не рисует и штатный `FocusToggle` ниже). Своё
* закрепление участника (задача 3.1) сделано мимо `LayoutContext`: состояние —
* в `RoomStage`, кнопка — `.room-pin-toggle` здесь, выбор фокуса —
* `pickStageFocus`.
*/ */
export function RoomParticipantTile({ export function RoomParticipantTile({
trackRef, trackRef,
disableSpeakingIndicator, disableSpeakingIndicator,
onParticipantClick, onParticipantClick,
onStopSharing, onStopSharing,
pinnedKey,
onTogglePin,
}: RoomParticipantTileProps) { }: RoomParticipantTileProps) {
return ( return (
<ParticipantTile <ParticipantTile
@@ -158,7 +204,7 @@ export function RoomParticipantTile({
disableSpeakingIndicator={disableSpeakingIndicator} disableSpeakingIndicator={disableSpeakingIndicator}
onParticipantClick={onParticipantClick} onParticipantClick={onParticipantClick}
> >
<TileBody onStopSharing={onStopSharing} /> <TileBody onStopSharing={onStopSharing} pinnedKey={pinnedKey} onTogglePin={onTogglePin} />
</ParticipantTile> </ParticipantTile>
) )
} }

View File

@@ -1,9 +1,9 @@
import { useState } from 'react' import { useEffect, useState, type ReactNode } from 'react'
import { Track } from 'livekit-client' import { EyeOff, Users } from 'lucide-react'
import { Track, type Participant } from 'livekit-client'
import { import {
CarouselLayout, CarouselLayout,
FocusLayoutContainer, FocusLayoutContainer,
GridLayout,
RoomAudioRenderer, RoomAudioRenderer,
isTrackReference, isTrackReference,
useRoomContext, useRoomContext,
@@ -12,7 +12,10 @@ import {
type TrackReferenceOrPlaceholder, type TrackReferenceOrPlaceholder,
} from '@livekit/components-react' } from '@livekit/components-react'
import { RoomParticipantTile } from '@/components/room/RoomParticipantTile' import { RoomParticipantTile } from '@/components/room/RoomParticipantTile'
import { pickStageFocus } from '@/components/room/stageFocus' import { StageGrid } from '@/components/room/StageGrid'
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
import { pickStageFocus, stageTrackKey } from '@/components/room/stageFocus'
import type { StageLayoutMode } from '@/lib/stageLayoutMode'
/** /**
* Стабильная (модульная, не пересоздаётся на каждый рендер) ссылка на * Стабильная (модульная, не пересоздаётся на каждый рендер) ссылка на
@@ -39,15 +42,98 @@ const STAGE_TRACK_SOURCES = [
{ source: Track.Source.ScreenShare, withPlaceholder: false }, { source: Track.Source.ScreenShare, withPlaceholder: false },
] ]
/** Ключ трека для `pickStageFocus` — см. обоснование в `stageFocus.ts`. */ /**
function stageTrackKey(t: TrackReferenceOrPlaceholder): string { * Удержание фокуса основного окна при смене говорящего, мс.
return `${t.participant.identity}:${t.source}` *
* Основное окно следует за спикером (`followSpeaker`, задача 3.2), и без
* удержания короткие реплики («ага», «угу») уводили бы большую плитку на
* секунду и возвращали обратно. Источник говорящих (`useSpeakingParticipants`
* поверх `RoomEvent.ActiveSpeakersChanged`) сам по себе не дребезжит, но
* реплику длиной в полсекунды он честно отдаёт как смену состава.
*
* Значение подобрано от периода самого события: LiveKit пересчитывает активных
* спикеров примерно раз в 0.5 с, то есть короткая реплика — это 12 обновления.
* 1.2 с ≈ 23 обновления: блик до 1.2 с не проходит вовсе (значение успевает
* вернуться обратно, таймер перезапускается), а осмысленная фраза переключает
* фокус с задержкой, которая на глаз читается как плавность, а не как тормоз.
* Меньше (~0.6 с) — короткие «ага» всё ещё пролезают, больше (~2 с) — заметно
* запаздывает переход на нового докладчика.
*/
const SPEAKER_HOLD_MS = 1200
/**
* Возвращает состав говорящих, «успокоенный» удержанием: новое значение
* применяется, только если оно продержалось `holdMs` без изменений. Короткая
* реплика меняет состав и возвращает его обратно раньше таймера — тогда
* применять уже нечего (cleanup эффекта гасит таймер, а новое значение
* сравнивается по ссылке с текущим).
*
* `holdMs <= 0` — удержания нет, значение отдаётся как есть (режим PiP: там
* фокус обязан следовать за говорящим мгновенно, поведение не менялось).
*/
function useSteadySpeakers(speakers: Participant[], holdMs: number): Participant[] {
const [steady, setSteady] = useState(speakers)
useEffect(() => {
if (holdMs <= 0 || speakers === steady) return
const timer = setTimeout(() => setSteady(speakers), holdMs)
return () => clearTimeout(timer)
}, [speakers, steady, holdMs])
return holdMs <= 0 ? speakers : steady
}
/**
* Есть ли у трека ЖИВОЕ видео: это настоящий трек (не плейсхолдер выключенной
* камеры) и он не в мьюте. По этому признаку режим «Живые плитки» делит
* участников на сетку и карусель, а `pickStageFocus` отдаёт предпочтение
* говорящему с картинкой.
*/
function hasLiveVideo(track: TrackReferenceOrPlaceholder): boolean {
return isTrackReference(track) && !track.publication.isMuted
} }
/** /**
* Основная сцена конференции: превью остальных участников + крупная плитка * Основная сцена конференции: превью остальных участников + крупная плитка
* активного спикера (FocusLayoutContainer + CarouselLayout при нескольких * активного спикера (FocusLayoutContainer + CarouselLayout при нескольких
* участниках, GridLayout при одном/двух). * участниках, равномерная сетка при одном/двух).
*
* РЕЖИМЫ ПОКАЗА (`layoutMode`, выбор пользователя, см. `lib/stageLayoutMode.ts`):
* - `standard` — как было до 0.0.11: крупная плитка (кого показать, решает
* `pickStageFocus`) + карусель остальных сбоку;
* - `tiles` — все участники равными плитками (`StageGrid`), без фокуса;
* - `live-tiles` — сетка только из участников с включённой камерой,
* остальные (плейсхолдер/мьют, см. `hasLiveVideo`) — в карусели сбоку.
* Если камеру не включил никто, сетка была бы пустой, поэтому в этом
* случае показываем всех — режим вырождается в `tiles`.
*
* ДЕМОНСТРАЦИЯ ЭКРАНА перебивает выбранный режим: пока в комнате есть хоть
* одна активная демонстрация, сцена ведёт себя как `standard` (демонстрация
* крупно, все камеры в карусели). Это сознательно: смысл плиточных режимов —
* равноправие участников, а демонстрация по определению неравноправна, ради
* неё её и включают. Режим при этом не меняется — он живёт в состоянии
* `RoomPage`, поэтому по завершении демонстрации сцена сама возвращается к
* выбранному пользователем виду.
*
* СКРЫТИЕ ОСТАЛЬНЫХ (`hideOthers`): карусель не рендерится вовсе, основная
* область занимает всю сцену. Скрыть можно тремя способами — меню «Вид»,
* шторка настроек на мобильном и кнопка «Скрыть» прямо над колонкой миниатюр
* (`onHideOthers`, только широкий экран). Вернуть — кнопкой «Показать
* остальных» на сцене (`onShowOthers`): она видна всегда, пока кто-то скрыт,
* чтобы участники не «потерялись» без понятного способа их вернуть. В режиме
* `tiles` скрывать нечего (карусели нет), переключатель там заблокирован —
* см. `StageViewOptions`.
*
* ФОКУС ПЕРЕЖИВАЕТ ПЕРЕЕЗД В МИНИ-ПЛЕЕР. Сцена в мини-плеере — ОТДЕЛЬНЫЙ
* экземпляр этого компонента (портал в PiP-окно), и своё состояние фокуса он
* начинал с нуля: демонстрации нет, никто прямо сейчас не говорит — и
* `pickStageFocus` доходил до последнего фолбэка `localKey`, то есть мини-окно
* открывалось на самом пользователе вместо того, что он видел крупно. В Safari
* бага не было видно: там Document PiP не используется, а video-фолбэк
* (`useRoomPiP`) берёт `<video>` прямо из фокус-плитки основного окна. Лечится
* передачей ключа наружу и обратно: `onFocusKeyChange` → состояние в
* `RoomPage` → `initialFocusKey` следующего экземпляра. Работает в обе стороны
* — возврат из мини-плеера тоже не сбрасывает фокус.
* *
* Раскладка — вертикальная колонка миниатюр слева от основной сцены (не * Раскладка — вертикальная колонка миниатюр слева от основной сцены (не
* горизонтальная лента, см. design/mockups/room.html после правки: узкая * горизонтальная лента, см. design/mockups/room.html после правки: узкая
@@ -75,23 +161,51 @@ function stageTrackKey(t: TrackReferenceOrPlaceholder): string {
* *
* Проп `variant="pip"` — для рендера * Проп `variant="pip"` — для рендера
* ВНУТРИ мини-плеера (Document PiP, портал в `RoomPage.tsx`). В этом режиме * ВНУТРИ мини-плеера (Document PiP, портал в `RoomPage.tsx`). В этом режиме
* показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида * показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида;
* — и фокус ЖИВО следует за активным спикером (см. `followSpeaker` у * режимы показа и скрытие остальных на мини-плеер не влияют вовсе.
* `pickStageFocus`), а не удерживается, как в основном окне. Основной рендер *
* (`variant="full"`, дефолт) не меняется вовсе. * Фокус следует за активным спикером в ОБОИХ вариантах (`followSpeaker` у
* `pickStageFocus`; для основного окна — с 0.0.6, задача 3.2), но по-разному:
* PiP переключается мгновенно и всегда показывает говорящего, а основное окно
* ждёт `SPEAKER_HOLD_MS` (не дёргается на коротких репликах), не уводит из
* фокуса живую демонстрацию экрана (`holdScreenShare`) и умеет закрепление
* участника (`pinnedKey`, задача 3.1) — кнопка-булавка на плитке.
*/ */
export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) { export function RoomStage({
variant = 'full',
layoutMode = 'standard',
hideOthers = false,
onShowOthers,
onHideOthers,
initialFocusKey = null,
onFocusKeyChange,
}: {
variant?: 'full' | 'pip'
/** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */
layoutMode?: StageLayoutMode
/** Скрыть карусель остальных участников; игнорируется при `variant="pip"`. */
hideOthers?: boolean
/** Вернуть скрытых участников — кнопка на сцене (см. докстринг выше). */
onShowOthers?: () => void
/** Скрыть остальных — кнопка над каруселью (только широкий экран). */
onHideOthers?: () => void
/** Чем инициализировать фокус при монтировании — см. докстринг про мини-плеер. */
initialFocusKey?: string | null
/** Сообщать наружу текущий фокус, чтобы его пережил переезд сцены в мини-плеер и обратно. */
onFocusKeyChange?: (key: string | null) => void
}) {
const room = useRoomContext() const room = useRoomContext()
const isCompact = useIsCompactViewport()
const tracks = useTracks(STAGE_TRACK_SOURCES, { const tracks = useTracks(STAGE_TRACK_SOURCES, {
onlySubscribed: false, onlySubscribed: false,
}) })
// Только для PiP (см. followSpeaker ниже) — активные спикеры уже // Активные спикеры уже отсортированы SDK по громкости
// отсортированы SDK по громкости (`Room.activeSpeakers`, обновляются по // (`Room.activeSpeakers`, обновляются по `RoomEvent.ActiveSpeakersChanged`,
// `RoomEvent.ActiveSpeakersChanged`, событие шлётся лишь при РЕАЛЬНОЙ смене // событие шлётся лишь при РЕАЛЬНОЙ смене состава/порядка говорящих — не
// состава/порядка говорящих — не дребезжит на каждый чих, в отличие от // дребезжит на каждый чих, в отличие от сырого `participant.isSpeaking`).
// сырого `participant.isSpeaking`). Хук вызывается безусловно (Rules of // Основное окно поверх этого ещё и удерживает состав (см. `useSteadySpeakers`
// Hooks) — для `variant="full"` его результат просто не используется. // и `SPEAKER_HOLD_MS`), PiP берёт значение как есть.
const speakingParticipants = useSpeakingParticipants() const speakingParticipants = useSteadySpeakers(useSpeakingParticipants(), variant === 'pip' ? 0 : SPEAKER_HOLD_MS)
const cameraTracks = tracks.filter((t) => t.source === Track.Source.Camera) const cameraTracks = tracks.filter((t) => t.source === Track.Source.Camera)
const screenShareTracks = tracks.filter((t) => isTrackReference(t) && t.source === Track.Source.ScreenShare) const screenShareTracks = tracks.filter((t) => isTrackReference(t) && t.source === Track.Source.ScreenShare)
@@ -121,47 +235,66 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
// новый массив только когда реально что-то изменилось (см. комментарий у // новый массив только когда реально что-то изменилось (см. комментарий у
// `STAGE_TRACK_SOURCES` про стабильность ссылки). // `STAGE_TRACK_SOURCES` про стабильность ссылки).
// //
// Для PiP (`variant="pip"`) пересчёт триггерится ЕЩЁ и сменой // Пересчёт триггерится ещё и сменой `speakingParticipants` (тоже сравнение
// `speakingParticipants` (тоже сравнение по ссылке — хук отдаёт новый // по ссылке — хук отдаёт новый массив только при реальном изменении состава/
// массив только при реальном изменении состава/порядка говорящих), и // порядка говорящих), и сменой закрепления (`pinnedKey`) — оба входа
// передаётся `followSpeaker: true` — фокус живо переключается на нового // `pickStageFocus` меняют результат без изменения самих треков.
// спикера, а не удерживает прежний (см. правило 2 в `pickStageFocus`). Для
// основного окна (`variant="full"`) `speakingChanged` всегда `false` —
// поведение байт-в-байт то же, что было до этой правки.
const [prevTracks, setPrevTracks] = useState(tracks) const [prevTracks, setPrevTracks] = useState(tracks)
const [prevSpeakingParticipants, setPrevSpeakingParticipants] = useState(speakingParticipants) const [prevSpeakingParticipants, setPrevSpeakingParticipants] = useState(speakingParticipants)
const [focusKey, setFocusKey] = useState<string | null>(null) // Стартовое значение — фокус, доставшийся от предыдущего экземпляра сцены
// (см. `initialFocusKey` и докстринг про мини-плеер). Дальше живёт своей
// жизнью: `pickStageFocus` пересчитывает его на каждое значимое изменение.
const [focusKey, setFocusKey] = useState<string | null>(initialFocusKey)
// Закрепление живёт в состоянии сцены (задача 3.1): ключ `identity:source`
// плитки, которую пользователь закрепил булавкой; `null` — закрепления нет.
// Только для основного окна — в PiP плитка одна и закреплять нечего.
const [pinnedKey, setPinnedKey] = useState<string | null>(null)
const [prevPinnedKey, setPrevPinnedKey] = useState<string | null>(null)
const cameraKeys = cameraTracks.map(stageTrackKey)
const screenShareKeys = screenShareTracks.map(stageTrackKey)
// Закреплённый участник вышел из комнаты (его ключа нет ни среди камер — а
// камера есть у КАЖДОГО участника хотя бы плейсхолдером, — ни среди
// демонстраций) — закрепление снимаем, чтобы сцена не осталась в подвешенном
// состоянии и булавка не «висела» на исчезнувшем ключе.
const pinnedAlive = pinnedKey !== null && (cameraKeys.includes(pinnedKey) || screenShareKeys.includes(pinnedKey))
const tracksChanged = tracks !== prevTracks const tracksChanged = tracks !== prevTracks
const speakingChanged = variant === 'pip' && speakingParticipants !== prevSpeakingParticipants const speakingChanged = speakingParticipants !== prevSpeakingParticipants
const pinnedChanged = pinnedKey !== prevPinnedKey
if (tracksChanged || speakingChanged) { if (pinnedKey !== null && !pinnedAlive) {
setPinnedKey(null)
}
if (tracksChanged || speakingChanged || pinnedChanged) {
const prevKeys = prevTracks.map(stageTrackKey) const prevKeys = prevTracks.map(stageTrackKey)
if (tracksChanged) setPrevTracks(tracks) if (tracksChanged) setPrevTracks(tracks)
if (speakingChanged) setPrevSpeakingParticipants(speakingParticipants) if (speakingChanged) setPrevSpeakingParticipants(speakingParticipants)
// Источник «говорящих» — РАЗНЫЙ для основного окна и PiP, намеренно: if (pinnedChanged) setPrevPinnedKey(pinnedKey)
// здесь строго тот же расчёт, что был в основном окне ДО этой правки // Говорящие — упорядоченные по громкости камера-ключи: нужен именно
// (`participant.isSpeaking`, без сортировки — фолбэк только на первый // порядок (взять самого громкого) и именно throttled-источник SDK
// рендер, дребезг неважен, см. JSDoc правила 3/4 в stageFocus.ts), а для // (`useSpeakingParticipants`, в основном окне ещё и с удержанием), чтобы
// PiP — упорядоченный по громкости `speakingParticipants` (нужен именно // followSpeaker не дёргался на каждый чих. Сырой `participant.isSpeaking`,
// порядок, чтобы взять самого громкого, и именно throttled-источник SDK, // на котором основное окно жило до 0.0.6, дребезжит и для слежения за
// чтобы followSpeaker не дёргался на каждый чих). // спикером не годится.
const speakingCameraKeys = const speakingCameraKeys = speakingParticipants
variant === 'pip'
? speakingParticipants
.map((p) => cameraTracks.find((t) => t.participant.identity === p.identity)) .map((p) => cameraTracks.find((t) => t.participant.identity === p.identity))
.filter((t): t is TrackReferenceOrPlaceholder => Boolean(t)) .filter((t): t is TrackReferenceOrPlaceholder => Boolean(t))
.map(stageTrackKey) .map(stageTrackKey)
: cameraTracks.filter((t) => t.participant.isSpeaking).map(stageTrackKey)
const result = pickStageFocus({ const result = pickStageFocus({
cameraKeys: cameraTracks.map(stageTrackKey), cameraKeys,
screenShareKeys: screenShareTracks.map(stageTrackKey), screenShareKeys,
speakingCameraKeys, speakingCameraKeys,
// Приоритет «говорящий с камерой выше говорящего без камеры» — только
// основному окну: PiP по договорённости ведёт себя ровно как раньше.
cameraKeysWithVideo: variant === 'pip' ? [] : cameraTracks.filter(hasLiveVideo).map(stageTrackKey),
prevKeys, prevKeys,
prevFocusKey: focusKey, prevFocusKey: focusKey,
followSpeaker: variant === 'pip', pinnedKey: pinnedAlive ? pinnedKey : null,
// Только для PiP — в основном окне фолбэк на «первый трек» не менялся followSpeaker: true,
// (см. JSDoc про speakingChanged выше: поведение full-варианта не трогаем). holdScreenShare: variant !== 'pip',
// Только для PiP — в основном окне фолбэк на «первый трек» не менялся.
localKey: variant === 'pip' ? `${room.localParticipant.identity}:${Track.Source.Camera}` : null, localKey: variant === 'pip' ? `${room.localParticipant.identity}:${Track.Source.Camera}` : null,
}) })
if (result.focusKey !== focusKey) { if (result.focusKey !== focusKey) {
@@ -169,6 +302,14 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
} }
} }
// Отдаём фокус наружу (в `RoomPage`), чтобы он пережил размонтирование этой
// сцены и достался следующей — см. `initialFocusKey`. Именно эффект, а не
// вызов в теле рендера: setState ЧУЖОГО компонента во время рендера React
// запрещает.
useEffect(() => {
onFocusKeyChange?.(focusKey)
}, [focusKey, onFocusKeyChange])
const focusTrack = tracks.find((t) => stageTrackKey(t) === focusKey) ?? screenShareTracks[0] ?? cameraTracks[0] const focusTrack = tracks.find((t) => stageTrackKey(t) === focusKey) ?? screenShareTracks[0] ?? cameraTracks[0]
const focusTrackKey = focusTrack ? stageTrackKey(focusTrack) : null const focusTrackKey = focusTrack ? stageTrackKey(focusTrack) : null
// При активной демонстрации карусель — ВСЕ камеры (включая демонстратора) И // При активной демонстрации карусель — ВСЕ камеры (включая демонстратора) И
@@ -192,6 +333,15 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
void room.localParticipant.setScreenShareEnabled(false) void room.localParticipant.setScreenShareEnabled(false)
} }
/**
* Закрепить/открепить плитку: повторное нажатие на уже закреплённой снимает
* закрепление. Ключ приходит из самой плитки (она знает свой трек из
* контекста — в карусели/гриде плитки рендерятся шаблоном, без пропсов).
*/
function handleTogglePin(key: string) {
setPinnedKey((prev) => (prev === key ? null : key))
}
// Мини-плеер показывает ТОЛЬКО активное окно — без карусели/ // Мини-плеер показывает ТОЛЬКО активное окно — без карусели/
// грида, одна плитка на весь контейнер (см. `.room-single-tile`, // грида, одна плитка на весь контейнер (см. `.room-single-tile`,
// `styles/room.css`). `focusTrack` уже вычислен выше тем же `pickStageFocus` // `styles/room.css`). `focusTrack` уже вычислен выше тем же `pickStageFocus`
@@ -205,22 +355,94 @@ export function RoomStage({ variant = 'full' }: { variant?: 'full' | 'pip' }) {
) )
} }
// Демонстрация экрана перебивает выбранный режим (обоснование — в докстринге).
const effectiveMode: StageLayoutMode = hasScreenShare ? 'standard' : layoutMode
const liveCameraTracks = cameraTracks.filter(hasLiveVideo)
// Кого показываем сбоку в карусели. В «плитках» — никого (все равноправны),
// в «живых плитках» — тех, у кого камера выключена (а если камеры нет ни у
// кого, карусель пуста: все ушли в сетку), в «стандарте» — всех, кроме
// плитки в фокусе.
const sideTracks: TrackReferenceOrPlaceholder[] =
effectiveMode === 'tiles'
? []
: effectiveMode === 'live-tiles'
? liveCameraTracks.length > 0
? cameraTracks.filter((t) => !hasLiveVideo(t))
: []
: carouselTracks
const showCarousel = !hideOthers && sideTracks.length > 0
// Закрепление имеет смысл только там, где есть «крупная плитка» —
// в плиточных режимах фокуса нет, поэтому и булавки на плитках нет.
const pinProps =
effectiveMode === 'standard' ? { pinnedKey, onTogglePin: handleTogglePin } : {}
function renderMain(): ReactNode {
if (effectiveMode === 'tiles') {
return (
<StageGrid tracks={tracks}>
<RoomParticipantTile />
</StageGrid>
)
}
if (effectiveMode === 'live-tiles') {
return (
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks}>
<RoomParticipantTile />
</StageGrid>
)
}
// «Стандарт» с единственным участником (карусель пуста и скрывать нечего) —
// прежнее поведение: равномерная сетка на всю сцену, а не фокус-плитка.
if (sideTracks.length === 0 && !hideOthers) {
return (
<StageGrid tracks={tracks}>
<RoomParticipantTile {...pinProps} />
</StageGrid>
)
}
// FocusLayout оригинала — лёгкая обёртка ровно над ParticipantTile
// (см. её исходник), поэтому вместо неё используем свою обёртку
// напрямую с тем же trackRef (аватар в фокус-плитке).
return (
focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} {...pinProps} />
)
}
return ( return (
<section className="stage"> <section className="stage">
{!hasScreenShare && (!focusTrack || carouselTracks.length === 0) ? ( {showCarousel ? (
<GridLayout tracks={tracks} className="stage-tiles">
<RoomParticipantTile />
</GridLayout>
) : (
<FocusLayoutContainer className="stage-tiles"> <FocusLayoutContainer className="stage-tiles">
<CarouselLayout tracks={carouselTracks}> {/* Обёртка колонки миниатюр: сама карусель не может быть прямым
<RoomParticipantTile /> ребёнком грида, потому что над ней стоит кнопка «Скрыть». Из-за
этого мобильный `.lk-carousel{order:1}` библиотеки перестаёт
действовать — порядок переносим на `.stage-side` (см. room.css). */}
<div className="stage-side">
{/* Быстрый способ убрать карусель, не открывая меню «Вид». Только
на широком экране: на мобильном колонка превращается в узкую
полосу снизу, где кнопке не место, — там переключатель живёт в
шторке настроек. Вернуть участников можно кнопкой на сцене. */}
{!isCompact && onHideOthers && (
<button type="button" className="stage-side-hide" onClick={onHideOthers}>
<EyeOff className="lucide" aria-hidden="true" />
<span>Скрыть</span>
</button>
)}
<CarouselLayout tracks={sideTracks}>
<RoomParticipantTile {...pinProps} />
</CarouselLayout> </CarouselLayout>
{/* FocusLayout оригинала — лёгкая обёртка ровно над ParticipantTile </div>
(см. её исходник), поэтому вместо неё используем свою обёртку {renderMain()}
напрямую с тем же trackRef (аватар в фокус-плитке). */}
{focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />}
</FocusLayoutContainer> </FocusLayoutContainer>
) : (
// Карусели нет — основная область занимает сцену целиком.
<div className="stage-tiles stage-main-only">{renderMain()}</div>
)}
{hideOthers && sideTracks.length > 0 && (
<button type="button" className="stage-show-others" onClick={onShowOthers}>
<Users className="lucide" aria-hidden="true" />
<span>Показать остальных ({sideTracks.length})</span>
</button>
)} )}
<RoomAudioRenderer /> <RoomAudioRenderer />
</section> </section>

View File

@@ -15,6 +15,8 @@ import {
import { Track, type ScreenShareCaptureOptions } from 'livekit-client' import { Track, type ScreenShareCaptureOptions } from 'livekit-client'
import { DisconnectButton, useTrackToggle } from '@livekit/components-react' import { DisconnectButton, useTrackToggle } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider' import { useToast } from '@/components/ui/ToastProvider'
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions'
/** /**
* Опции захвата демонстрации экрана: `audio: true` — звук * Опции захвата демонстрации экрана: `audio: true` — звук
@@ -36,7 +38,7 @@ const SCREEN_SHARE_CAPTURE_OPTIONS: ScreenShareCaptureOptions = {
systemAudio: 'include', systemAudio: 'include',
} }
interface RoomToolbarProps { interface RoomToolbarProps extends StageViewProps {
/** Показывать ли кнопку чата — `JoinOut.chat_enabled` И чат не помечен недоступным (close-код 4404). */ /** Показывать ли кнопку чата — `JoinOut.chat_enabled` И чат не помечен недоступным (close-код 4404). */
chatVisible: boolean chatVisible: boolean
chatOpen: boolean chatOpen: boolean
@@ -56,10 +58,15 @@ interface RoomToolbarProps {
} }
/** /**
* Нижний тулбар комнаты: микрофон/камера/демонстрация экрана/ * Нижний тулбар комнаты: микрофон/камера/демонстрация экрана/вид сцены/
* настройки устройств/полноэкранный режим/мини-плеер/чат/выход — собственные * настройки устройств/полноэкранный режим/мини-плеер/чат/выход — собственные
* кнопки на хуках LiveKit (useTrackToggle/DisconnectButton) и панели чата, * кнопки на хуках LiveKit (useTrackToggle/DisconnectButton) и панели чата,
* стилизованные по design/mockups/room.html. * стилизованные по design/mockups/room.html.
*
* Кнопка «Вид» (режимы показа и скрытие остальных) рендерится ТОЛЬКО на
* широком экране — условным рендерингом, а не скрытием через CSS: тулбар на
* мобильном и так ужат до пяти кнопок, а те же настройки там доступны секцией
* «Вид» в шторке настроек (`DeviceSettingsDialog`).
*/ */
export function RoomToolbar({ export function RoomToolbar({
chatVisible, chatVisible,
@@ -73,8 +80,13 @@ export function RoomToolbar({
pipSupported, pipSupported,
pipActive, pipActive,
onTogglePiP, onTogglePiP,
layoutMode,
onLayoutModeChange,
hideOthers,
onHideOthersChange,
}: RoomToolbarProps) { }: RoomToolbarProps) {
const toast = useToast() const toast = useToast()
const isCompact = useIsCompactViewport()
const mic = useTrackToggle({ source: Track.Source.Microphone }) const mic = useTrackToggle({ source: Track.Source.Microphone })
const camera = useTrackToggle({ source: Track.Source.Camera }) const camera = useTrackToggle({ source: Track.Source.Camera })
const screenShare = useTrackToggle({ const screenShare = useTrackToggle({
@@ -127,7 +139,7 @@ export function RoomToolbar({
<button <button
type="button" type="button"
{...screenShare.buttonProps} {...screenShare.buttonProps}
className={`tb-btn${screenShare.enabled ? ' is-sharing' : ''}`} className={`tb-btn tb-btn--screenshare${screenShare.enabled ? ' is-sharing' : ''}`}
aria-label={screenShare.enabled ? 'Остановить демонстрацию экрана' : 'Демонстрировать экран'} aria-label={screenShare.enabled ? 'Остановить демонстрацию экрана' : 'Демонстрировать экран'}
> >
<span className="icon-shell"> <span className="icon-shell">
@@ -140,6 +152,15 @@ export function RoomToolbar({
<span className="label">Демонстрация</span> <span className="label">Демонстрация</span>
</button> </button>
{!isCompact && (
<StageViewMenu
layoutMode={layoutMode}
onLayoutModeChange={onLayoutModeChange}
hideOthers={hideOthers}
onHideOthersChange={onHideOthersChange}
/>
)}
<button <button
type="button" type="button"
className="tb-btn" className="tb-btn"
@@ -155,7 +176,7 @@ export function RoomToolbar({
{fullscreenSupported && ( {fullscreenSupported && (
<button <button
type="button" type="button"
className={`tb-btn${fullscreenActive ? ' is-panel-open' : ''}`} className={`tb-btn tb-btn--fullscreen${fullscreenActive ? ' is-panel-open' : ''}`}
aria-label={fullscreenActive ? 'Выйти из полноэкранного режима' : 'Развернуть на весь экран'} aria-label={fullscreenActive ? 'Выйти из полноэкранного режима' : 'Развернуть на весь экран'}
aria-pressed={fullscreenActive} aria-pressed={fullscreenActive}
onClick={onToggleFullscreen} onClick={onToggleFullscreen}
@@ -174,7 +195,7 @@ export function RoomToolbar({
{pipSupported && ( {pipSupported && (
<button <button
type="button" type="button"
className={`tb-btn${pipActive ? ' is-panel-open' : ''}`} className={`tb-btn tb-btn--pip${pipActive ? ' is-panel-open' : ''}`}
aria-label={pipActive ? 'Свернуть мини-окно' : 'Открыть мини-окно'} aria-label={pipActive ? 'Свернуть мини-окно' : 'Открыть мини-окно'}
aria-pressed={pipActive} aria-pressed={pipActive}
onClick={onTogglePiP} onClick={onTogglePiP}

View File

@@ -0,0 +1,113 @@
import { useRef, type ReactNode, type RefObject } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import {
TrackLoop,
useGridLayout,
usePagination,
useSwipe,
type GridLayoutDefinition,
type TrackReferenceOrPlaceholder,
} from '@livekit/components-react'
/**
* Раскладки равномерной сетки — расширенный набор поверх библиотечного
* `GRID_LAYOUTS` (@livekit/components-core, `helper/grid-layouts.ts`).
*
* Зачем свой набор. Библиотечный требует `minWidth: 560` уже для сетки 2×2,
* поэтому на телефоне (~390px) максимальной раскладкой оказывается `1×2` — две
* плитки на страницу, и десяток участников превращается в пять страниц
* листания. Здесь добавлены два портретных варианта под узкий экран (2×2 и
* 2×3): плитка шириной ~190px с именем и аватаром читается нормально, а
* страниц становится втрое меньше.
*
* Как это читает `selectGridLayout` (см. её исходник): раскладки сортируются
* по `maxTiles` (при равенстве — по `minWidth`), берётся первая, вмещающая всех
* участников, при этом раскладка пропускается, если ДАЛЬШЕ есть другая с тем же
* `maxTiles` и подходящей ориентацией контейнера. Отсюда пары «портрет/
* ландшафт» на одно и то же число плиток: 1×2 / 2×1 и 2×3 / 3×2 — на широком
* экране выигрывает горизонтальный вариант, на узком вертикальный. Если
* контейнер не дотягивает до `minWidth`/`minHeight`, функция рекурсивно
* спускается к меньшей раскладке, а лишние участники уходят на следующую
* страницу (`usePagination`).
*/
const STAGE_GRID_LAYOUTS: GridLayoutDefinition[] = [
{ columns: 1, rows: 1 },
{ columns: 1, rows: 2, orientation: 'portrait' },
{ columns: 2, rows: 1, orientation: 'landscape' },
// 4 плитки помещаются и на телефоне: 390px / 2 ≈ 190px на плитку.
{ columns: 2, rows: 2, minWidth: 360 },
{ columns: 2, rows: 3, minWidth: 360, minHeight: 480, orientation: 'portrait' },
{ columns: 3, rows: 2, minWidth: 700, orientation: 'landscape' },
{ columns: 3, rows: 3, minWidth: 700 },
{ columns: 4, rows: 4, minWidth: 960 },
{ columns: 5, rows: 5, minWidth: 1100 },
]
interface StageGridProps {
tracks: TrackReferenceOrPlaceholder[]
/** Шаблон плитки — рендерится для каждого трека страницы (как у `GridLayout`, через `TrackLoop`). */
children: ReactNode
}
/**
* Равномерная сетка плиток с пагинацией — собственная сборка вместо
* библиотечного `GridLayout`.
*
* Причина ровно одна: `GridLayout` не пробрасывает `gridLayouts` в
* `useGridLayout`, то есть набор раскладок у неё жёстко зашит (см.
* `STAGE_GRID_LAYOUTS` выше). Всё остальное — те же публичные хуки, что
* использует оригинал (`useGridLayout` + `usePagination` + `useSwipe` +
* `TrackLoop`) и тот же класс `.lk-grid-layout`, поэтому и стили библиотеки, и
* `useVisualStableUpdate` внутри пагинации работают как раньше. Отличается
* только элемент управления страницами: библиотечные `PaginationIndicator`/
* `PaginationControl` из пакета не экспортируются, вместо них — свой
* `.stage-grid-pages` (кнопки со стрелками + счётчик, доступен и мышью, и с
* клавиатуры; на тач-экране страницы листаются ещё и свайпом).
*/
export function StageGrid({ tracks, children }: StageGridProps) {
const gridEl = useRef<HTMLDivElement | null>(null)
// Хуки библиотеки объявлены с `RefObject<HTMLDivElement>` (типы React 18, где
// `current` был readonly и тип вёл себя ковариантно). В типах React 19
// `current` мутабельный, поэтому `RefObject<HTMLDivElement | null>` в такой
// параметр уже не присваивается — приведение безопасно: оба хука только
// читают `.current` (ResizeObserver и слушатели touch-событий).
const gridRef = gridEl as RefObject<HTMLDivElement>
const { layout } = useGridLayout(gridRef, tracks.length, { gridLayouts: STAGE_GRID_LAYOUTS })
const pagination = usePagination(layout.maxTiles, tracks)
useSwipe(gridRef, {
onLeftSwipe: pagination.nextPage,
onRightSwipe: pagination.prevPage,
})
const hasPages = pagination.totalPageCount > 1
return (
<div ref={gridEl} className="lk-grid-layout stage-grid" data-lk-pagination={hasPages}>
<TrackLoop tracks={pagination.tracks}>{children}</TrackLoop>
{hasPages && (
<div className="stage-grid-pages">
<button
type="button"
aria-label="Предыдущая страница участников"
disabled={pagination.currentPage <= 1}
onClick={pagination.prevPage}
>
<ChevronLeft className="lucide" aria-hidden="true" />
</button>
<span aria-live="polite">
{pagination.currentPage} / {pagination.totalPageCount}
</span>
<button
type="button"
aria-label="Следующая страница участников"
disabled={pagination.currentPage >= pagination.totalPageCount}
onClick={pagination.nextPage}
>
<ChevronRight className="lucide" aria-hidden="true" />
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,130 @@
import { useEffect, useId, useRef, useState } from 'react'
import { LayoutGrid } from 'lucide-react'
import { STAGE_LAYOUT_MODE_OPTIONS, type StageLayoutMode } from '@/lib/stageLayoutMode'
export interface StageViewProps {
layoutMode: StageLayoutMode
onLayoutModeChange: (mode: StageLayoutMode) => void
/** Карусель остальных участников скрыта — на сцене только основная область. */
hideOthers: boolean
onHideOthersChange: (hide: boolean) => void
}
/**
* Блок выбора вида сцены: режим показа (три варианта) + скрытие остальных
* участников. Один и тот же блок рендерится в двух местах — в поповере кнопки
* «Вид» на десктопе (`StageViewMenu` ниже) и секцией внутри мобильной шторки
* настроек (`DeviceSettingsDialog`), поэтому вынесен отдельно.
*
* «Скрыть остальных» в режиме «Плитки» заблокировано, а не спрятано: все
* плитки там равноправны, скрывать нечего, но исчезающий на ровном месте
* переключатель читался бы как баг — вместо этого он выключен с пояснением.
*/
export function StageViewOptions({
layoutMode,
onLayoutModeChange,
hideOthers,
onHideOthersChange,
}: StageViewProps) {
// Радиогруппе нужно имя, уникальное на документ: блок может оказаться на
// странице дважды (десктопный поповер и шторка живут в разных ветках
// рендера, но полагаться на это не стоит).
const groupName = useId()
const hideOthersDisabled = layoutMode === 'tiles'
return (
<div className="stage-view-options">
<fieldset className="stage-view-modes">
<legend>Режим показа</legend>
{STAGE_LAYOUT_MODE_OPTIONS.map((option) => (
<label
key={option.mode}
className={`stage-view-mode${layoutMode === option.mode ? ' is-active' : ''}`}
>
<input
type="radio"
name={groupName}
value={option.mode}
checked={layoutMode === option.mode}
onChange={() => onLayoutModeChange(option.mode)}
/>
<span className="stage-view-mode-text">
<span className="stage-view-mode-title">{option.title}</span>
<span className="stage-view-mode-hint">{option.hint}</span>
</span>
</label>
))}
</fieldset>
<label className={`stage-view-toggle${hideOthersDisabled ? ' is-disabled' : ''}`}>
<input
type="checkbox"
checked={hideOthers}
disabled={hideOthersDisabled}
onChange={(e) => onHideOthersChange(e.target.checked)}
/>
<span>Скрыть остальных участников</span>
</label>
{hideOthersDisabled && (
<p className="stage-view-note">В режиме «Плитки» все участники равноправны скрывать нечего.</p>
)}
</div>
)
}
/**
* Кнопка «Вид» в тулбаре с поповером над ней — десктопный вход в
* `StageViewOptions`. На мобильном не рендерится вовсе (см. `RoomToolbar`):
* там тулбар и так ужат до пяти кнопок, а те же настройки доступны секцией
* «Вид» в шторке настроек.
*
* Закрытие по клику вне и Escape — тем же паттерном, что меню пользователя в
* `ShellTopbar` (слушатели на document, пока меню открыто).
*/
export function StageViewMenu(props: StageViewProps) {
const [open, setOpen] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function handlePointerDown(event: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
setOpen(false)
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handlePointerDown)
document.addEventListener('keydown', handleKeydown)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
document.removeEventListener('keydown', handleKeydown)
}
}, [open])
return (
<div className="tb-menu-wrap" ref={wrapRef}>
<button
type="button"
className={`tb-btn${open ? ' is-panel-open' : ''}`}
aria-label="Вид сцены"
aria-expanded={open}
aria-haspopup="dialog"
onClick={() => setOpen((v) => !v)}
>
<span className="icon-shell">
<LayoutGrid className="lucide" aria-hidden="true" />
</span>
<span className="label">Вид</span>
</button>
{open && (
<div className="tb-menu" role="dialog" aria-label="Вид сцены">
<StageViewOptions {...props} />
</div>
)}
</div>
)
}

View File

@@ -1,9 +1,11 @@
/** /**
* Чистая функция выбора «сцены в фокусе» (демонстрация экрана). * Чистая функция выбора «сцены в фокусе» (демонстрация экрана).
* *
* Никаких зависимостей от React/DOM/LiveKit SDK — на вход только примитивы, * Никаких зависимостей от React/DOM/LiveKit SDK в РАНТАЙМЕ — на вход только
* на выход тоже примитивы; можно покрыть unit-тестом при появлении раннера * примитивы, на выход тоже примитивы; можно покрыть unit-тестом при появлении
* (vitest в проект сознательно не вводим, тестируем вручную). * раннера (vitest в проект сознательно не вводим, тестируем вручную).
* Единственный импорт из SDK — `import type` у `stageTrackKey` (тип стирается
* при компиляции, рантайм-зависимости не добавляет).
* *
* Ключ трека — НЕ sid публикации и НЕ голая identity участника, а составной * Ключ трека — НЕ sid публикации и НЕ голая identity участника, а составной
* `${identity}:${source}` (см. `RoomStage.tsx`, функция `stageTrackKey`): * `${identity}:${source}` (см. `RoomStage.tsx`, функция `stageTrackKey`):
@@ -20,9 +22,21 @@
* однозначность (camera и screen_share одного участника — разные ключи). * однозначность (camera и screen_share одного участника — разные ключи).
*/ */
import type { TrackReferenceOrPlaceholder } from '@livekit/components-react'
/** Вид источника трека на сцене. */ /** Вид источника трека на сцене. */
export type StageFocusKind = 'camera' | 'screen_share' export type StageFocusKind = 'camera' | 'screen_share'
/**
* Ключ трека для `pickStageFocus` — см. обоснование схемы в начале файла.
* Живёт здесь (а не в `RoomStage.tsx`), потому что нужен обеим сторонам:
* сцене — чтобы считать фокус, плитке (`RoomParticipantTile`) — чтобы понять,
* закреплена ли именно она, и каким ключом сообщить о нажатии на «закрепить».
*/
export function stageTrackKey(t: TrackReferenceOrPlaceholder): string {
return `${t.participant.identity}:${t.source}`
}
export interface PickStageFocusInput { export interface PickStageFocusInput {
/** Ключи текущих камера-треков (один на участника — трек либо его плейсхолдер). */ /** Ключи текущих камера-треков (один на участника — трек либо его плейсхолдер). */
cameraKeys: readonly string[] cameraKeys: readonly string[]
@@ -35,19 +49,51 @@ export interface PickStageFocusInput {
* `followSpeaker`, для живого переключения фокуса. * `followSpeaker`, для живого переключения фокуса.
*/ */
speakingCameraKeys: readonly string[] speakingCameraKeys: readonly string[]
/**
* Подмножество `cameraKeys` с ЖИВЫМ видео (камера включена и не в мьюте) —
* среди нескольких одновременно говорящих такой участник выигрывает у
* говорящего с выключенной камерой: показывать крупно аватар-заглушку, когда
* рядом говорит человек с картинкой, бессмысленно. Не указан — приоритета
* нет, берётся первый (самый громкий) говорящий, как было раньше.
*/
cameraKeysWithVideo?: readonly string[]
/** Объединённый набор ключей (camera+screenshare) с ПРЕДЫДУЩЕГО рендера — определяет, какие screenshare-ключи «новые». */ /** Объединённый набор ключей (camera+screenshare) с ПРЕДЫДУЩЕГО рендера — определяет, какие screenshare-ключи «новые». */
prevKeys: readonly string[] prevKeys: readonly string[]
/** Ключ, что был в фокусе на предыдущем рендере; `null` — фокус ещё не выбирался. */ /** Ключ, что был в фокусе на предыдущем рендере; `null` — фокус ещё не выбирался. */
prevFocusKey: string | null prevFocusKey: string | null
/** /**
* Режим мини-плеера (PiP): фокус должен ЖИВО следовать за * Ключ трека, ЗАКРЕПЛЁННОГО пользователем в основном окне (кнопка-булавка на
* активным спикером (переключаться сразу, а не удерживать текущий), в * плитке, состояние живёт в `RoomStage.tsx`); `null` — закрепления нет.
* отличие от основного окна сцены — там держим фокус, даже если заговорил * Закрепление держит фокус вопреки говорящим, но уступает ЛЮБОЙ активной
* кто-то другой (см. правило 2 ниже и обоснование в `RoomStage.tsx` про * демонстрации экрана (формулировка оператора: «перебивается только чьей-либо
* дребезг `isSpeaking` у фейковых медиапотоков). По умолчанию `false` — * демонстрацией экрана») — а когда демонстрация закончилась, фокус
* поведение основного окна не меняется. * возвращается именно на закреплённого, а не на того, кто был до неё:
* правило закрепления стоит ВЫШЕ удержания предыдущего фокуса.
* Ключ закреплённого участника, покинувшего комнату, игнорируется (его нет
* ни в `cameraKeys`, ни в `screenShareKeys`) — снимает закрепление вызывающая
* сторона.
*/
pinnedKey?: string | null
/**
* Фокус должен ЖИВО следовать за активным спикером (переключаться сразу, а
* не удерживать текущий). С 0.0.6 включено и для мини-плеера (PiP), и для
* основного окна — решение оператора (этап 3, задача 3.2). Защита от
* дребезга — на стороне вызывающего: источник «говорящих» — throttled
* `useSpeakingParticipants()` поверх `RoomEvent.ActiveSpeakersChanged`, а в
* основном окне ещё и удержание в ~1.2 с (см. `useSteadySpeakers` в
* `RoomStage.tsx`), не сырой дребезжащий `participant.isSpeaking`.
* По умолчанию `false` — фокус удерживается (см. правило 5).
*/ */
followSpeaker?: boolean followSpeaker?: boolean
/**
* Живая демонстрация экрана в фокусе НЕ уступает заговорившему участнику
* (правило 3). Нужно основному окну: там демонстрация — это содержательный
* центр разговора, и уводить её из большого окна на каждую реплику нельзя.
* Мини-плеер (PiP) показывает ровно одну плитку и намеренно ведёт себя иначе
* — всегда показывает того, кто говорит, поэтому там `false` (поведение
* PiP не менялось с 0.0.4).
*/
holdScreenShare?: boolean
/** /**
* Ключ локального участника (та же схема `identity:source`) — предпоследний * Ключ локального участника (та же схема `identity:source`) — предпоследний
* фолбэк, ПЕРЕД чисто первым элементом набора: если фокуса ещё не было и * фолбэк, ПЕРЕД чисто первым элементом набора: если фокуса ещё не было и
@@ -64,6 +110,20 @@ export interface PickStageFocusResult {
kind: StageFocusKind | null kind: StageFocusKind | null
} }
/**
* Выбирает говорящего, которого стоит показать крупно: среди живых говорящих
* (упорядоченных по громкости) сначала ищем того, у кого включена камера, и
* только если такого нет — берём самого громкого как есть.
*/
function pickSpeakerKey(
speakingCameraKeys: readonly string[],
cameraKeys: readonly string[],
cameraKeysWithVideo: readonly string[],
): string | null {
const liveSpeakers = speakingCameraKeys.filter((key) => cameraKeys.includes(key))
return liveSpeakers.find((key) => cameraKeysWithVideo.includes(key)) ?? liveSpeakers[0] ?? null
}
/** /**
* Выбирает, какой трек показать крупно (в `FocusLayoutContainer`). * Выбирает, какой трек показать крупно (в `FocusLayoutContainer`).
* *
@@ -72,24 +132,38 @@ export interface PickStageFocusResult {
* фокус безусловно переходит на него (последний из новых, если появилось * фокус безусловно переходит на него (последний из новых, если появилось
* сразу несколько), даже если до этого в фокусе была камера или другая * сразу несколько), даже если до этого в фокусе была камера или другая
* демонстрация. Так же ведут себя типовые UI конференций (Google Meet). * демонстрация. Так же ведут себя типовые UI конференций (Google Meet).
* 2. `followSpeaker` (только PiP): если сейчас есть говорящий — фокус СРАЗУ * 2. Закрепление (`pinnedKey`, только основное окно): закреплённый участник
* переходит на него, даже если текущий фокус ещё жив. В основном окне * забирает фокус у говорящих и у удержания предыдущего фокуса, но уступает
* (`followSpeaker: false`) этот шаг пропускается — см. правило 3. * ЛЮБОЙ активной демонстрации экрана. Поэтому правило и стоит выше
* 3. Иначе, если текущий фокус жив (остался среди camera/screenshare-ключей) — * удержания (правило 5): как только демонстрация закончилась и
* `screenShareKeys` опустел, фокус возвращается на закреплённого, а не
* остаётся на том, кто был в фокусе до демонстрации.
* 3. `holdScreenShare` (только основное окно): демонстрация, уже стоящая в
* фокусе, не уступает заговорившему — иначе большое окно уводило бы шэр на
* каждую реплику. В PiP шаг пропускается (там одна плитка и она всегда
* показывает говорящего).
* 4. `followSpeaker`: если сейчас есть говорящий — фокус СРАЗУ переходит на
* него, даже если текущий фокус ещё жив; среди одновременно говорящих
* предпочитаем того, у кого включена камера (`cameraKeysWithVideo`).
* При `followSpeaker: false` этот шаг пропускается — см. правило 5.
* 5. Иначе, если текущий фокус жив (остался среди camera/screenshare-ключей) —
* держим его: НЕ дёргаем фокус на каждый ре-рендер (изменение состава * держим его: НЕ дёргаем фокус на каждый ре-рендер (изменение состава
* участников, дребезг isSpeaking и т.п.). Это и есть «стабильный фолбэк» * участников, дребезг isSpeaking и т.п.). Это и есть «стабильный фолбэк»,
* для PiP, когда никто не говорит — держим предыдущего активного. * когда никто не говорит — держим предыдущего активного.
* 4. Иначе (фокуса не было или он пропал) — приоритет активной демонстрации * 6. Иначе (фокуса не было или он пропал) — приоритет активной демонстрации
* над камерой; среди камер — активный спикер, иначе `localKey` (если * над камерой; среди камер — активный спикер (снова с приоритетом камеры),
* указан и жив), иначе первая по порядку. * иначе `localKey` (если указан и жив), иначе первая по порядку.
*/ */
export function pickStageFocus({ export function pickStageFocus({
cameraKeys, cameraKeys,
screenShareKeys, screenShareKeys,
speakingCameraKeys, speakingCameraKeys,
cameraKeysWithVideo = [],
prevKeys, prevKeys,
prevFocusKey, prevFocusKey,
pinnedKey = null,
followSpeaker = false, followSpeaker = false,
holdScreenShare = false,
localKey = null, localKey = null,
}: PickStageFocusInput): PickStageFocusResult { }: PickStageFocusInput): PickStageFocusResult {
if (cameraKeys.length === 0 && screenShareKeys.length === 0) { if (cameraKeys.length === 0 && screenShareKeys.length === 0) {
@@ -102,8 +176,25 @@ export function pickStageFocus({
return { focusKey: newScreenShareKeys[newScreenShareKeys.length - 1], kind: 'screen_share' } return { focusKey: newScreenShareKeys[newScreenShareKeys.length - 1], kind: 'screen_share' }
} }
if (pinnedKey) {
// Закреплена сама демонстрация — она и есть «активная демонстрация»,
// уступать нечему (UI позволяет закрепить любую плитку, включая шэр).
if (screenShareKeys.includes(pinnedKey)) {
return { focusKey: pinnedKey, kind: 'screen_share' }
}
// Закреплена камера: пока в комнате идёт чья-то демонстрация, она
// перебивает закрепление (правило оператора) — идём дальше по списку.
if (cameraKeys.includes(pinnedKey) && screenShareKeys.length === 0) {
return { focusKey: pinnedKey, kind: 'camera' }
}
}
if (holdScreenShare && prevFocusKey && screenShareKeys.includes(prevFocusKey)) {
return { focusKey: prevFocusKey, kind: 'screen_share' }
}
if (followSpeaker) { if (followSpeaker) {
const liveSpeaker = speakingCameraKeys.find((key) => cameraKeys.includes(key)) const liveSpeaker = pickSpeakerKey(speakingCameraKeys, cameraKeys, cameraKeysWithVideo)
if (liveSpeaker) { if (liveSpeaker) {
return { focusKey: liveSpeaker, kind: 'camera' } return { focusKey: liveSpeaker, kind: 'camera' }
} }
@@ -120,7 +211,7 @@ export function pickStageFocus({
return { focusKey: screenShareKeys[screenShareKeys.length - 1], kind: 'screen_share' } return { focusKey: screenShareKeys[screenShareKeys.length - 1], kind: 'screen_share' }
} }
const speaking = speakingCameraKeys.find((key) => cameraKeys.includes(key)) const speaking = pickSpeakerKey(speakingCameraKeys, cameraKeys, cameraKeysWithVideo)
if (speaking) { if (speaking) {
return { focusKey: speaking, kind: 'camera' } return { focusKey: speaking, kind: 'camera' }
} }

View File

@@ -0,0 +1,27 @@
import { useEffect, useState } from 'react'
/** Совпадает с мобильным брейкпоинтом комнаты (`room.css`, `max-width: 600px`). */
const COMPACT_VIEWPORT_QUERY = '(max-width: 600px)'
/**
* Живое отслеживание мобильной ширины — та же схема, что системная тема в
* `useTheme.ts` (matchMedia + change-листенер).
*
* Нужен там, где мобильный вариант — это ДРУГАЯ разметка, а не другие стили:
* шторка вместо модалки (`DeviceSettingsDialog`) и условный рендеринг кнопок
* тулбара (`RoomToolbar`). Прятать элементы новым CSS-правилом поверх
* медиазапроса в `room.css` намеренно не стали — там уже была битва
* специфичности (см. комментарий в конце `room.css`).
*/
export 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
}

View File

@@ -0,0 +1,32 @@
import { useEffect, useRef } from 'react'
/**
* Общее поведение модалок оболочки (`.modal-overlay`/`.modal-panel`):
* закрытие по Escape и возврат фокуса на элемент, который был активен до
* открытия модалки (обычно кнопка, её вызвавшая) — доступность по клавиатуре.
*/
export function useModalDismiss(onClose: () => void) {
const previouslyFocused = useRef<Element | null>(null)
// Всегда актуальный колбэк без пересоздания подписки на keydown при каждом
// рендере (onClose у вызывающих обычно инлайновая функция). Синхронизация —
// отдельным эффектом (не прямым присваиванием в теле рендера, см.
// react-hooks/refs — рефы нельзя трогать во время рендера).
const onCloseRef = useRef(onClose)
useEffect(() => {
onCloseRef.current = onClose
})
useEffect(() => {
previouslyFocused.current = document.activeElement
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onCloseRef.current()
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
if (previouslyFocused.current instanceof HTMLElement) previouslyFocused.current.focus()
}
}, [])
}

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

@@ -0,0 +1,24 @@
import { CheckCircle2, Clock, Play, Repeat } from 'lucide-react'
import type { OccurrenceOut } from '@/api/conferences'
export type OccurrenceBadgeKind = 'scheduled' | 'pinned' | 'live' | 'ended'
/** Общая логика статус-бейджа вхождения — используется чипом календаря и информационным окном (`ConferenceOccurrenceDialog`). */
export function occurrenceBadge(
occ: OccurrenceOut,
start: Date | null,
end: Date | null,
): { kind: OccurrenceBadgeKind; label: string } {
const now = new Date()
if (start && end && now >= start && now <= end) return { kind: 'live', label: 'Идёт сейчас' }
if (end && now > end) return { kind: 'ended', label: 'Завершена' }
if (occ.is_pinned) return { kind: 'pinned', label: 'Закреплена' }
return { kind: 'scheduled', label: 'Запланирована' }
}
export const OCCURRENCE_BADGE_ICON: Record<OccurrenceBadgeKind, typeof Clock> = {
scheduled: Clock,
pinned: Repeat,
live: Play,
ended: CheckCircle2,
}

View File

@@ -0,0 +1,55 @@
const STORAGE_KEY = 'vidconf-stage-layout-mode'
/**
* Режим показа участников на сцене конференции — выбирается пользователем
* явно (до 0.0.11 раскладка выбиралась автоматически по наличию демонстрации
* экрана и числу участников).
*
* - `standard` — один крупный участник (фокус) + остальные в карусели сбоку;
* кто именно в фокусе, по-прежнему решает `pickStageFocus`.
* - `tiles` — все участники равными плитками, без выделенного крупного.
* - `live-tiles` — равномерная сетка ТОЛЬКО из участников с включённой
* камерой, остальные (камера выключена) — в карусели сбоку.
*/
export type StageLayoutMode = 'standard' | 'tiles' | 'live-tiles'
/** Порядок и подписи режимов в переключателе (единый источник для тулбара и мобильной шторки). */
export const STAGE_LAYOUT_MODE_OPTIONS: readonly {
mode: StageLayoutMode
title: string
hint: string
}[] = [
{ mode: 'standard', title: 'Стандарт', hint: 'Крупно — активный участник, остальные сбоку' },
{ mode: 'tiles', title: 'Плитки', hint: 'Все участники равными плитками' },
{ mode: 'live-tiles', title: 'Живые плитки', hint: 'Плитками — только с камерой, остальные сбоку' },
]
function isStageLayoutMode(value: string | null): value is StageLayoutMode {
return value === 'standard' || value === 'tiles' || value === 'live-tiles'
}
/**
* Сохранённый режим показа — читается при входе в комнату.
*
* Персист сделан своим модулем (как `lib/audioOutputDevice.ts`), а не через
* `usePersistentUserChoices` LiveKit: их `LocalUserChoices` — фиксированная
* структура про устройства и имя, поля для нашей раскладки там нет и добавить
* его нельзя. Схема хранения та же: одна строка в localStorage, любые сбои
* доступа к нему гасятся (приватный режим/политики браузера).
*/
export function loadStageLayoutMode(): StageLayoutMode {
try {
const stored = localStorage.getItem(STORAGE_KEY)
return isStageLayoutMode(stored) ? stored : 'standard'
} catch {
return 'standard'
}
}
export function saveStageLayoutMode(mode: StageLayoutMode): void {
try {
localStorage.setItem(STORAGE_KEY, mode)
} catch {
// Сохранение недоступно — выбор продержится до конца сессии в комнате.
}
}

View File

@@ -1,11 +1,10 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ShellTopbar } from '@/components/layout/ShellTopbar' import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { AppFooter } from '@/components/layout/AppFooter'
import { AdminConferencesTab } from '@/components/admin/AdminConferencesTab' import { AdminConferencesTab } from '@/components/admin/AdminConferencesTab'
import { AdminUsersTab } from '@/components/admin/AdminUsersTab' import { AdminUsersTab } from '@/components/admin/AdminUsersTab'
import { AdminTeamsTab } from '@/components/admin/AdminTeamsTab' import { AdminTeamsTab } from '@/components/admin/AdminTeamsTab'
import { AdminSettingsTab } from '@/components/admin/AdminSettingsTab' import { AdminSettingsTab } from '@/components/admin/AdminSettingsTab'
import { getHealth } from '@/api/health'
import '@/styles/lobby.css' import '@/styles/lobby.css'
import '@/styles/calendar.css' import '@/styles/calendar.css'
import '@/styles/admin.css' import '@/styles/admin.css'
@@ -30,10 +29,6 @@ const TABS: { value: AdminTab; label: string }[] = [
*/ */
export function AdminPage() { export function AdminPage() {
const [tab, setTab] = useState<AdminTab>('conferences') const [tab, setTab] = useState<AdminTab>('conferences')
// Версия инстанса для футера — поле `version` в `/api/health`;
// если его нет в ответе, футер версию просто не показывает
// (см. `HealthOut.version`).
const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, retry: false })
return ( return (
<div className="admin-page page-shell"> <div className="admin-page page-shell">
@@ -63,11 +58,8 @@ export function AdminPage() {
{tab === 'users' && <AdminUsersTab />} {tab === 'users' && <AdminUsersTab />}
{tab === 'teams' && <AdminTeamsTab />} {tab === 'teams' && <AdminTeamsTab />}
{tab === 'settings' && <AdminSettingsTab />} {tab === 'settings' && <AdminSettingsTab />}
<footer className="admin-footer">
VidConf{health?.version ? ` · v${health.version}` : ''}
</footer>
</main> </main>
<AppFooter />
</div> </div>
) )
} }

View File

@@ -2,9 +2,11 @@ import { useCallback, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
import { ShellTopbar } from '@/components/layout/ShellTopbar' import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { AppFooter } from '@/components/layout/AppFooter'
import { ConferenceCalendar, type CalendarViewMode } from '@/components/calendar/ConferenceCalendar' import { ConferenceCalendar, type CalendarViewMode } from '@/components/calendar/ConferenceCalendar'
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard' import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
import { ConferenceOccurrenceDialog } from '@/components/calendar/ConferenceOccurrenceDialog' import { ConferenceOccurrenceDialog } from '@/components/calendar/ConferenceOccurrenceDialog'
import { useModalDismiss } from '@/hooks/useModalDismiss'
import { getCalendarOccurrences, getMyConferences, type ConferenceOut, type OccurrenceOut } from '@/api/conferences' import { getCalendarOccurrences, getMyConferences, type ConferenceOut, type OccurrenceOut } from '@/api/conferences'
import { pluralizeConferences } from '@/lib/pluralize' import { pluralizeConferences } from '@/lib/pluralize'
import '@/styles/lobby.css' import '@/styles/lobby.css'
@@ -22,17 +24,17 @@ const EMPTY_OCCURRENCES: OccurrenceOut[] = []
* Календарь-менеджер запланированных/закреплённых конференций * Календарь-менеджер запланированных/закреплённых конференций
* (см. design/mockups/calendar.html). * (см. design/mockups/calendar.html).
* *
* Редактирование в сайдбаре доступно только для «своих» вхождений — тех, * Форма создания/редактирования (`ConferenceFormCard`) живёт в модалке, не в
* что присутствуют в `/conferences/my` (закреплённые и предстоящие разовые * сетке страницы — календарь занимает всю ширину. Клик по чипу (в т.ч. по
* владельца; там же полная модель — recurrence/duration, которых нет в * своей конференции) открывает информационное окно (`ConferenceOccurrenceDialog`);
* `OccurrenceOut` календарной выборки). Остальные вхождения по клику * редактирование — отдельное действие: карандашик на чипе (только у
* открывают лёгкий информационный диалог с входом и ссылкой на «Мои * владельца, см. `myConferenceById`) или кнопка «Редактировать» в самом
* конференции» — согласованное отклонение от макета, вызванное отсутствием * информационном окне.
* в контракте эндпоинта получения одной конференции по id.
*/ */
export function CalendarPage() { export function CalendarPage() {
const [view, setView] = useState<CalendarViewMode>('dayGridWeek') const [view, setView] = useState<CalendarViewMode>('dayGridWeek')
const [range, setRange] = useState<VisibleRange | null>(null) const [range, setRange] = useState<VisibleRange | null>(null)
const [isFormOpen, setIsFormOpen] = useState(false)
const [editingConference, setEditingConference] = useState<ConferenceOut | null>(null) const [editingConference, setEditingConference] = useState<ConferenceOut | null>(null)
const [occurrenceDialogTarget, setOccurrenceDialogTarget] = useState<OccurrenceOut | null>(null) const [occurrenceDialogTarget, setOccurrenceDialogTarget] = useState<OccurrenceOut | null>(null)
@@ -45,21 +47,18 @@ export function CalendarPage() {
const { data: myConferences } = useQuery({ queryKey: ['conferences', 'my'], queryFn: getMyConferences }) const { data: myConferences } = useQuery({ queryKey: ['conferences', 'my'], queryFn: getMyConferences })
const myConferenceById = useMemo(() => new Map((myConferences ?? []).map((c) => [c.id, c])), [myConferences]) const myConferenceById = useMemo(() => new Map((myConferences ?? []).map((c) => [c.id, c])), [myConferences])
const handleOccurrenceClick = useCallback( const openCreateForm = useCallback(() => {
(occurrence: OccurrenceOut) => { setEditingConference(null)
// `/conferences/my` может содержать и конференции, куда пользователь setIsFormOpen(true)
// только приглашён участником (не организатор) — редактирование }, [])
// открываем только владельцу, иначе — тот же
// информационный диалог, что и для чужих вхождений. const openEditForm = useCallback((conference: ConferenceOut) => {
const owned = myConferenceById.get(occurrence.conference_id) setEditingConference(conference)
if (owned?.is_owner) { setIsFormOpen(true)
setEditingConference(owned) setOccurrenceDialogTarget(null)
} else { }, [])
setOccurrenceDialogTarget(occurrence)
} const closeForm = useCallback(() => setIsFormOpen(false), [])
},
[myConferenceById],
)
// Бэйл-аут по значению (не по ссылке): FullCalendar вызывает `datesSet` // Бэйл-аут по значению (не по ссылке): FullCalendar вызывает `datesSet`
// повторно всякий раз, когда меняется identity его пропсов (см. подробный // повторно всякий раз, когда меняется identity его пропсов (см. подробный
@@ -71,6 +70,8 @@ export function CalendarPage() {
setRange((prev) => (prev && prev.fromIso === fromIso && prev.toIso === toIso ? prev : { fromIso, toIso })) setRange((prev) => (prev && prev.fromIso === fromIso && prev.toIso === toIso ? prev : { fromIso, toIso }))
}, []) }, [])
const dialogOwnedConference = occurrenceDialogTarget ? myConferenceById.get(occurrenceDialogTarget.conference_id) : undefined
return ( return (
<div className="page-shell"> <div className="page-shell">
<ShellTopbar /> <ShellTopbar />
@@ -83,7 +84,7 @@ export function CalendarPage() {
разовую встречу или закрепите постоянную конференцию с повторением разовую встречу или закрепите постоянную конференцию с повторением
</p> </p>
</div> </div>
<button type="button" className="btn btn-primary" onClick={() => setEditingConference(null)}> <button type="button" className="btn btn-primary" onClick={openCreateForm}>
<Plus style={{ width: 16, height: 16 }} aria-hidden="true" /> <Plus style={{ width: 16, height: 16 }} aria-hidden="true" />
Новая конференция Новая конференция
</button> </button>
@@ -95,23 +96,58 @@ export function CalendarPage() {
onViewChange={setView} onViewChange={setView}
occurrences={occurrences ?? EMPTY_OCCURRENCES} occurrences={occurrences ?? EMPTY_OCCURRENCES}
isLoading={occurrencesLoading} isLoading={occurrencesLoading}
myConferenceById={myConferenceById}
onRangeChange={handleRangeChange} onRangeChange={handleRangeChange}
onOccurrenceClick={handleOccurrenceClick} onOccurrenceClick={setOccurrenceDialogTarget}
/> onEditClick={openEditForm}
<ConferenceFormCard
key={editingConference?.id ?? 'create'}
conference={editingConference ?? undefined}
onSaved={() => setEditingConference(null)}
onDeleted={() => setEditingConference(null)}
onCancelEdit={() => setEditingConference(null)}
/> />
</div> </div>
</main> </main>
<AppFooter />
{isFormOpen && (
<ConferenceFormModal
conference={editingConference}
onSaved={closeForm}
onDeleted={closeForm}
onClose={closeForm}
/>
)}
{occurrenceDialogTarget && ( {occurrenceDialogTarget && (
<ConferenceOccurrenceDialog occurrence={occurrenceDialogTarget} onClose={() => setOccurrenceDialogTarget(null)} /> <ConferenceOccurrenceDialog
occurrence={occurrenceDialogTarget}
ownedConference={dialogOwnedConference?.is_owner ? dialogOwnedConference : undefined}
onClose={() => setOccurrenceDialogTarget(null)}
onEdit={openEditForm}
/>
)} )}
</div> </div>
) )
} }
interface ConferenceFormModalProps {
conference: ConferenceOut | null
onSaved: () => void
onDeleted: () => void
onClose: () => void
}
/** Модалка создания/редактирования — разметка по образцу `ConferenceOccurrenceDialog` (modal-overlay/modal-panel, закрытие по клику на оверлей, Escape и возврат фокуса). */
function ConferenceFormModal({ conference, onSaved, onDeleted, onClose }: ConferenceFormModalProps) {
useModalDismiss(onClose)
return (
<div className="modal-overlay" role="dialog" aria-modal="true" onClick={onClose}>
<div className="modal-form-wrap" onClick={(e) => e.stopPropagation()}>
<ConferenceFormCard
key={conference?.id ?? 'create'}
conference={conference ?? undefined}
onSaved={onSaved}
onDeleted={onDeleted}
onCancelEdit={onClose}
/>
</div>
</div>
)
}

View File

@@ -9,6 +9,7 @@ import {
} from '@/api/conferences' } from '@/api/conferences'
import { ApiError, errorDetail } from '@/api/client' import { ApiError, errorDetail } from '@/api/client'
import { useAuth } from '@/auth/useAuth' import { useAuth } from '@/auth/useAuth'
import { AppFooter } from '@/components/layout/AppFooter'
import { LogoMark } from '@/components/ui/LogoMark' import { LogoMark } from '@/components/ui/LogoMark'
import { ThemeToggle } from '@/components/ui/ThemeToggle' import { ThemeToggle } from '@/components/ui/ThemeToggle'
import { parseJoinQuery } from '@/lib/parseJoinQuery' import { parseJoinQuery } from '@/lib/parseJoinQuery'
@@ -409,6 +410,7 @@ export function JoinPage() {
</section> </section>
)} )}
</main> </main>
<AppFooter />
</div> </div>
) )
} }

View File

@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Calendar, Link2, User, Video } from 'lucide-react' import { Calendar, Link2, User, Video } from 'lucide-react'
import { ShellTopbar } from '@/components/layout/ShellTopbar' import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { AppFooter } from '@/components/layout/AppFooter'
import { LogoMedallion } from '@/components/ui/LogoMark' import { LogoMedallion } from '@/components/ui/LogoMark'
import { createConference } from '@/api/conferences' import { createConference } from '@/api/conferences'
import { useToast } from '@/components/ui/ToastProvider' import { useToast } from '@/components/ui/ToastProvider'
@@ -113,6 +114,7 @@ export function LobbyPage() {
</div> </div>
</div> </div>
</main> </main>
<AppFooter />
</div> </div>
) )
} }

View File

@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { AlertTriangle, CheckCircle2, Clock, Lock, Pencil, Play, Repeat } from 'lucide-react' import { AlertTriangle, CheckCircle2, Clock, Lock, Pencil, Play, Repeat } from 'lucide-react'
import { ShellTopbar } from '@/components/layout/ShellTopbar' import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { AppFooter } from '@/components/layout/AppFooter'
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard' import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard' import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard'
import { CopyPill } from '@/components/ui/CopyPill' import { CopyPill } from '@/components/ui/CopyPill'
@@ -212,6 +213,7 @@ export function MyConferencesPage() {
</section> </section>
)} )}
</main> </main>
<AppFooter />
{editingConference && ( {editingConference && (
<div className="modal-overlay" role="dialog" aria-modal="true" onClick={() => setEditingConference(null)}> <div className="modal-overlay" role="dialog" aria-modal="true" onClick={() => setEditingConference(null)}>

View File

@@ -2,6 +2,7 @@ import { useRef, useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Trash2, Upload } from 'lucide-react' import { Trash2, Upload } from 'lucide-react'
import { ShellTopbar } from '@/components/layout/ShellTopbar' import { ShellTopbar } from '@/components/layout/ShellTopbar'
import { AppFooter } from '@/components/layout/AppFooter'
import { Avatar } from '@/components/ui/Avatar' import { Avatar } from '@/components/ui/Avatar'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
import { useToast } from '@/components/ui/ToastProvider' import { useToast } from '@/components/ui/ToastProvider'
@@ -271,6 +272,7 @@ export function ProfilePage() {
</form> </form>
</section> </section>
</main> </main>
<AppFooter />
</div> </div>
) )
} }

View File

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

View File

@@ -17,6 +17,8 @@ import { RoomStage } from '@/components/room/RoomStage'
import { RoomToolbar } from '@/components/room/RoomToolbar' import { RoomToolbar } from '@/components/room/RoomToolbar'
import { ChatPanel } from '@/components/room/ChatPanel' import { ChatPanel } from '@/components/room/ChatPanel'
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog' import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode'
interface RoomJoinState { interface RoomJoinState {
livekitUrl: string livekitUrl: string
@@ -149,6 +151,30 @@ export function RoomPage() {
const pip = useRoomPiP(roomRootRef) const pip = useRoomPiP(roomRootRef)
const [settingsOpen, setSettingsOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false)
// Вид сцены живёт здесь, а не в `RoomStage`: переключатели — в тулбаре и в
// шторке настроек, а сцена их только читает (общий предок).
//
// Режим показа персистится между заходами в комнату (`lib/stageLayoutMode.ts`,
// localStorage) — это устойчивое предпочтение: кто любит «плитки», хочет их
// и завтра. А вот «скрыть остальных» намеренно НЕ сохраняется: это разовое
// действие по ходу разговора, и войти в новую конференцию сразу без половины
// участников — сюрприз, а не удобство. Сбрасывается вместе с уходом со
// страницы комнаты.
const [layoutMode, setLayoutMode] = useState<StageLayoutMode>(loadStageLayoutMode)
const [hideOthers, setHideOthers] = useState(false)
const handleLayoutModeChange = useCallback((mode: StageLayoutMode) => {
setLayoutMode(mode)
saveStageLayoutMode(mode)
}, [])
// Ключ трека, который сцена показывает крупно. Живёт ЗДЕСЬ, а не только
// внутри `RoomStage`, потому что при открытии мини-плеера сцена
// размонтируется в основном окне и монтируется заново в PiP-окне (портал
// ниже) — без этого мостика новый экземпляр начинал бы выбор фокуса с нуля и
// открывал мини-окно на самом пользователе. Подробнее — докстринг `RoomStage`.
const [stageFocusKey, setStageFocusKey] = useState<string | null>(null)
// Сохранённый выбор устройств — читаем через собственный вызов // Сохранённый выбор устройств — читаем через собственный вызов
// usePersistentUserChoices (независимый от того, что использует // usePersistentUserChoices (независимый от того, что использует
// DeviceSettingsDialog: там свой вызов хука со своим состоянием). ВАЖНО: // DeviceSettingsDialog: там свой вызов хука со своим состоянием). ВАЖНО:
@@ -163,6 +189,12 @@ export function RoomPage() {
() => ({ () => ({
audioCaptureDefaults: { deviceId: userChoices.audioDeviceId || undefined }, audioCaptureDefaults: { deviceId: userChoices.audioDeviceId || undefined },
videoCaptureDefaults: { deviceId: userChoices.videoDeviceId || undefined }, videoCaptureDefaults: { deviceId: userChoices.videoDeviceId || undefined },
// Аудиовыход (колонки/наушники/bluetooth) — отдельный персист, не через
// usePersistentUserChoices: LocalUserChoices LiveKit про него не знает
// (см. lib/audioOutputDevice.ts). Читаем один раз при монтировании —
// как и с audio/videoDeviceId, смена применяется через DeviceSettingsDialog
// (setActiveMediaDevice), а не пересозданием roomOptions.
audioOutput: { deviceId: loadAudioOutputDeviceId() || undefined },
}), }),
[userChoices], [userChoices],
) )
@@ -211,7 +243,14 @@ export function RoomPage() {
</button> </button>
</div> </div>
) : ( ) : (
<RoomStage /> <RoomStage
layoutMode={layoutMode}
hideOthers={hideOthers}
onShowOthers={() => setHideOthers(false)}
onHideOthers={() => setHideOthers(true)}
initialFocusKey={stageFocusKey}
onFocusKeyChange={setStageFocusKey}
/>
)} )}
{chatVisible && chatOpen && ( {chatVisible && chatOpen && (
<ChatPanel <ChatPanel
@@ -235,15 +274,32 @@ export function RoomPage() {
pipSupported={pip.supported} pipSupported={pip.supported}
pipActive={pip.active} pipActive={pip.active}
onTogglePiP={pip.toggle} onTogglePiP={pip.toggle}
layoutMode={layoutMode}
onLayoutModeChange={handleLayoutModeChange}
hideOthers={hideOthers}
onHideOthersChange={setHideOthers}
/> />
</div> </div>
{settingsOpen && <DeviceSettingsDialog onClose={() => setSettingsOpen(false)} />} {settingsOpen && (
<DeviceSettingsDialog
onClose={() => setSettingsOpen(false)}
layoutMode={layoutMode}
onLayoutModeChange={handleLayoutModeChange}
hideOthers={hideOthers}
onHideOthersChange={setHideOthers}
/>
)}
{/* Портал внутрь LiveKitRoom (не как сосед снаружи!) — RoomStage, {/* Портал внутрь LiveKitRoom (не как сосед снаружи!) — RoomStage,
рендерящийся в PiP-окне, продолжает читать RoomContext/треки. рендерящийся в PiP-окне, продолжает читать RoomContext/треки.
`variant="pip"` — мини-плеер `variant="pip"` — мини-плеер
показывает только активное окно (одну плитку), без карусели/грида показывает только активное окно (одну плитку), без карусели/грида
основного окна. */} основного окна. `initialFocusKey` — то, что было крупно в основном
{pip.pipWindow && createPortal(<RoomStage variant="pip" />, pip.pipWindow.document.body)} окне: без него мини-окно открывалось на самом пользователе. */}
{pip.pipWindow &&
createPortal(
<RoomStage variant="pip" initialFocusKey={stageFocusKey} onFocusKeyChange={setStageFocusKey} />,
pip.pipWindow.document.body,
)}
</LiveKitRoom> </LiveKitRoom>
</div> </div>
) )

View File

@@ -14,6 +14,33 @@
*/ */
.admin-page .section-tabs { display: flex; gap: var(--space-2); margin-bottom: var(--space-5); border-bottom: 1px solid var(--color-border); } .admin-page .section-tabs { display: flex; gap: var(--space-2); margin-bottom: var(--space-5); border-bottom: 1px solid var(--color-border); }
/* Мобильная ширина: 4 вкладки не помещаются в один ряд (см. аудит) —
переносим в 2 ряда по 2, а необязательный поясняющий текст под заголовком
прячем, чтобы не съедал экран (сам заголовок тянет .page-head h1 из
lobby.css). */
@media (max-width: 640px) {
.admin-page .page-head p { display: none; }
.admin-page .section-tabs { flex-wrap: wrap; }
/* Таблица «Пользователи» — на мобильном оставляем только колонку
«Пользователь»; роль/команда/блокировка редактируются через карточку
профиля (открывается кликом по имени, см. AdminUserProfileDialog). */
.admin-page .users-table th:not(:first-child),
.admin-page .users-table td:not(:first-child) {
display: none;
}
/* Таблица «Конференции» — оставляем «Конференция» и «Владелец», статус/
тип/начало/действия скрыты; клик по названию (.conf-cell-name) открывает
то же редактирование, что раньше было доступно только через карандаш в
скрытой колонке действий — там же теперь есть и удаление (см. кнопку
«Удалить» в EditConferenceModal). */
.admin-page .conferences-table th:not(:nth-child(-n+2)),
.admin-page .conferences-table td:not(:nth-child(-n+2)) {
display: none;
}
}
.admin-page .section-tab { padding: 10px 4px; margin-right: var(--space-6); background: none; border: none; border-bottom: 2px solid transparent; font: var(--text-body-lg); font-weight: 600; color: var(--color-ink-500); cursor: pointer; } .admin-page .section-tab { padding: 10px 4px; margin-right: var(--space-6); background: none; border: none; border-bottom: 2px solid transparent; font: var(--text-body-lg); font-weight: 600; color: var(--color-ink-500); cursor: pointer; }
.admin-page .section-tab:hover { color: var(--color-ink-900); } .admin-page .section-tab:hover { color: var(--color-ink-900); }
.admin-page .section-tab.is-active { color: var(--color-ink-700); border-color: var(--color-accent-active); } .admin-page .section-tab.is-active { color: var(--color-ink-700); border-color: var(--color-accent-active); }
@@ -64,6 +91,14 @@
.admin-page .user-cell-name { background: none; border: none; padding: 0; font: inherit; color: inherit; cursor: pointer; text-align: left; } .admin-page .user-cell-name { background: none; border: none; padding: 0; font: inherit; color: inherit; cursor: pointer; text-align: left; }
.admin-page .user-cell-name:hover { text-decoration: underline; color: var(--color-ink-700); } .admin-page .user-cell-name:hover { text-decoration: underline; color: var(--color-ink-700); }
/* Название конференции с №, оформленное кнопкой — открывает то же
редактирование, что и карандаш в row-actions правее (см.
AdminConferencesTab). На мобильном (.conferences-table ниже) это
единственный вход в редактирование строки, на десктопе — просто более
крупная область клика поверх уже существующей иконки. */
.admin-page .conf-cell-name { background: none; border: none; padding: 0; margin: 0; font: inherit; color: inherit; cursor: pointer; text-align: left; display: block; width: 100%; }
.admin-page .conf-cell-name:hover { color: var(--color-ink-700); }
.admin-page select { font: var(--text-body); color: var(--color-ink-900); padding: 6px 10px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: var(--color-surface); font-family: var(--font-body); } .admin-page select { font: var(--text-body); color: var(--color-ink-900); padding: 6px 10px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: var(--color-surface); font-family: var(--font-body); }
.admin-page select:disabled { opacity: .5; cursor: not-allowed; } .admin-page select:disabled { opacity: .5; cursor: not-allowed; }
.admin-page select:focus { outline: none; border-color: var(--color-focus-ring); } .admin-page select:focus { outline: none; border-color: var(--color-focus-ring); }
@@ -133,10 +168,8 @@
.admin-page .settings-grid { grid-template-columns: 1fr; } .admin-page .settings-grid { grid-template-columns: 1fr; }
} }
/* ---------- Футер админки ---------- /* Футер админки (`.admin-footer`) убран в 0.0.6: вместо него общий для всех
Версия инстанса из `/api/health` (см. AdminPage.tsx) — до появления поля страниц компонент `AppFooter` (см. `styles/footer.css`). */
на backend строка сокращается до одного «VidConf» без версии. */
.admin-page .admin-footer { margin-top: var(--space-6); padding-top: var(--space-4); border-top: 1px solid var(--color-border); font: var(--text-caption); color: var(--color-ink-400); text-align: center; }
/* ===================================================================== /* =====================================================================
* Тёмная тема: заливка var(--color-ink-700) (индиго) сочеталась с жёстким * Тёмная тема: заливка var(--color-ink-700) (индиго) сочеталась с жёстким

View File

@@ -27,7 +27,11 @@
border-radius: 50%; border-radius: 50%;
background: radial-gradient(circle at 30% 30%, rgba(212, 242, 227, 0.45), rgba(212, 242, 227, 0) 70%); background: radial-gradient(circle at 30% 30%, rgba(212, 242, 227, 0.45), rgba(212, 242, 227, 0) 70%);
} }
.brand-copy { z-index: 1; } /* `margin-block: auto` держит блок по центру свободной высоты панели. Нужно
с тех пор, как из панели убрали нижнюю строку копирайта (она переехала в
общий футер): у `.brand-panel` осталось два потомка вместо трёх, и
`justify-content: space-between` иначе прижал бы текст к самому низу. */
.brand-copy { z-index: 1; margin-block: auto; }
.eyebrow { .eyebrow {
font: var(--text-caption); font: var(--text-caption);
letter-spacing: 0.08em; letter-spacing: 0.08em;
@@ -244,7 +248,10 @@
right: -90px; right: -90px;
bottom: -90px; bottom: -90px;
} }
.brand-headline { max-width: none; } /* clamp() — на узких экранах слово «инфраструктура» иначе вылезает за
край брендовой панели (см. .brand-panel padding ниже и её overflow:hidden,
обрезающий текст без переноса вместо уменьшения кегля). */
.brand-headline { max-width: none; font-size: clamp(22px, 6.2vw, 34px); }
.brand-sub { max-width: none; } .brand-sub { max-width: none; }
.brand-stats { flex-wrap: wrap; } .brand-stats { flex-wrap: wrap; }
.stat-glass { flex: 1 1 140px; min-width: 0; } .stat-glass { flex: 1 1 140px; min-width: 0; }

View File

@@ -7,17 +7,20 @@
* (см. комментарий в ConferenceCalendar.tsx). * (см. комментарий в ConferenceCalendar.tsx).
*/ */
/* ---------- Грид: сетка вхождений + карточка создания/редактирования ---------- */ /* ---------- Календарь во всю ширину контента ---------- */
/* Раньше `.layout-grid` была двухколоночной сеткой (календарь + вечно
висящая сбоку `.create-card`) — форма создания/редактирования переехала в
модалку (`.modal-form-wrap`, см. my-conferences.css), сайдбара больше нет.
`min-width:0` оставлен: `.week-card` внутри — единственный элемент грида, и
без этого грид-трек не сжимается уже своего min-content (внутренняя таблица
FullCalendar с 5 колонками) — классический grid blowout, раздувающий трек и
всю страницу шире вьюпорта (см. тот же приём в lobby.css/.action). */
.layout-grid { .layout-grid {
display: grid; display: grid;
grid-template-columns: 1fr 400px; grid-template-columns: 1fr;
gap: var(--space-6); gap: var(--space-6);
align-items: start; align-items: start;
} }
/* min-width:0 — без этого грид-элемент с 1fr-треком не сжимается уже своего
min-content (тут это внутренняя таблица FullCalendar с 5 колонками) —
классический grid blowout: трек и вся страница раздуваются шире вьюпорта,
появляется горизонтальный скролл (см. тот же приём в lobby.css/.action). */
.layout-grid > * { min-width: 0; } .layout-grid > * { min-width: 0; }
.week-card { .week-card {
@@ -67,12 +70,15 @@
* Событие-вхождение конференции — карточка-«чип» в духе .conf-chip мокапа. * Событие-вхождение конференции — карточка-«чип» в духе .conf-chip мокапа.
* FullCalendar оборачивает событие в .fc-daygrid-event (наследует классы из * FullCalendar оборачивает событие в .fc-daygrid-event (наследует классы из
* `classNames`) — переопределяем его внешний вид под чип целиком, без * `classNames`) — переопределяем его внешний вид под чип целиком, без
* отдельного контейнера. * отдельного контейнера. Содержимое — только название (+карандашик у
* владельца, время/статус/номер — в ConferenceOccurrenceDialog), поэтому чип —
* одна строка (flex row), а не колонка бейдж/время/название, как раньше.
*/ */
.vc-calendar .fc-daygrid-event.conf-chip { .vc-calendar .fc-daygrid-event.conf-chip {
display: flex !important; display: flex !important;
flex-direction: column; align-items: center;
gap: 4px; gap: 8px;
min-width: 0;
background: var(--color-surface) !important; background: var(--color-surface) !important;
border: 1px solid var(--color-border) !important; border: 1px solid var(--color-border) !important;
border-radius: var(--radius-md) !important; border-radius: var(--radius-md) !important;
@@ -85,10 +91,34 @@
.vc-calendar .fc-daygrid-event.conf-chip .fc-event-time, .vc-calendar .fc-daygrid-event.conf-chip .fc-event-time,
.vc-calendar .fc-daygrid-event.conf-chip .fc-event-title { display: none; } /* заменено собственным eventContent */ .vc-calendar .fc-daygrid-event.conf-chip .fc-event-title { display: none; } /* заменено собственным eventContent */
.chip-time { display: block; font: var(--text-caption); color: var(--color-ink-500); text-transform: none; letter-spacing: normal; } /* Цепочка min-width:0 + overflow:hidden + text-overflow:ellipsis — без неё
.chip-title { display: flex; align-items: center; gap: 6px; font: var(--text-body); font-weight: 700; color: var(--color-ink-900); } длинное название распирает чип и вылезает за границы ячейки (в узкой
недельной колонке — особенно). `.fc-daygrid-event-harness` — прямой
flex-item `.fc-daygrid-day-events` (флекс-колонка) выше по файлу: по
умолчанию у флекс-айтема min-width:auto (= min-content), из-за чего он не
сжимается уже ширины своего содержимого — тот самый grid/flex-blowout. */
.vc-calendar .fc-daygrid-event-harness { min-width: 0; }
.chip-row { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; }
.chip-title { display: flex; align-items: center; gap: 6px; font: var(--text-body); font-weight: 700; color: var(--color-ink-900); min-width: 0; flex: 1; }
.chip-title-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.chip-lock { width: 13px; height: 13px; flex-shrink: 0; } .chip-lock { width: 13px; height: 13px; flex-shrink: 0; }
.chip-edit {
width: 22px;
height: 22px;
flex-shrink: 0;
border-radius: 50%;
border: none;
background: transparent;
color: var(--color-ink-500);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.chip-edit svg { width: 13px; height: 13px; }
.chip-edit:hover { background: var(--color-bg-alt); color: var(--color-ink-900); }
.conf-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 9px; border-radius: var(--radius-full); font: var(--text-caption); font-size: 11px; width: fit-content; } .conf-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 9px; border-radius: var(--radius-full); font: var(--text-caption); font-size: 11px; width: fit-content; }
.conf-badge.scheduled { background: var(--color-warning-bg); color: var(--color-warning); } .conf-badge.scheduled { background: var(--color-warning-bg); color: var(--color-warning); }
.conf-badge.pinned { background: var(--color-bg-alt); color: var(--color-ink-700); border: 1px solid var(--color-border-strong); } .conf-badge.pinned { background: var(--color-bg-alt); color: var(--color-ink-700); border: 1px solid var(--color-border-strong); }
@@ -242,17 +272,11 @@
совпадения media query (тот же баг уже ловили в lobby.css/.action, гейт 3 совпадения media query (тот же баг уже ловили в lobby.css/.action, гейт 3
— см. комментарий там). Макет calendar.html — desktop-only — см. комментарий там). Макет calendar.html — desktop-only
(`html { min-width: 1280px }`, DESIGN_SYSTEM.md §«Breakpoints»), адаптива (`html { min-width: 1280px }`, DESIGN_SYSTEM.md §«Breakpoints»), адаптива
в нём нет — раскладка ниже подобрана по здравому смыслу: календарь сверху, в нём нет.
карточка создания/редактирования — под ним (сохраняем порядок вёрстки). .layout-grid уже одноколоночная на всех ширинах (§2.2) — двухколоночного
960px-брейкпоинта под сайдбар-форму больше нет, форма теперь в модалке
(.modal-form-wrap .create-card в my-conferences.css гасит sticky сама).
*/ */
@media (max-width: 960px) {
/* Двухколоночный грид (сетка + сайдбар 400px) → одна колонка, сайдбар
перестаёт быть sticky (в потоке одной колонки залипание при скролле
страницы выглядит и работает хуже, чем простое следование за контентом). */
.layout-grid { grid-template-columns: 1fr; }
.create-card { position: static; top: auto; }
}
@media (max-width: 640px) { @media (max-width: 640px) {
.week-card, .create-card { padding: var(--space-4); } .week-card, .create-card { padding: var(--space-4); }
.week-toolbar { flex-direction: column; align-items: stretch; } .week-toolbar { flex-direction: column; align-items: stretch; }

View File

@@ -0,0 +1,38 @@
/*
* VidConf — общий футер приложения (см. `components/layout/AppFooter.tsx`).
*
* Файл импортируется самим компонентом, а не страницами: футер стоит в четырёх
* разных раскладках (`page-shell`, `AuthLayout`, `join-shell`, `lobby-shell`),
* и таскать импорт по всем страницам смысла нет.
*
* Футер — обычный блок в потоке (не `position: fixed`), поэтому контент он
* не перекрывает ни на какой высоте экрана.
*/
.app-footer {
padding: var(--space-5) var(--space-4);
font: var(--text-caption);
color: var(--color-ink-400);
text-align: center;
}
.app-footer a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
.app-footer a:hover { color: var(--color-ink-700); }
/* Оболочка страниц (`page-shell`): выравниваем футер по ширине контента
(`main.page-main` — max-width 1440 + боковые отступы) и отделяем линией,
как это делал прежний футер админки. */
.page-shell .app-footer {
max-width: 1440px;
margin: 0 auto;
padding: var(--space-4) var(--space-8) var(--space-6);
border-top: 1px solid var(--color-border);
}
/* Экраны auth: футер — последний элемент правой колонки с формой, поэтому
ограничиваем его шириной самой формы (`.screen-block`, 440px), иначе
центрированная строка «уезжала» бы вправо от неё. */
.form-panel .app-footer { max-width: 440px; width: 100%; padding-inline: 0; margin-top: var(--space-8); }
@media (max-width: 640px) {
.page-shell .app-footer { padding: var(--space-4) var(--space-4) var(--space-5); }
}

View File

@@ -55,6 +55,14 @@
.field.has-error input { border-color: var(--color-danger); box-shadow: 0 0 0 3px rgba(199, 54, 79, 0.1); } .field.has-error input { border-color: var(--color-danger); box-shadow: 0 0 0 3px rgba(199, 54, 79, 0.1); }
.error-msg { display: flex; align-items: center; gap: 6px; margin-top: var(--space-2); font: var(--text-body); color: var(--color-danger); } .error-msg { display: flex; align-items: center; gap: 6px; margin-top: var(--space-2); font: var(--text-body); color: var(--color-danger); }
/* Кнопка «Ввести другую ссылку или номер» (экран «конференция завершена») —
единственная кнопка на экране БЕЗ формы-обёртки (Продолжить/Войти лежат
в <form>, которая как block-элемент задаёт им ширину; здесь у .btn нет
родителя-формы). .btn держит white-space: nowrap (shell.css) — на
остальных кнопках текст короткий и это не заметно, а здесь длинная фраза
выталкивала кнопку за правый край карточки на узких экранах. */
.flow-card > .btn-primary { width: 100%; white-space: normal; text-align: center; }
.link-back { display: block; text-align: center; margin-top: var(--space-5); font: var(--text-body); font-weight: 600; color: var(--color-ink-500); text-decoration: none; background: none; border: none; width: 100%; cursor: pointer; } .link-back { display: block; text-align: center; margin-top: var(--space-5); font: var(--text-body); font-weight: 600; color: var(--color-ink-500); text-decoration: none; background: none; border: none; width: 100%; cursor: pointer; }
.link-back:hover { color: var(--color-ink-700); } .link-back:hover { color: var(--color-ink-700); }

View File

@@ -23,6 +23,8 @@ main.page-main { padding: var(--space-8); max-width: 1440px; margin: 0 auto; }
@media (max-width: 640px) { @media (max-width: 640px) {
main.page-main { padding: var(--space-5) var(--space-4); } main.page-main { padding: var(--space-5) var(--space-4); }
.page-head { flex-direction: column; align-items: flex-start; gap: var(--space-3); } .page-head { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
/* Длинные заголовки («Администрирование» и т.п.) иначе вылезают за край. */
.page-head h1 { font-size: clamp(22px, 6.5vw, 34px); }
} }
/* ---------- Лобби-хаб (мозаика действий) ---------- */ /* ---------- Лобби-хаб (мозаика действий) ---------- */

View File

@@ -52,13 +52,6 @@
.icon-btn { width: 40px; height: 40px; flex-shrink: 0; border-radius: 50%; border: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-ink-500); display: flex; align-items: center; justify-content: center; cursor: pointer; } .icon-btn { width: 40px; height: 40px; flex-shrink: 0; border-radius: 50%; border: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-ink-500); display: flex; align-items: center; justify-content: center; cursor: pointer; }
.icon-btn:hover { border-color: var(--color-border-strong); color: var(--color-ink-900); } .icon-btn:hover { border-color: var(--color-border-strong); color: var(--color-ink-900); }
/* ---------- Модалка редактирования (переиспользует ConferenceFormCard) ---------- */
/* .create-card сама по себе — визуальная панель (фон/обводка/радиус/паддинг из
calendar.css); здесь только гасим её sticky-позиционирование календаря и
ограничиваем высоту внутри модалки. */
.modal-form-wrap { width: 100%; max-width: 480px; }
.modal-form-wrap .create-card { position: static; max-height: calc(100vh - var(--space-12)); overflow-y: auto; }
/* Тёмная тема: тултип «Скопировано» — заливка была var(--color-ink-900) /* Тёмная тема: тултип «Скопировано» — заливка была var(--color-ink-900)
(тёмный индиго в светлой теме → стал бы светло-серым в тёмной, ломая (тёмный индиго в светлой теме → стал бы светло-серым в тёмной, ломая
тёмный тултип поверх тёмной страницы); заменена на буквальный #333333 тёмный тултип поверх тёмной страницы); заменена на буквальный #333333

View File

@@ -47,7 +47,9 @@
.room-invite-chip span { font: var(--text-mono-sm); color: var(--color-room-text-secondary); } .room-invite-chip span { font: var(--text-mono-sm); color: var(--color-room-text-secondary); }
.room-main { display: flex; flex: 1; min-height: 0; } .room-main { display: flex; flex: 1; min-height: 0; }
.stage { flex: 1; display: flex; flex-direction: column; padding: var(--space-5) var(--space-6); gap: var(--space-4); min-width: 0; } /* `position: relative` — якорь для кнопки «Показать остальных» (`.stage-show-others`),
которая висит оверлеем над сценой, см. её правило ниже. */
.stage { flex: 1; display: flex; flex-direction: column; padding: var(--space-5) var(--space-6); gap: var(--space-4); min-width: 0; position: relative; }
/* /*
* Демонстрация экрана: `object-fit: cover` (дефолт VideoTrack для * Демонстрация экрана: `object-fit: cover` (дефолт VideoTrack для
@@ -115,6 +117,10 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
@media (max-width: 600px) { @media (max-width: 600px) {
.stage-tiles.lk-focus-layout { grid-template-columns: 1fr; } .stage-tiles.lk-focus-layout { grid-template-columns: 1fr; }
.stage { padding: var(--space-3) var(--space-4); gap: var(--space-3); } .stage { padding: var(--space-3) var(--space-4); gap: var(--space-3); }
/* Их `.lk-carousel{order:1}` (ставит миниатюры ПОД спикера) больше не
действует: прямой ребёнок грида теперь обёртка `.stage-side`, а не сама
карусель — переносим порядок на неё, иначе миниатюры уезжают наверх. */
.stage-side { order: 1; }
} }
/* /*
@@ -130,6 +136,123 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
.room-single-tile { flex: 1; min-height: 0; display: flex; } .room-single-tile { 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%; }
/*
* ---------- Колонка миниатюр (кнопка «Скрыть» + карусель) ----------
* Прямой ребёнок `.lk-focus-layout` — вместо самой карусели, потому что над
* ней стоит кнопка «Скрыть» (см. `RoomStage`). Карусель занимает весь остаток
* колонки: ей важны собственные размеры — `CarouselLayout` по ним и решает,
* вертикальная она или горизонтальная (см. комментарий в `RoomStage`).
* `min-height: 0` обязателен — иначе flex-элемент не даёт себя сжать и
* колонка вылезает за высоту сцены.
*/
.stage-side { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; min-height: 0; }
.stage-side .lk-carousel { flex: 1; min-height: 0; }
.stage-side-hide {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
flex-shrink: 0;
padding: 6px 10px;
border-radius: var(--radius-md);
border: 1px solid var(--color-room-tile-border);
background: var(--color-room-tile);
color: var(--color-room-text-secondary);
font: var(--text-caption);
text-transform: none;
letter-spacing: normal;
cursor: pointer;
}
.stage-side-hide:hover { background: var(--color-room-tile-hover); color: var(--color-room-text-primary); }
.stage-side-hide svg { width: 14px; height: 14px; flex-shrink: 0; }
/*
* ---------- Основная область без карусели ----------
* `.stage-main-only` — сцена, где карусель не рендерится вовсе: плиточные
* режимы («Плитки» всегда, «Живые плитки» когда камеры выключены не у кого),
* «Стандарт» с единственным участником и любой режим при включённом «скрыть
* остальных». Единственный ребёнок (плитка фокуса или сетка `.stage-grid`)
* занимает всё место — тем же приёмом, что `.room-single-tile` выше, но без
* `min-height` мини-плеера: в основном окне высота приходит от `.room-main`.
*/
.stage-main-only { display: flex; }
.stage-main-only > * { flex: 1; min-width: 0; min-height: 0; }
/*
* ---------- Равномерная сетка плиток (`StageGrid`) ----------
* Класс `.lk-grid-layout` библиотеки на месте (оттуда вся геометрия сетки:
* `--lk-col-count`/`--lk-row-count`, gap, padding), здесь — только то, чего у
* неё нет: сброс минимальных размеров (сетка может жить внутри grid-ячейки
* `.lk-focus-layout`, где без `min-*: 0` она распирала бы колонку) и якорь для
* собственного переключателя страниц.
*/
.stage-grid { position: relative; min-width: 0; min-height: 0; }
.stage-grid-pages {
position: absolute;
bottom: calc(var(--lk-grid-gap) / 2);
left: 50%;
transform: translateX(-50%);
z-index: 5;
display: flex;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: var(--radius-full);
background: rgba(30, 30, 30, 0.85);
border: 1px solid var(--color-room-tile-border);
color: var(--color-room-text-primary);
font: var(--text-caption);
}
.stage-grid-pages button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: inherit;
cursor: pointer;
}
.stage-grid-pages button:hover:not(:disabled) { background: var(--color-room-tile-hover); }
.stage-grid-pages button:disabled { opacity: 0.35; cursor: default; }
.stage-grid-pages svg { width: 16px; height: 16px; }
.stage-grid-pages span { padding: 0 4px; font-variant-numeric: tabular-nums; }
/*
* ---------- Кнопка «Показать остальных» ----------
* Единственный видимый способ вернуть скрытую карусель, поэтому висит прямо на
* сцене, а не только в меню «Вид»: пользователь не должен «терять» участников
* без понятного способа их вернуть. Внизу по центру — там у плиток пусто
* (`.lk-participant-metadata` прижата к левому и правому углам).
*/
.stage-show-others {
position: absolute;
bottom: var(--space-5);
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: var(--radius-full);
border: 1px solid var(--color-room-tile-border);
background: rgba(30, 30, 30, 0.9);
color: var(--color-room-text-primary);
font: var(--text-body);
font-weight: 600;
/* Иначе на узком экране подпись ломается на две строки и кнопка вырастает
в высоту вдвое (поймали на 390px). */
white-space: nowrap;
box-shadow: var(--shadow-md);
cursor: pointer;
}
.stage-show-others:hover { background: var(--color-room-tile-hover); }
.stage-show-others svg { width: 18px; height: 18px; flex-shrink: 0; }
/* Нижний тулбар: свои кнопки на хуках LiveKit (TrackToggle/DisconnectButton) */ /* Нижний тулбар: свои кнопки на хуках LiveKit (TrackToggle/DisconnectButton) */
.room-toolbar { .room-toolbar {
display: flex; display: flex;
@@ -200,6 +323,75 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
justify-content: center; justify-content: center;
} }
/*
* ---------- Кнопка «Вид» и её поповер (десктоп) ----------
* Обёртка вокруг кнопки тулбара — якорь для всплывающей панели над ней
* (`StageViewMenu`). На мобильном ни кнопки, ни поповера нет вовсе: там блок
* настроек вида рендерится секцией внутри шторки (условный рендеринг в React,
* а не скрытие через CSS, — см. `RoomToolbar`).
*/
.tb-menu-wrap { position: relative; display: flex; }
.tb-menu {
position: absolute;
bottom: calc(100% + var(--space-2));
left: 50%;
transform: translateX(-50%);
z-index: 50;
width: 280px;
padding: var(--space-4);
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);
}
/*
* ---------- Блок «Вид сцены» (`StageViewOptions`) ----------
* Общий для десктопного поповера и мобильной шторки — отсюда нейтральная
* геометрия без привязки к контейнеру (ширину задаёт родитель).
*/
.stage-view-options { display: flex; flex-direction: column; gap: var(--space-3); }
/* Секция «Вид» внутри шторки настроек — отбивка как у `.room-field` (свой
класс, а не `.room-field`, см. комментарий в `DeviceSettingsDialog`). */
.stage-view-section { margin-bottom: var(--space-5); }
.stage-view-modes { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; border: none; }
.stage-view-modes legend {
padding: 0 0 var(--space-2);
font: var(--text-caption);
color: var(--color-room-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.stage-view-mode {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 10px;
border-radius: var(--radius-md);
border: 1px solid transparent;
cursor: pointer;
}
.stage-view-mode:hover { background: var(--color-room-tile); }
.stage-view-mode.is-active { background: var(--color-room-tile); border-color: var(--color-room-speaker-ring); }
.stage-view-mode input { margin-top: 3px; accent-color: var(--color-room-mic-on); flex-shrink: 0; }
.stage-view-mode-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.stage-view-mode-title { font: var(--text-body); font-weight: 600; color: var(--color-room-text-primary); }
.stage-view-mode-hint { font: var(--text-caption); text-transform: none; letter-spacing: normal; color: var(--color-room-text-secondary); }
.stage-view-toggle {
display: flex;
align-items: center;
gap: 10px;
padding-top: var(--space-3);
border-top: 1px solid var(--color-room-tile-border);
font: var(--text-body);
color: var(--color-room-text-primary);
cursor: pointer;
}
.stage-view-toggle input { accent-color: var(--color-room-mic-on); flex-shrink: 0; }
.stage-view-toggle.is-disabled { color: var(--color-room-text-tertiary); cursor: default; }
.stage-view-note { margin: 0; font: var(--text-caption); text-transform: none; letter-spacing: normal; color: var(--color-room-text-tertiary); }
/* ---------- Панель чата (см. design/mockups/room.html, .chat-panel) ---------- */ /* ---------- Панель чата (см. design/mockups/room.html, .chat-panel) ---------- */
.chat-panel { .chat-panel {
width: 320px; width: 320px;
@@ -378,6 +570,48 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
font-family: var(--font-body); font-family: var(--font-body);
} }
.room-field select:focus { outline: none; border-color: var(--color-room-focus-ring); } .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;
}
/* /*
* ---------- Чип «Вы демонстрируете экран» ---------- * ---------- Чип «Вы демонстрируете экран» ----------
@@ -416,6 +650,52 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
} }
.stage-sharing-chip button:hover { filter: brightness(0.95); } .stage-sharing-chip button:hover { filter: brightness(0.95); }
/*
* ---------- Кнопка закрепления участника (булавка) ----------
* Рендерится внутри `RoomParticipantTile` (см. её `TileBody`), т.е. внутри
* `.lk-participant-tile` — тот у @livekit/components-styles уже
* `position: relative`. Геометрия и логика появления повторяют штатную
* `.lk-focus-toggle-button` той же библиотеки (правый верхний угол, видна по
* наведению) — чтобы плитка выглядела цельно, а не «нашей кнопкой поверх
* чужой». Отличие: у закреплённой плитки булавка видна ВСЕГДА и подсвечена —
* закрепление должно читаться без наведения.
*/
.room-pin-toggle {
position: absolute;
top: 0.25rem;
right: 0.25rem;
z-index: 5;
display: flex;
padding: 0.25rem;
border: none;
border-radius: calc(var(--lk-border-radius, 0.5rem) / 2);
background: rgba(0, 0, 0, 0.5);
color: var(--color-room-text-primary);
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease-in-out;
transition-delay: 0.2s;
}
.room-pin-toggle svg { width: 20px; height: 20px; }
.lk-participant-tile:hover .room-pin-toggle,
.lk-participant-tile:focus-within .room-pin-toggle,
.room-pin-toggle.is-pinned { opacity: 1; transition-delay: 0s; }
.room-pin-toggle.is-pinned { background: var(--color-room-mic-on); color: #10331f; }
/* Тач-устройства (мобильный/планшет): hover нет вовсе — иначе булавка была бы
недоступна. Тулбар комнаты не задет: кнопка живёт на плитке. */
@media (hover: none) {
.room-pin-toggle { opacity: 1; transition-delay: 0s; }
}
/* Метка закреплённой плитки — рамка по контуру. Класс закрепления висит на
кнопке (корневой div плитки рендерит библиотека, свой класс туда не
добавить), поэтому селектор идёт через `:has()`. */
.lk-participant-tile:has(.room-pin-toggle.is-pinned) {
outline: 2px solid var(--color-room-mic-on);
outline-offset: -2px;
}
/* ---------- Заглушка «конференция в мини-окне» (Document PiP) ---------- */ /* ---------- Заглушка «конференция в мини-окне» (Document PiP) ---------- */
.room-pip-placeholder { .room-pip-placeholder {
flex: 1; flex: 1;
@@ -430,17 +710,50 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
} }
.room-pip-placeholder svg { width: 48px; height: 48px; color: var(--color-room-text-tertiary); } .room-pip-placeholder svg { width: 48px; height: 48px; color: var(--color-room-text-tertiary); }
/* ---------- Аватар вместо иконки-заглушки при выключенной камере ---------- */ /* ---------- Аватар вместо иконки-заглушки при выключенной камере ----------
* Размер считается от УЗКОЙ стороны плитки, а не от ширины и высоты по
* отдельности. Раньше здесь было `width: 40%; height: 40%` — проценты от
* РАЗНЫХ сторон, а круглым `.avatar` делает только `border-radius: 50%`
* (shell.css), поэтому на неквадратной плитке (а они почти всегда такие)
* получался эллипс. Плюс `max-*: 96px` не давал иконке вырасти на большой
* плитке фокуса.
*
* Механика: контейнером container-запросов делаем `.lk-participant-placeholder`
* — блок библиотеки, который `position: absolute; inset: 0`, то есть в
* точности повторяет плитку и уже имеет обе размерности (обязательное условие
* для `container-type: size`). Контейнер именно на нём, а не на самой
* `.lk-participant-tile`: `container-type: size` включает size containment, и
* на плитке он отрезал бы её содержимое (видео, метаданные) от влияния на
* авторазмер — на placeholder-обёртке внутри ничего, кроме аватара, нет,
* ломать нечему.
*
* `cqmin` — минимум из ширины и высоты контейнера, буквально «узкая сторона»:
* - основная (крупная) плитка — 50cqmin, примерно половина плитки;
* - плитки карусели — во всю узкую сторону минус отступ 5px с каждого края.
* `aspect-ratio: 1` + `height: auto` гарантируют круг при любой форме плитки:
* диаметр никогда не больше меньшей стороны, дальше иконка уменьшается
* пропорционально вместе с плиткой. `min-width: 32px` оставлен — на совсем
* крошечной плитке инициалы должны оставаться читаемыми (сжатие плитки ниже
* этого порога и так означает, что показывать там нечего).
*/
.lk-participant-tile .lk-participant-placeholder {
container-type: size;
}
.room-tile-avatar { .room-tile-avatar {
width: 40%; width: 50cqmin;
height: 40%; height: auto;
aspect-ratio: 1;
min-width: 32px; min-width: 32px;
min-height: 32px; min-height: 0;
max-width: 96px; max-width: none;
max-height: 96px; max-height: none;
font-size: clamp(12px, 3vw, 28px); font-size: clamp(12px, 20cqmin, 44px);
font-weight: 700; font-weight: 700;
} }
.lk-carousel .room-tile-avatar {
width: calc(100cqmin - 10px);
font-size: clamp(12px, 34cqmin, 28px);
}
.room-loader, .room-error { .room-loader, .room-error {
display: flex; display: flex;
@@ -480,3 +793,37 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
--lk-grid-gap: var(--space-4); --lk-grid-gap: var(--space-4);
--lk-control-bar-height: 0px; --lk-control-bar-height: 0px;
} }
/* ---------- Узкие экраны: топбар и тулбар комнаты ----------
ВАЖНО: этот блок обязан идти строго ПОСЛЕДНИМ в файле. Селекторы ниже
(`.tb-btn`, `.room-toolbar`) имеют ту же специфичность, что и безусловные
правила выше, а при равной специфичности побеждает порядок в исходнике, а
не факт совпадения media query — тот же грабли уже ловили в
auth.css/calendar.css/lobby.css (см. комментарии там). Первая версия этого
блока стояла в середине файла: `.room-invite-chip` (объявлен ВЫШЕ) скрылся,
а `.tb-btn`/`.room-toolbar` (объявлены НИЖЕ) продолжали побеждать — тулбар
на мобильном не менялся вовсе.
Топбар: скрываем название конференции и строку статуса — они и так есть
в самой конференции, а вот чип «№ … / Пригласить» с телефона нужен чаще
(скопировать ссылку и позвать участника), поэтому он остаётся.
Тулбар: демонстрация экрана, полноэкранный режим и мини-окно с телефона
практически не нужны, а все 8 кнопок в ширину экрана физически не
помещаются — скрываем их и уплотняем оставшиеся пять
(микрофон/камера/устройства/чат/выход). `flex-wrap` + `min-width: 0` —
страховка на совсем узких экранах, чтобы футер ни при каких подписях не
вылезал за ширину окна. */
@media (max-width: 600px) {
.room-topbar { padding: var(--space-3) var(--space-4); justify-content: flex-end; }
.room-title-block { display: none; }
.tb-btn--screenshare,
.tb-btn--fullscreen,
.tb-btn--pip { display: none; }
.room-toolbar { padding: var(--space-3) var(--space-2); gap: 2px; flex-wrap: wrap; }
.tb-btn { min-width: 0; padding: 6px 6px; }
.tb-btn .icon-shell { width: 40px; height: 40px; }
.tb-btn span.label { font-size: 11px; }
}

View File

@@ -265,6 +265,23 @@
cursor: not-allowed; cursor: not-allowed;
} }
.btn-danger {
background: transparent;
color: var(--color-danger);
border: 1.5px solid var(--color-danger);
font: var(--text-body);
font-weight: 600;
padding: 9px 18px;
border-radius: var(--radius-full);
}
.btn-danger:hover:not(:disabled) {
background: var(--color-danger-bg);
}
.btn-danger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ---------- Бейджи статуса ---------- */ /* ---------- Бейджи статуса ---------- */
.badge { .badge {
display: inline-flex; display: inline-flex;
@@ -393,6 +410,31 @@
.modal-actions { display: flex; gap: var(--space-3); margin-top: var(--space-5); } .modal-actions { display: flex; gap: var(--space-3); margin-top: var(--space-5); }
.modal-actions .btn { flex: 1; justify-content: center; } .modal-actions .btn { flex: 1; justify-content: center; }
/* Опасное действие (удаление) — отдельно слева, Отмена/Сохранить — группой
справа, без растягивания на всю ширину (см. EditConferenceModal). */
.modal-actions.modal-actions--split { justify-content: space-between; }
.modal-actions--split > .btn-danger { flex: none; }
.modal-actions--split .modal-actions-group { display: flex; gap: var(--space-3); }
.modal-actions--split .row-actions { display: flex; align-items: center; gap: var(--space-3); }
/* Модалка вокруг ConferenceFormCard (`.create-card`, «Мои конференции» и
календарь) — сама карточка остаётся визуальной панелью
(фон/обводка/радиус/паддинг из calendar.css), здесь только гасим её
sticky-позиционирование из десктопного сайдбара и ограничиваем высоту
внутри модалки, чтобы скроллилась она сама, а не вся страница. */
.modal-form-wrap { width: 100%; max-width: 480px; }
.modal-form-wrap .create-card { position: static; max-height: calc(100vh - var(--space-12)); overflow-y: auto; }
/* Узкий экран: «Удалить» + «Отмена» + «Сохранить» в один ряд не помещаются
(в сумме шире содержимого модалки на телефоне) — переносим «Удалить» на
свою строку сверху. */
@media (max-width: 480px) {
.modal-actions--split { flex-wrap: wrap; }
.modal-actions--split > .btn-danger { flex: 1 1 100%; }
.modal-actions--split .modal-actions-group { flex: 1 1 100%; }
.modal-actions--split .row-actions { flex-wrap: wrap; }
}
/* ---------- Поля форм в модалках (общие для всех диалогов оболочки) ---------- */ /* ---------- Поля форм в модалках (общие для всех диалогов оболочки) ---------- */
.field { margin-bottom: var(--space-5); } .field { margin-bottom: var(--space-5); }
.field:last-child { margin-bottom: 0; } .field:last-child { margin-bottom: 0; }

View File

@@ -162,9 +162,16 @@ async def send_invitations_async(
body = "\n".join(body_lines) body = "\n".join(body_lines)
backend = create_email_backend(settings) backend = create_email_backend(settings)
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
for email in recipients: for email in recipients:
try: try:
await backend.send(to=email, subject=subject, body=body, attachments=[attachment]) await backend.send(
to=email,
subject=subject,
body=body,
attachments=[attachment],
reply_to=reply_to,
)
except EmailSendError as exc: except EmailSendError as exc:
if not exc.retryable: if not exc.retryable:
logger.warning( logger.warning(

View File

@@ -156,9 +156,16 @@ async def notify_session_async(
) )
backend = create_email_backend(get_settings()) backend = create_email_backend(get_settings())
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
for email in pending: for email in pending:
try: try:
await backend.send(to=email, subject=subject, body=text_body, html_body=html_body) await backend.send(
to=email,
subject=subject,
body=text_body,
html_body=html_body,
reply_to=reply_to,
)
except EmailSendError as exc: except EmailSendError as exc:
if not exc.retryable: if not exc.retryable:
logger.warning( logger.warning(