Повторное ревью кодовой базы: docs/reviews/2026-09-28-1243-codebase-review.md (статус 12 замечаний, новые замечания 13–16). Остаток п. 9 (docs/changes/022): - 14 async-функций API, UI и сервисов больше не обращаются к SQLite напрямую — через asyncio.to_thread; jobs.start_jobs стал async (БД в потоке, create_task в event loop); ops._conn для подключения к устройству; - тест-линтер по AST: в async def нет прямых вызовов функций с session_scope — защита от регресса. Тесты: 29 из 29. Стенд: задачи и актор событий в порядке, параллельные запросы не ждут медленного устройства, боевые данные не изменены. Ручная проверка UI пользователем на момент коммита не подтверждена. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ros_control
Централизованное управление парком MikroTik RouterOS: WEB UI и JSON API.
Дизайн и требования — ros_control.svg; макет интерфейса — утверждён и перенесён в приложение (см. docs/changes/006-ui-redesign).
Admin Dashboard ⇄ Control Server ⇄ RouterOS REST (на каждом устройстве)
⇅ ⇅
Metadata DB S3 (Yandex Object Storage)
Возможности
Устройства
- Список устройств: добавление, изменение, удаление; пароли хранятся зашифрованно (Fernet). Имя устройства в таблице кликабельно — открывает окно изменения; само имя задаётся только при создании и не меняется (оно входит в ключи бэкапов).
- К устройству можно добавить примечание (до 500 символов): видно подсказкой при наведении на имя.
- Статус: online/offline, модель, канал обновлений, версии ROS и FW, uptime, время запроса бэкапа. Колонки Upgrade ROS и Upgrade FW показывают версию, до которой можно обновиться (устройство проверяет обновления на серверах MikroTik; без интернета на устройстве — «—»).
- Мониторинг доступности: сервер опрашивает устройства каждые 30 с (
POLL_INTERVAL), недоступность определяется за ~4 с на устройство (ROS_CONNECT_TIMEOUT); страница обновляет статусы сама, без перезагрузки.
Группы и фильтры
- Устройство относится к одной группе: вкладки групп со счётчиками, страница «Группы» (создание, переименование, удаление), перенос отмеченных устройств в группу. При добавлении устройства группу можно создать «на месте» (пункт «+ Новая группа…»).
- Фильтры устройств: поиск (имя, адрес, модель), статус, наличие обновлений, канал. Фильтры бэкапов: группа, устройство, тип файла, период, имя файла. Фильтры попадают в URL.
Операции
- Действия по устройству — в меню «⋯» строки; действия над выбранными устройствами (бэкап, статус, обновление ROS/FW, канал, перенос в группу) появляются в полосе инструментов при выборе строк; через API их можно запускать и над всей группой.
- Бэкап:
.backup(без шифрования) и.rsc(сshow-sensitive, т.е. с паролями и ключами — из него можно восстановить все сущности, использующие пароль). Сервер создаёт файлы через REST, скачивает во временную папку, загружает в S3 и удаляет файлы с устройства. - Список бэкапов в бакете, скачивание (временная ссылка), удаление одного файла или группы выбранных (чекбоксы, кнопка «Удалить» с подтверждением).
- Канал обновлений; обновление ROS (устройство перезагружается после скачивания); обновление FW (перезагрузка сразу после появления в журнале устройства записи «Firmware upgraded successfully…»).
- Долгие и групповые операции выполняются задачами (карточка «Задачи», состояние — в UI и через API).
Идентификаторы и журнал событий
У каждой сущности глобально уникальный ID вида <префикс>_<uuid7>: dev_… устройство, grp_… группа, job_… задача, bkp_… резервная копия, evt_… запись журнала. Префикс исключает пересечение типов, UUIDv7 не повторяется и сортируется по времени создания; ID не переиспользуются после удаления. Числовых ID нет: GET /api/v1/devices/dev_…, ID чужого типа или неверного формата — 404.
Резервная копия — пара файлов (.backup + .rsc) с одним bkp_ ID; файлы в бакете имеют метаданные backup-id/device-id (Yandex возвращает их как Backup-Id/Device-Id), в списке файлов API есть backup_id; копия, у которой пропали файлы, помечается удалённой, но остаётся в истории.
Журнал событий пишется в БД: создание, изменение и удаление устройств и групп, перенос устройств, жизненный цикл задач и бэкапов, смена online/offline, вход в UI, ротация и очистка журнала. Каждая запись содержит evt_ ID, ID сущности, актора (ui:<пользователь>, api, poller, system) и данные.
В UI — страница «Журнал» (фильтры по типу, актору, устройству, периоду и поиску по сообщению/ID; подгрузка «Показать ещё 100»; клик по строке открывает запись с полными ID и данными).
- Ротация: «Журнал → Настройки» — срок хранения (дней) и максимум записей,
0— без ограничения. Настройки хранятся в БД (значения по умолчанию —EVENTS_RETENTION_DAYS=90,EVENTS_MAX_ROWS=100000в.env); ротация выполняется при старте, раз в час и сразу после сохранения настроек и оставляет записьjournal.rotated. - Очистка: «Журнал → Очистить журнал» открывает окно с вводом пароля пользователя. Неверный пароль журнал не трогает (
journal.clear_denied), 5 неверных попыток за 10 минут блокируют очистку на 10 минут. После успешной очистки остаётся одна записьjournal.cleared(кто и сколько удалил). Очистки через API нет — только через это окно; API даёт чтение журнала и настроек.
Интерфейс
Экран строится сверху вниз: приложение (навигация) → страница (заголовок, счётчики, главное действие) → вкладки групп → полоса инструментов таблицы → данные; «Задачи» — отдельная карточка. Пока ничего не выбрано, полоса показывает фильтры; при выборе строк — действия над выбранными.
Добавление и изменение устройств, создание и переименование групп — в окнах поверх страницы (запасные страницы /devices/new, /devices/{id}/edit работают без JavaScript). Выпадающие меню не обрезаются таблицей и раскрываются вверх, если снизу нет места. Выпадающие списки (фильтры устройств/бэкапов/журнала, поле «Группа») оформлены как меню действий «⋯»: список строит JS поверх обычного <select>, который остаётся в разметке скрытым — без JavaScript работает стандартный выбор браузера. Светлая и тёмная темы переключаются по настройке системы. Шрифты IBM Plex лежат в app/ui/static/fonts (лицензия OFL), внешние ресурсы не загружаются.
Требования к устройствам
- Поддерживаются и RouterBOARD, и виртуальные CHR (у них нет
/system/routerboard: версия FW не показывается, обновление FW пропускается). - RouterOS 7.1+, включённый сервис
www-ssl(REST API, порт 443; неapi-ssl8729 и неapi8728). Еслиwww-sslнедоступен, для доверенной сети можно указать сервисwww(порт 80) и снять флаг «HTTPS» у устройства — логин и пароль пойдут открытым текстом. - Пользователь с политиками
read,write,policy,test,ftp,reboot,sensitive(проще всего — группаfull). Встроенная группаwriteс запретомftp/policyне подходит: бэкап падает сnot enough permissions. После смены прав пересоздайте подключение к устройству. - Доступ устройств к S3 не нужен: файлы забирает и загружает Control Server (ему нужен доступ к
storage.yandexcloud.net:443).
Безопасность
- Секреты обязательны:
API_TOKEN,SESSION_SECRET(≥ 32 символов) иADMIN_PASSWORD(≥ 12 символов) не могут быть пустыми или равнымиchange-me;SECRET_KEYдолжен быть валидным ключом Fernet. С небезопасными значениями приложение не стартует (RuntimeErrorпри старте, без секретов в тексте ошибки и в логе) — сгенерировать:python -c "import secrets; print(secrets.token_urlsafe(32))". Порт 8000 не стоит открывать в недоверенную сеть. - Вход в UI ограничен по IP клиента: 5 неверных попыток за 10 минут блокируют IP на 10 минут (в блокировке пароль не проверяется); единственного администратора нельзя заблокировать чужими попытками, т.к. блокировка не привязана к имени пользователя. Ограничение достоверно только пока перед приложением нет reverse-proxy — иначе все запросы приходят с одного IP прокси, и понадобится доверенный
X-Forwarded-For. - Cookie сессии UI:
SESSION_COOKIE_SECURE=falseпо умолчанию (UI по HTTP продолжает работать); включитеtrue, когда приложение работает за TLS. - Пароли устройств шифруются ключом
SECRET_KEY(Fernet); потеря ключа = потеря доступа к сохранённым паролям. - Секреты в бэкапах:
.rscсоздаётся сshow-sensitive, а.backup— без шифрования, поэтому файлы содержат пароли и ключи открытым текстом. Ограничьте доступ к бакету и ссылкам скачивания.
Ограничения
- Один процесс на БД: сервер держит файловую блокировку
<файл БД>.lockрядом с БД (снимается при остановке); второй процесс на той же БД (--workers 2+, вторая копия контейнера на том же томе) не стартует — понятная ошибка вместо молчаливой порчи данных. В памяти процесса (не переживает перезапуск и не разделяется между процессами) — семафор фоновых задач, блокировка синхронизации бэкапов, счётчики неудачных попыток входа в UI и очистки журнала, кэш списка бакета. - Кэш списка бакета: страница «Бэкапы» и
GET /api/v1/backupsне перечитывают бакет на каждый просмотр — список живётBACKUPS_CACHE_TTLсекунд (по умолчанию 60;0— кэш выключен). Изменения бакета, сделанные не через это приложение, видны не позже TTL или сразу — кнопкой «Обновить список» (refresh=1). Собственные изменения (бэкап, удаление) сбрасывают кэш сами.
Запуск
cp .env.example .env # заполнить SECRET_KEY, SESSION_SECRET, ADMIN_PASSWORD, API_TOKEN, S3_*
docker compose up -d --build # UI: http://localhost:8000, OpenAPI: /docs
БД (SQLite) хранится в Docker-томе ros_data (docker volume inspect ros_control_ros_data), переживает пересборку контейнера; схема обновляется автоматически при старте (перед миграцией ID создаётся копия ros_control.db.bak-<метка> рядом с БД). Остановка: docker compose down (том сохраняется; down -v удалит БД).
Ключ Fernet: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())".
Настройки (.env)
| Переменная | По умолчанию | Назначение |
|---|---|---|
SECRET_KEY |
— | ключ Fernet для паролей устройств (обязателен) |
SESSION_SECRET |
— | подпись cookie-сессии UI (обязателен, ≥ 32 символов) |
SESSION_COOKIE_SECURE |
false |
Secure-флаг cookie сессии; включить за TLS |
ADMIN_USER / ADMIN_PASSWORD |
admin / — |
вход в UI (ADMIN_PASSWORD обязателен, ≥ 12 символов) |
API_TOKEN |
— | Bearer-токен API (обязателен, ≥ 32 символов) |
DATABASE_URL |
sqlite:///./data/ros_control.db |
база метаданных (в compose — том) |
S3_ENDPOINT, S3_REGION, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY, S3_PREFIX |
Yandex Object Storage, backups |
бакет для резервных копий |
BACKUPS_CACHE_TTL |
60 |
кэш списка бакета, с; 0 — выключить (см. «Ограничения») |
MAX_CONCURRENCY |
10 |
одновременные обращения к устройствам |
ROS_CONNECT_TIMEOUT |
4 |
секунд на установление соединения (скорость обнаружения недоступности) |
ROS_TIMEOUT |
30 |
секунд на ответ устройства (долгие операции) |
POLL_INTERVAL |
30 |
период фонового опроса, с; 0 — выключить |
UPDATE_CHECK_INTERVAL |
1800 |
как часто опрос проверяет обновления ROS, с |
EVENTS_RETENTION_DAYS / EVENTS_MAX_ROWS |
90 / 100000 |
ротация журнала по умолчанию (действующие значения меняются в UI); 0 — без ограничения |
Разработка
python3 -m venv venv && ./venv/bin/pip install -r requirements.txt
set -a; . ./.env; set +a
./venv/bin/uvicorn app.main:app --reload
./venv/bin/python -m pytest # 29 тестов, фоновый опрос в тестах выключен
API v1
Заголовок Authorization: Bearer <API_TOKEN>. Интерактивная документация — /docs. Пример:
curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devices | python3 -m json.tool
Все id — строки (dev_…, grp_…, job_…); group_id — grp_…. Устройство: name, host, port, username, password (только при записи), verify_tls, use_tls (false — HTTP), group_id (null — без группы), note.
| Метод | Путь | Назначение |
|---|---|---|
| GET/POST | /api/v1/devices |
список (фильтры group, q, status, updates, channel) / добавить |
| GET/PATCH/DELETE | /api/v1/devices/{id} |
получить / изменить (имя менять нельзя — 400) / удалить |
| POST | /api/v1/devices/refresh, /devices/{id}/refresh |
обновить статус |
| POST | /api/v1/devices/{id}/backups |
бэкап (задача) |
| PUT | /api/v1/devices/{id}/update/channel |
{"channel": "stable|long-term|testing|development"} |
| POST | /api/v1/devices/{id}/update/install |
обновление ROS (задача) |
| POST | /api/v1/devices/{id}/firmware/upgrade |
обновление FW (задача) |
| GET/POST | /api/v1/groups |
группы (с числом устройств) / создать |
| PATCH/DELETE | /api/v1/groups/{id} |
переименовать / удалить (устройства остаются без группы) |
| POST | /api/v1/batch/{backup|ros_update|fw_update} |
{"device_ids": [...]} и/или {"group_id": N} — групповая операция (задача) |
| PUT | /api/v1/batch/channel |
{"device_ids": [...] или "group_id": N, "channel": "..."} — групповая смена канала (задача set_channel) → 202 {"job_ids": [...]} |
| GET/DELETE | /api/v1/backups, /backups/download?key= |
бэкапы в бакете (фильтры device_id, group, kind, date_from, date_to, q; refresh=1 — минуя кэш) |
| POST | /api/v1/backups/delete |
групповое удаление файлов: {"keys": [...]} → {"deleted": N, "failed": M} |
| GET | /api/v1/jobs, /jobs/{id} |
состояние задач |
| GET | /api/v1/events, /events/{id} |
журнал событий (фильтры entity_id, type, device_id, job_id, actor, date_from, date_to, q; постранично before=<evt_…>, limit) |
| GET/PUT | /api/v1/events/settings |
настройки ротации журнала {"retention_days", "max_rows"} и сводка (очистки журнала через API нет) |
Внешние справочники: RouterOS REST API, Yandex Object Storage S3 API.
Структура
app/ros— клиент RouterOS REST и операции (статус, бэкап, скачивание файлов, обновления)app/s3.py— бакет;app/security.py— шифрование и авторизация;app/models.py,app/db.py— БД и миграцииapp/services— устройства и фильтры, группы, бэкапы, операции, задачи, фоновый опрос (poller.py)app/api— JSON API;app/ui— WEB UI (Jinja2 + HTMX,static/со стилями, скриптом и шрифтами)tests— минимальный набор;docs/changes— планы и итоги каждого измененияdocs/reviews— ревью кодовой базы (замечания и приоритеты исправлений)
История изменений
Планы и итоги — в docs/changes/:
001-initial-implementation— первая версия.002-http-support-and-fixes— HTTP-подключение, исправления по итогам теста на реальном устройстве.003-download-via-rest-upload-from-server— бэкап: скачивание файлов через REST, загрузка в S3 сервером.004-upgrade-columns-and-actions-menu— колонки Upgrade ROS/FW, меню действий «⋯».005-groups-and-filters— группы устройств, фильтры устройств и бэкапов.006-ui-redesign— новый интерфейс по утверждённому макету; исправлены перезагрузка после обновления FW и привязка группы при добавлении устройства.007-fast-offline-detection— фоновый опрос устройств (30 с), быстрый таймаут соединения, автообновление страницы.008-device-note— примечание к устройству.009-dialog-label-alignment— выравнивание полей в окне устройства.010-export-show-sensitive—.rscсоздаётся сshow-sensitive(секреты в файле).011-dropdown-clipping— выпадающие меню не обрезаются в узком окне.012-clickable-device-name— клик по имени устройства открывает окно изменения.013-bulk-delete-backups— выбор файлов чекбоксами и групповое удаление бэкапов.014-chr-support— поддержка CHR (нет/system/routerboard), строгое сравнение версий ROS, причина недоступности в таблице.015-immutable-device-name— имя устройства задаётся только при создании.016-unique-ids-and-event-log— глобально уникальные ID (префикс + UUIDv7) для всех сущностей, журнал событий в БД, миграция числовых ID.017-events-ui-rotation— страница «Журнал» в UI, настройки ротации, очистка журнала с подтверждением паролем.018-correctness-consistency— одиночное удаление бэкапа в UI через общий сервисdelete_many, единая система миграций (колонки старой схемы — вmigrations.runдо миграции ID), групповая смена канала фоновыми задачами (set_channel).019-performance-scaling— кэш списка бакета (BACKUPS_CACHE_TTL, «Обновить список»); обработчики без обращений к event loop — обычные функции (пул потоков FastAPI), запись статуса и тяжёлые операции с БД в фоне — черезasyncio.to_thread; SQLite — WAL иbusy_timeout; файловая блокировка БД — один процесс на БД.020-custom-select-menus— выпадающие списки (фильтры, «Группа») в стиле меню действий «⋯»: прогрессивное улучшение в JS, нативныйselectостаётся в разметке и работает без JavaScript.021-security-hardening— отказ старта при небезопасных секретах (API_TOKEN/SESSION_SECRET/ADMIN_PASSWORD/SECRET_KEY), блокировка входа в UI по IP клиента,SESSION_COOKIE_SECURE, безопасныйnextв редиректах (/ui/move,/backups/delete-many).022-async-db-remainder— остаток п. 9 ревью: оставшиеся синхронные обращения к БД в async-обработчиках (API, UI,ops,backups.search) — черезasyncio.to_thread;jobs.start_jobsразделён на синхронную_create_jobs(в потоке) иasync start_jobs(создаёт задачи и планирует их в event loop); регрессионный тест-линтер (AST-обходapp/) не даёт синхронным обращениям к БД вернуться в async-код.