Все выпадающие списки (фильтры «Устройства», «Бэкапы», «Журнал», поле «Группа» в окне устройства) оформлены как меню «⋯» (docs/changes/020): - app.js строит меню поверх скрытого select: значение уходит с формой, без JavaScript работает стандартный список; выбор отправляет change — существующие onchange/hx-trigger не менялись; - позиционирование — существующий placeMenu, выбранный пункт отмечен «✓», клавиатура: Tab, Enter/Space, ↑/↓, Escape; - Escape при открытом списке в окне закрывает только список. Тесты: 24 из 24. Автотестов JS нет; ручная проверка 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на случайные (openssl rand -hex 24); порт 8000 не стоит открывать в недоверенную сеть. - Пароли устройств шифруются ключом
SECRET_KEY(Fernet); потеря ключа = потеря доступа к сохранённым паролям. - Секреты в бэкапах:
.rscсоздаётся сshow-sensitive, а.backup— без шифрования, поэтому файлы содержат пароли и ключи открытым текстом. Ограничьте доступ к бакету и ссылкам скачивания.
Ограничения
- Один процесс на БД: сервер держит файловую блокировку
<файл БД>.lockрядом с БД (снимается при остановке); второй процесс на той же БД (--workers 2+, вторая копия контейнера на том же томе) не стартует — понятная ошибка вместо молчаливой порчи данных. В памяти процесса (не переживает перезапуск и не разделяется между процессами) — семафор фоновых задач, блокировка синхронизации бэкапов, счётчики неудачных попыток очистки журнала и кэш списка бакета. - Кэш списка бакета: страница «Бэкапы» и
GET /api/v1/backupsне перечитывают бакет на каждый просмотр — список живётBACKUPS_CACHE_TTLсекунд (по умолчанию 60;0— кэш выключен). Изменения бакета, сделанные не через это приложение, видны не позже TTL или сразу — кнопкой «Обновить список» (refresh=1). Собственные изменения (бэкап, удаление) сбрасывают кэш сами.
Запуск
cp .env.example .env # заполнить SECRET_KEY, 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 |
change-me |
подпись cookie-сессии UI |
ADMIN_USER / ADMIN_PASSWORD |
admin / change-me |
вход в UI |
API_TOKEN |
change-me |
Bearer-токен API |
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 # 24 теста, фоновый опрос в тестах выключен
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.